diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdcaa93..b8015c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,4 +18,5 @@ jobs: python-version: ${{ matrix.python-version }} - run: uv run pytest -q --timeout=600 - run: uv run pyright src + - run: uv run lint-imports - run: uv build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b62f8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,100 @@ +# Changelog + +All notable changes to `formal-argumentation` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-05-22 + +### Changed — BREAKING + +The package was reorganized from a flat module layout (56 modules in one +directory) into layered subpackages. **Every import path changed.** The +distribution name (`formal-argumentation`) and the import root (`argumentation`) +are unchanged; only the dotted paths of individual modules changed. + +- `import argumentation` no longer eagerly imports submodules, and + `argumentation.__all__` was removed. Import the specific module you need by + its new layered path (see the table below). +- The `iccma-cli` console-script entry point moved from + `argumentation.iccma_cli:main` to `argumentation.solving.iccma_cli:main`. + The `iccma-cli` command itself is unchanged; the `python -m` form is now + `python -m argumentation.solving.iccma_cli`. +- The architecture is now enforced by an `import-linter` layered contract + (`pyproject.toml` `[tool.importlinter]`): a module may import only from its + own layer or a strictly lower layer. `uv run lint-imports` checks the DAG. + +There are no compatibility shims and no re-exports from the old paths. Old +imports fail cleanly with `ModuleNotFoundError`. Update every import to its new +path using the table below. + +`argumentation.semantics` stays a top-level module; the `argumentation.encodings` +data directory and the `argumentation.solver_adapters` subpackage +(`clingo`, `iccma_aba`, `iccma_af`) are unchanged. + +#### Complete old → new import-path table + +| Old path | New path | +|---|---| +| `argumentation.dung` | `argumentation.core.dung` | +| `argumentation.labelling` | `argumentation.core.labelling` | +| `argumentation.preference` | `argumentation.core.preference` | +| `argumentation.solver_results` | `argumentation.core.solver_results` | +| `argumentation.preprocessing` | `argumentation.core.preprocessing` | +| `argumentation.scc_recursive` | `argumentation.core.scc_recursive` | +| `argumentation.bipolar` | `argumentation.core.bipolar` | +| `argumentation.accrual` | `argumentation.core.accrual` | +| `argumentation.aspic` | `argumentation.structured.aspic.aspic` | +| `argumentation.aspic_encoding` | `argumentation.structured.aspic.aspic_encoding` | +| `argumentation.aspic_incomplete` | `argumentation.structured.aspic.aspic_incomplete` | +| `argumentation.subjective_aspic` | `argumentation.structured.aspic.subjective_aspic` | +| `argumentation.datalog_grounding` | `argumentation.structured.aspic.datalog_grounding` | +| `argumentation.aba` | `argumentation.structured.aba.aba` | +| `argumentation.aba_sat` | `argumentation.structured.aba.aba_sat` | +| `argumentation.aba_asp` | `argumentation.structured.aba.aba_asp` | +| `argumentation.aba_decomposition` | `argumentation.structured.aba.aba_decomposition` | +| `argumentation.aba_incremental` | `argumentation.structured.aba.aba_incremental` | +| `argumentation.aba_preprocessing` | `argumentation.structured.aba.aba_preprocessing` | +| `argumentation.aba_route_policy` | `argumentation.structured.aba.aba_route_policy` | +| `argumentation.aba_telemetry` | `argumentation.structured.aba.aba_telemetry` | +| `argumentation.adf` | `argumentation.frameworks.adf` | +| `argumentation.setaf` | `argumentation.frameworks.setaf` | +| `argumentation.setaf_io` | `argumentation.frameworks.setaf_io` | +| `argumentation.caf` | `argumentation.frameworks.caf` | +| `argumentation.vaf` | `argumentation.frameworks.vaf` | +| `argumentation.vaf_completion` | `argumentation.frameworks.vaf_completion` | +| `argumentation.partial_af` | `argumentation.frameworks.partial_af` | +| `argumentation.practical_reasoning` | `argumentation.frameworks.practical_reasoning` | +| `argumentation.gradual` | `argumentation.gradual.gradual` | +| `argumentation.dfquad` | `argumentation.gradual.dfquad` | +| `argumentation.equational` | `argumentation.gradual.equational` | +| `argumentation.gradual_principles` | `argumentation.gradual.gradual_principles` | +| `argumentation.llm_surface` | `argumentation.gradual.llm_surface` | +| `argumentation.sensitivity` | `argumentation.gradual.sensitivity` | +| `argumentation.ranking` | `argumentation.ranking.ranking` | +| `argumentation.ranking_axioms` | `argumentation.ranking.ranking_axioms` | +| `argumentation.weighted` | `argumentation.ranking.weighted` | +| `argumentation.matt_toni` | `argumentation.ranking.matt_toni` | +| `argumentation.probabilistic` | `argumentation.probabilistic.probabilistic` | +| `argumentation.probabilistic_components` | `argumentation.probabilistic.probabilistic_components` | +| `argumentation.probabilistic_treedecomp` | `argumentation.probabilistic.probabilistic_treedecomp` | +| `argumentation.epistemic` | `argumentation.probabilistic.epistemic` | +| `argumentation.enforcement` | `argumentation.dynamics.enforcement` | +| `argumentation.dynamic` | `argumentation.dynamics.dynamic` | +| `argumentation.af_revision` | `argumentation.dynamics.af_revision` | +| `argumentation.approximate` | `argumentation.dynamics.approximate` | +| `argumentation.optimization` | `argumentation.dynamics.optimization` | +| `argumentation.iccma` | `argumentation.interop.iccma` | +| `argumentation.af_sat` | `argumentation.solving.af_sat` | +| `argumentation.sat_encoding` | `argumentation.solving.sat_encoding` | +| `argumentation.backends` | `argumentation.solving.backends` | +| `argumentation.solver` | `argumentation.solving.solver` | +| `argumentation.solver_differential` | `argumentation.solving.solver_differential` | +| `argumentation.iccma_cli` | `argumentation.solving.iccma_cli` | +| `argumentation.semantics` | `argumentation.semantics` (unchanged — top-level module) | + +The `argumentation.solver_adapters.clingo`, `argumentation.solver_adapters.iccma_aba`, +and `argumentation.solver_adapters.iccma_af` paths are unchanged. + +[0.3.0]: https://github.com/ctoth/argumentation/releases/tag/v0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bd65f4..a4ccc94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Run checks from the repository root: ```powershell uv run pyright src +uv run lint-imports uv run pytest -vv ``` @@ -16,6 +17,30 @@ The optional Z3 backend is tested by the default development environment. Keep the base package free of mandatory runtime dependencies unless a formal kernel module genuinely requires one. +## Package layout + +`argumentation` is organized into layered subpackages. A module imports only +from its own layer or a strictly lower layer. From the base upward: + +1. `argumentation.core` — Dung, labelling, preference, bipolar, accrual, and + shared solver-result and preprocessing primitives. +2. `argumentation.structured.aspic`, `argumentation.frameworks`, + `argumentation.gradual`, `argumentation.ranking` — framework families built + on `core`. `gradual` and `ranking` are additionally independent of each + other. +3. `argumentation.structured.aba`, `argumentation.probabilistic`, + `argumentation.dynamics` — built on `core` and the layer-2 families. +4. `argumentation.interop` — exchange-format I/O. +5. `argumentation.solver_adapters` — external-solver subprocess adapters. +6. `argumentation.solving` — solver orchestration and SAT encodings. +7. `argumentation.semantics` — the topmost generic dispatcher. + +A new module goes in the subpackage of its correct layer. `uv run lint-imports` +enforces the DAG via the `[tool.importlinter]` contract in `pyproject.toml`: an +upward import (a lower layer importing a higher one) fails CI. See +`docs/architecture.md` for the full layer contract and the two sanctioned +function-local `solver_adapters.clingo` exceptions. + ## Boundary Package code and tests must not import application-layer storage, provenance, diff --git a/README.md b/README.md index 57c66e2..72125eb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ useful) page that fixes its behaviour. The pure-Python implementations are the reference for package algorithms; solver adapters are typed boundaries around external tools. +The package is organized into layered subpackages — `core`, +`structured.aspic`, `structured.aba`, `frameworks`, `gradual`, `ranking`, +`probabilistic`, `dynamics`, `interop`, `solving`, `solver_adapters` — with an +`import-linter`-enforced import DAG. See `docs/architecture.md` for the layer +contract and [`CHANGELOG.md`](CHANGELOG.md) for the 0.3.0 breaking-change +details (all import paths changed in 0.3.0). + ## Contents - [Install](#install) @@ -45,9 +52,9 @@ Optional extras unlock specific surfaces: | Extra | What it unlocks | Pulls | |---|---|---| -| `[z3]` | `argumentation.epistemic` linear atomic constraint satisfiability / entailment and the `argumentation.af_sat` SAT backend | `z3-solver>=4.12` | -| `[asp]` | Clingo-backed ABA solving (`argumentation.aba_asp`) and ASP backends in `argumentation.aspic_encoding` | `clingo>=5.7` | -| `[grounding]` | Datalog-style grounding of defeasible theories into ASPIC+ (`argumentation.datalog_grounding`) | [`gunray`](https://github.com/ctoth/gunray) (sourced from git, not PyPI) | +| `[z3]` | `argumentation.probabilistic.epistemic` linear atomic constraint satisfiability / entailment and the `argumentation.solving.af_sat` SAT backend | `z3-solver>=4.12` | +| `[asp]` | Clingo-backed ABA solving (`argumentation.structured.aba.aba_asp`) and ASP backends in `argumentation.structured.aspic.aspic_encoding` | `clingo>=5.7` | +| `[grounding]` | Datalog-style grounding of defeasible theories into ASPIC+ (`argumentation.structured.aspic.datalog_grounding`) | [`gunray`](https://github.com/ctoth/gunray) (sourced from git, not PyPI) | ```powershell uv add "formal-argumentation[z3]" @@ -62,7 +69,7 @@ The `[grounding]` extra requires resolving `gunray` from its git URL; see A Dung framework, three semantics, in eight lines: ```python -from argumentation.dung import ( +from argumentation.core.dung import ( ArgumentationFramework, grounded_extension, preferred_extensions, @@ -85,23 +92,23 @@ dispatch, the SAT encoder, the probabilistic kernel, and the ICCMA writer. ## Surface tour -| Family | Modules | One-line summary | +| Family | Subpackage | One-line summary | |---|---|---| -| [Core](#core) | `dung`, `labelling`, `preference`, `semantics` | Dung 1995 + Caminada/Gaggl-Woltran/DMT extensions, three-valued labellings, preference primitives, generic dispatch | -| [Structured](#structured) | `aspic`, `aspic_encoding`, `aspic_incomplete`, `subjective_aspic`, `aba`, `aba_asp`, `aba_sat`, `accrual` | ASPIC+ argument construction, ASP-style encoding, incomplete-premise reasoning, flat ABA / ABA+ with native, ASP, and SAT backends | -| [Quantitative and bipolar](#quantitative-and-bipolar) | `bipolar`, `gradual`, `gradual_principles`, `ranking`, `ranking_axioms`, `weighted`, `dfquad`, `equational`, `matt_toni` | Cayrol bipolar, Potyka quadratic energy + Shapley impacts, Categoriser/Burden rankings, Dunne-style weighted, DF-QuAD, Gabbay equational, zero-sum game strengths | -| [Probabilistic and epistemic](#probabilistic-and-epistemic) | `probabilistic`, `epistemic` | PrAFs over seven strategies (Monte Carlo, exact enum, tree-decomp DP, paper-faithful Popescu-Wallner, DF-QuAD), epistemic graphs with Z3-backed constraints | -| [Specialized frameworks](#specialized-frameworks) | `adf`, `setaf`, `setaf_io`, `caf`, `vaf`, `vaf_completion`, `practical_reasoning` | Brewka-Woltran ADFs with typed acceptance ASTs, collective-attack SETAFs, claim-augmented AFs, Bench-Capon value-based, Atkinson AATS practical arguments | -| [Dynamics and revision](#dynamics-revision-enforcement) | `partial_af`, `af_revision`, `dynamic`, `enforcement`, `approximate` | Partial AFs and completions, Baumann/Diller revision, dynamic update streams, minimal-change enforcement, k-stable approximation | -| [Encoding and interop](#encoding-and-interop) | `iccma`, `iccma_cli`, `sat_encoding`, `af_sat`, `aba_sat`, `datalog_grounding`, `llm_surface` | ICCMA AF/ADF/ABA exchange, pure-Python SAT encodings, incremental AF SAT kernel, Gunray-grounded ASPIC+, QBAF adapter for argumentative LLM pipelines | -| [Solver orchestration](#solver-surfaces) | `solver`, `solver_results`, `solver_differential`, `backends`, `solver_adapters/` | Typed solver tasks, capability detection, default backend routing, ICCMA / clingo subprocess adapters | - -`docs/architecture.md` covers the kernel-and-adapters design in depth. +| [Core](#core) | `core.dung`, `core.labelling`, `core.preference`, `semantics` | Dung 1995 + Caminada/Gaggl-Woltran/DMT extensions, three-valued labellings, preference primitives, generic dispatch | +| [Structured](#structured) | `structured.aspic.*`, `structured.aba.*`, `core.accrual` | ASPIC+ argument construction, ASP-style encoding, incomplete-premise reasoning, flat ABA / ABA+ with native, ASP, and SAT backends | +| [Quantitative and bipolar](#quantitative-and-bipolar) | `core.bipolar`, `gradual.*`, `ranking.*` | Cayrol bipolar, Potyka quadratic energy + Shapley impacts, Categoriser/Burden rankings, Dunne-style weighted, DF-QuAD, Gabbay equational, zero-sum game strengths | +| [Probabilistic and epistemic](#probabilistic-and-epistemic) | `probabilistic.probabilistic`, `probabilistic.epistemic` | PrAFs over seven strategies (Monte Carlo, exact enum, tree-decomp DP, paper-faithful Popescu-Wallner, DF-QuAD), epistemic graphs with Z3-backed constraints | +| [Specialized frameworks](#specialized-frameworks) | `frameworks.*` | Brewka-Woltran ADFs with typed acceptance ASTs, collective-attack SETAFs, claim-augmented AFs, Bench-Capon value-based, Atkinson AATS practical arguments | +| [Dynamics and revision](#dynamics-revision-enforcement) | `frameworks.partial_af`, `dynamics.*` | Partial AFs and completions, Baumann/Diller revision, dynamic update streams, minimal-change enforcement, k-stable approximation | +| [Encoding and interop](#encoding-and-interop) | `interop.iccma`, `solving.sat_encoding`, `solving.af_sat`, `structured.aspic.datalog_grounding`, `gradual.llm_surface` | ICCMA AF/ADF/ABA exchange, pure-Python SAT encodings, incremental AF SAT kernel, Gunray-grounded ASPIC+, QBAF adapter for argumentative LLM pipelines | +| [Solver orchestration](#solver-surfaces) | `solving.*`, `solver_adapters/` | Typed solver tasks, capability detection, default backend routing, ICCMA / clingo subprocess adapters | + +`docs/architecture.md` covers the layered kernel-and-adapters design in depth. `docs/backends.md` documents the backend selection rule. ## Core -`argumentation.dung` provides the four canonical Dung semantics — +`argumentation.core.dung` provides the four canonical Dung semantics — `grounded_extension`, `complete_extensions`, `preferred_extensions`, `stable_extensions` — together with `naive_extensions`, `semi_stable_extensions` (Caminada 2011), `stage_extensions`, @@ -112,11 +119,11 @@ a pre-preference `attacks` relation (used by conflict-freeness) and the post-preference `defeats` relation (used by defence), following Modgil & Prakken (2018) Def 14. -`argumentation.labelling` exposes the three-valued IN / OUT / UNDEC labelling -and a bridge from extensions to labellings: +`argumentation.core.labelling` exposes the three-valued IN / OUT / UNDEC +labelling and a bridge from extensions to labellings: ```python -from argumentation.labelling import Labelling +from argumentation.core.labelling import Labelling labelling = Labelling.from_extension(af, frozenset({"a", "c"})) labelling.in_arguments # frozenset({"a", "c"}) @@ -125,9 +132,9 @@ labelling.undecided_arguments # frozenset() labelling.range # in ∪ out ``` -`argumentation.preference` provides preference primitives used across ASPIC+ -and revision: `strict_partial_order_closure` (transitive closure with cycle -and reflexivity rejection), `strictly_weaker` (elitist and democratic +`argumentation.core.preference` provides preference primitives used across +ASPIC+ and revision: `strict_partial_order_closure` (transitive closure with +cycle and reflexivity rejection), `strictly_weaker` (elitist and democratic comparisons over numeric strength vectors, Modgil & Prakken Def 19), and `defeat_holds` (generic attack-to-defeat resolution). @@ -152,16 +159,17 @@ accepted_arguments(af, semantics="preferred", mode="credulous") ## Structured -`argumentation.aspic` builds arguments from a knowledge base and a set of -strict and defeasible rules over a logical language with a contrariness -function, then derives attacks and defeats. The full ASPIC+ surface includes -`build_arguments`, `compute_attacks`, `compute_defeats`, argument accessors -(`conc`, `prem`, `sub`, `top_rule`, `def_rules`, `last_def_rules`, `prem_p`, -`is_firm`, `is_strict`), `transposition_closure`, `strict_closure`, -`is_c_consistent`, and a `CSAF` type packaging the constructed structured AF. +`argumentation.structured.aspic.aspic` builds arguments from a knowledge base +and a set of strict and defeasible rules over a logical language with a +contrariness function, then derives attacks and defeats. The full ASPIC+ +surface includes `build_arguments`, `compute_attacks`, `compute_defeats`, +argument accessors (`conc`, `prem`, `sub`, `top_rule`, `def_rules`, +`last_def_rules`, `prem_p`, `is_firm`, `is_strict`), `transposition_closure`, +`strict_closure`, `is_c_consistent`, and a `CSAF` type packaging the +constructed structured AF. ```python -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, ContrarinessFn, GroundAtom, KnowledgeBase, Literal, PreferenceConfig, Rule, build_arguments, compute_attacks, compute_defeats, ) @@ -186,28 +194,29 @@ arguments = build_arguments(system, kb) defeats = compute_defeats(compute_attacks(arguments, system), arguments, system, kb, pref) ``` -`argumentation.aspic_encoding` encodes ASPIC+ theories into a deterministic -ASP-style fact vocabulary (Lehtonen, Niskanen & Järvisalo 2024) and provides a -typed grounded-query surface backed by either the materialised reference -projection or an optional registered backend (e.g. clingo via the `[asp]` -extra). - -`argumentation.aspic_incomplete` reasons over ASPIC+ theories with optional -ordinary premises. `evaluate_incomplete_grounded` enumerates all completions -of the unknown premises and classifies a query literal as `stable`, -`relevant`, `unknown`, or `unsupported`. - -`argumentation.subjective_aspic` implements Wallner-style value filtering -before ASPIC+ argument construction. `argumentation.accrual` exposes -Prakken-style weak/strong applicability checks and accrual envelopes for -same-conclusion arguments. - -`argumentation.aba` implements flat ABA and ABA+ over ASPIC literals, -including complete, preferred, stable, naive, grounded, well-founded, and -ideal assumption-extension functions plus a Dung projection. -`argumentation.aba_sat` provides task-directed support-mask SAT enumeration -for stable, complete, and preferred extensions. `argumentation.aba_asp` -provides clingo-backed extension queries when the `[asp]` extra is installed. +`argumentation.structured.aspic.aspic_encoding` encodes ASPIC+ theories into a +deterministic ASP-style fact vocabulary (Lehtonen, Niskanen & Järvisalo 2024) +and provides a typed grounded-query surface backed by either the materialised +reference projection or an optional registered backend (e.g. clingo via the +`[asp]` extra). + +`argumentation.structured.aspic.aspic_incomplete` reasons over ASPIC+ theories +with optional ordinary premises. `evaluate_incomplete_grounded` enumerates all +completions of the unknown premises and classifies a query literal as +`stable`, `relevant`, `unknown`, or `unsupported`. + +`argumentation.structured.aspic.subjective_aspic` implements Wallner-style +value filtering before ASPIC+ argument construction. +`argumentation.core.accrual` exposes Prakken-style weak/strong applicability +checks and accrual envelopes for same-conclusion arguments. + +`argumentation.structured.aba.aba` implements flat ABA and ABA+ over ASPIC +literals, including complete, preferred, stable, naive, grounded, +well-founded, and ideal assumption-extension functions plus a Dung +projection. `argumentation.structured.aba.aba_sat` provides task-directed +support-mask SAT enumeration for stable, complete, and preferred extensions. +`argumentation.structured.aba.aba_asp` provides clingo-backed extension +queries when the `[asp]` extra is installed. > Modgil, S. & Prakken, H. (2018). A general account of argumentation with > preferences. *Artificial Intelligence*, 248, 51–104. @@ -218,12 +227,12 @@ provides clingo-backed extension queries when the `[asp]` extra is installed. ## Quantitative and bipolar -`argumentation.bipolar` adds an explicit support relation alongside defeats. -Support chains induce *derived* defeats (supported and indirect), computed to -a fixpoint, and yield d-, s-, and c-admissibility variants. +`argumentation.core.bipolar` adds an explicit support relation alongside +defeats. Support chains induce *derived* defeats (supported and indirect), +computed to a fixpoint, and yield d-, s-, and c-admissibility variants. ```python -from argumentation.bipolar import ( +from argumentation.core.bipolar import ( BipolarArgumentationFramework, cayrol_derived_defeats, d_preferred_extensions, ) @@ -236,13 +245,13 @@ baf = BipolarArgumentationFramework( cayrol_derived_defeats(baf.defeats, baf.supports) # {("a", "c")} ``` -`argumentation.ranking` provides non-binary acceptability rankings — +`argumentation.ranking.ranking` provides non-binary acceptability rankings — Categoriser scores, iterative Burden numbers, and others. Results expose `scores`, `ranking` (a tuple of frozensets, one per tier, best first), `converged`, `iterations`, and `semantics`: ```python -from argumentation.ranking import categoriser_ranking +from argumentation.ranking.ranking import categoriser_ranking result = categoriser_ranking(af) result.scores # {"a": 0.618..., "b": 0.618..., ...} @@ -250,27 +259,30 @@ result.ranking # tuple of frozensets, best tier first result.converged # True / False ``` -`argumentation.weighted` implements Dunne-style weighted argument systems by -enumerating attack sets whose deleted weight fits an inconsistency budget. +`argumentation.ranking.weighted` implements Dunne-style weighted argument +systems by enumerating attack sets whose deleted weight fits an inconsistency +budget. -`argumentation.gradual` computes Potyka-style quadratic-energy strengths for -weighted bipolar graphs, exposes revised direct-impact attribution, and -computes exact Shapley-style per-attack impact scores (Al Anaissy et al. 2024 -Def 13). +`argumentation.gradual.gradual` computes Potyka-style quadratic-energy +strengths for weighted bipolar graphs, exposes revised direct-impact +attribution, and computes exact Shapley-style per-attack impact scores +(Al Anaissy et al. 2024 Def 13). -`argumentation.dfquad` exposes DF-QuAD aggregation/combination and strength -propagation. `argumentation.equational` provides iterative equational -fixpoint scoring schemes. `argumentation.matt_toni` computes finite zero-sum -game strengths and raises when the game matrix is too large for the in-package -solver. +`argumentation.gradual.dfquad` exposes DF-QuAD aggregation/combination and +strength propagation. `argumentation.gradual.equational` provides iterative +equational fixpoint scoring schemes. `argumentation.ranking.matt_toni` +computes finite zero-sum game strengths and raises when the game matrix is too +large for the in-package solver. -`argumentation.gradual_principles` and `argumentation.ranking_axioms` contain -executable checks for balance, directionality, monotonicity, ranking -preorder, void-precedence, and cardinality-precedence obligations. +`argumentation.gradual.gradual_principles` and +`argumentation.ranking.ranking_axioms` contain executable checks for balance, +directionality, monotonicity, ranking preorder, void-precedence, and +cardinality-precedence obligations. -`argumentation.vaf` implements Bench-Capon value-based argumentation -frameworks. `argumentation.llm_surface` is a dependency-free QBAF adapter for -argumentative LLM pipelines (Freedman et al. 2025). +`argumentation.frameworks.vaf` implements Bench-Capon value-based +argumentation frameworks. `argumentation.gradual.llm_surface` is a +dependency-free QBAF adapter for argumentative LLM pipelines (Freedman et +al. 2025). > Cayrol, C. & Lagasquie-Schiex, M.-C. (2005). On the acceptability of > arguments in bipolar argumentation frameworks. In *ECSQARU 2005*. @@ -281,13 +293,13 @@ argumentative LLM pipelines (Freedman et al. 2025). ## Probabilistic and epistemic -`argumentation.probabilistic` implements probabilistic argumentation -frameworks (PrAFs). Each argument has an existence probability and each -defeat has a presence probability; acceptance is the probability over sampled -worlds. +`argumentation.probabilistic.probabilistic` implements probabilistic +argumentation frameworks (PrAFs). Each argument has an existence probability +and each defeat has a presence probability; acceptance is the probability over +sampled worlds. ```python -from argumentation.probabilistic import ( +from argumentation.probabilistic.probabilistic import ( ProbabilisticAF, compute_probabilistic_acceptance, ) @@ -324,10 +336,11 @@ exact-set extension probability is opt-in via `query_kind="extension_probability with `queried_set=...`. `summarize_defeat_relations` exposes exact defeat marginals as a diagnostic. -`argumentation.epistemic` represents epistemic graphs with positive and -negative influences over belief levels, finite model enumeration, evidence -updates, and projection to constellation PrAFs. Install `[z3]` to use its -linear atomic constraint satisfiability and entailment helpers. +`argumentation.probabilistic.epistemic` represents epistemic graphs with +positive and negative influences over belief levels, finite model +enumeration, evidence updates, and projection to constellation PrAFs. Install +`[z3]` to use its linear atomic constraint satisfiability and entailment +helpers. > Li, H., Oren, N., & Norman, T. J. (2012). Probabilistic argumentation > frameworks. In *TAFA 2011*. @@ -338,45 +351,46 @@ linear atomic constraint satisfiability and entailment helpers. ## Specialized frameworks -`argumentation.adf` implements abstract dialectical frameworks with typed -acceptance-condition ASTs, three-valued interpretations, +`argumentation.frameworks.adf` implements abstract dialectical frameworks with +typed acceptance-condition ASTs, three-valued interpretations, grounded/admissible/complete/model/preferred/stable model enumeration, structural link classification, JSON/formula I/O helpers, and Dung bridges. -`argumentation.setaf` implements argumentation frameworks with collective -attacks (conflict-free, admissible, complete, preferred, grounded, stable, -semi-stable, stage). `argumentation.setaf_io` provides ASPARTIX fact I/O plus -compact deterministic SETAF parser/writer helpers. See `docs/setaf.md` for -semantics details. +`argumentation.frameworks.setaf` implements argumentation frameworks with +collective attacks (conflict-free, admissible, complete, preferred, grounded, +stable, semi-stable, stage). `argumentation.frameworks.setaf_io` provides +ASPARTIX fact I/O plus compact deterministic SETAF parser/writer helpers. See +`docs/setaf.md` for semantics details. -`argumentation.caf` implements claim-augmented AFs with inherited and -claim-level extension views plus a concurrence checker. See +`argumentation.frameworks.caf` implements claim-augmented AFs with inherited +and claim-level extension views plus a concurrence checker. See `docs/caf-semantics.md`. -`argumentation.vaf` implements Bench-Capon value-based argumentation -frameworks: audience-specific defeat removes attacks whose target value is -preferred to the attacker value, and objective/subjective acceptance quantify -over audience orders. `argumentation.vaf_completion` adds finite -argument-chain and audience helpers for fact-uncertainty completions. +`argumentation.frameworks.vaf` implements Bench-Capon value-based +argumentation frameworks: audience-specific defeat removes attacks whose +target value is preferred to the attacker value, and objective/subjective +acceptance quantify over audience orders. +`argumentation.frameworks.vaf_completion` adds finite argument-chain and +audience helpers for fact-uncertainty completions. -`argumentation.practical_reasoning` implements the Atkinson and Bench-Capon -AATS grounding for AS1-style practical arguments and the CQ5, CQ6, and CQ11 -choice-stage objections. +`argumentation.frameworks.practical_reasoning` implements the Atkinson and +Bench-Capon AATS grounding for AS1-style practical arguments and the CQ5, CQ6, +and CQ11 choice-stage objections. ## Dynamics, revision, enforcement -`argumentation.partial_af` represents AFs that leave some attack pairs -*uncertain*. Pairs over A × A are partitioned into `attacks`, `ignorance`, and -`non_attacks`; reasoning is by enumerating *completions*. The module also -provides three merge aggregations (`sum_merge_frameworks`, +`argumentation.frameworks.partial_af` represents AFs that leave some attack +pairs *uncertain*. Pairs over A × A are partitioned into `attacks`, +`ignorance`, and `non_attacks`; reasoning is by enumerating *completions*. The +module also provides three merge aggregations (`sum_merge_frameworks`, `max_merge_frameworks`, `leximax_merge_frameworks`) and `consensual_expand`. -`argumentation.af_revision` adds arguments and attacks to an existing +`argumentation.dynamics.af_revision` adds arguments and attacks to an existing framework, or revises an extension state by a formula or by a target framework, while preserving rationality postulates: ```python -from argumentation.af_revision import ( +from argumentation.dynamics.af_revision import ( baumann_2015_kernel_union_expand, cayrol_2014_classify_grounded_argument_addition, AFChangeKind, @@ -391,16 +405,17 @@ kind = cayrol_2014_classify_grounded_argument_addition( # DESTRUCTIVE | EXPANSIVE | CONSERVATIVE | ALTERING ``` -`argumentation.dynamic` provides a recompute-from-scratch dynamic AF wrapper -with argument/attack update streams and credulous/skeptical queries after -each state transition. +`argumentation.dynamics.dynamic` provides a recompute-from-scratch dynamic AF +wrapper with argument/attack update streams and credulous/skeptical queries +after each state transition. -`argumentation.enforcement` provides a brute-force minimal-change oracle for -argument and extension enforcement, returning typed witness edits, the edited -framework, and the resulting extensions. +`argumentation.dynamics.enforcement` provides a brute-force minimal-change +oracle for argument and extension enforcement, returning typed witness edits, +the edited framework, and the resulting extensions. -`argumentation.approximate` exposes k-stable semantics, bounded grounded -iteration, and budgeted semi-stable approximation with exactness metadata. +`argumentation.dynamics.approximate` exposes k-stable semantics, bounded +grounded iteration, and budgeted semi-stable approximation with exactness +metadata. > Baumann, R. (2015). Context-free and context-sensitive kernels: update and > deletion equivalence in abstract argumentation. In *ECAI 2014*. @@ -412,33 +427,33 @@ iteration, and budgeted semi-stable approximation with exactness metadata. ## Encoding and interop -`argumentation.iccma` reads and writes ICCMA-style AF, ADF, and ABA exchange -formats: +`argumentation.interop.iccma` reads and writes ICCMA-style AF, ADF, and ABA +exchange formats: ```python -from argumentation.iccma import parse_af, write_af +from argumentation.interop.iccma import parse_af, write_af af = parse_af("p af 3\n1 2\n2 3\n") text = write_af(af) ``` -`argumentation.sat_encoding` provides a pure-Python CNF encoding of stable -extension semantics over one Boolean variable per argument; the encoding is -solver-independent. `argumentation.af_sat` provides a Z3-backed incremental -SAT kernel for Dung AFs with telemetry (`SATCheck`, `SATTraceSink`, -`AfSatKernel`). -`argumentation.aba_sat` provides task-directed SAT enumeration for ABA. +`argumentation.solving.sat_encoding` provides a pure-Python CNF encoding of +stable extension semantics over one Boolean variable per argument; the +encoding is solver-independent. `argumentation.solving.af_sat` provides a +Z3-backed incremental SAT kernel for Dung AFs with telemetry (`SATCheck`, +`SATTraceSink`, `AfSatKernel`). `argumentation.structured.aba.aba_sat` +provides task-directed SAT enumeration for ABA. -`argumentation.datalog_grounding` (requires the `[grounding]` extra) grounds -a Gunray `DefeasibleTheory` into propositional ASPIC+ via -`ground_defeasible_theory(theory) -> GroundedDatalogTheory`. It consumes -[Gunray](https://github.com/ctoth/gunray) — a sister project that owns the -defeasible-theory schema — rather than redefining one. +`argumentation.structured.aspic.datalog_grounding` (requires the +`[grounding]` extra) grounds a Gunray `DefeasibleTheory` into propositional +ASPIC+ via `ground_defeasible_theory(theory) -> GroundedDatalogTheory`. It +consumes [Gunray](https://github.com/ctoth/gunray) — a sister project that +owns the defeasible-theory schema — rather than redefining one. -`argumentation.llm_surface` is a dependency-free adapter for argumentative -LLM pipelines: callers supply propositions and attack/support edges, the -package computes QBAF strengths, Shapley-style attack explanations, and -contestation witnesses. +`argumentation.gradual.llm_surface` is a dependency-free adapter for +argumentative LLM pipelines: callers supply propositions and attack/support +edges, the package computes QBAF strengths, Shapley-style attack +explanations, and contestation witnesses. The package ships with prebuilt clingo `.lp` encodings under `argumentation.encodings/` (admissible/complete/stable for AF, ASPIC+, and @@ -446,7 +461,7 @@ ABA), used by the ASP-backed paths. ## Solver surfaces -`argumentation.solver` separates solver tasks by result type: +`argumentation.solving.solver` separates solver tasks by result type: - `ExtensionEnumerationSuccess` — all extensions for enumeration tasks. - `SingleExtensionSuccess` — one witness extension or `None`. @@ -463,11 +478,11 @@ the ABA equivalent for that contract; `solve_dung_extensions(..., backend="iccma returns typed unavailable instead of pretending one witness enumerates every extension. -`argumentation.backends` exposes capability detection (`has_clingo`, +`argumentation.solving.backends` exposes capability detection (`has_clingo`, `has_z3`) and `default_backend(...)` / `backend_choice_reason(...)` for -routing decisions. `argumentation.solver_results` defines the typed +routing decisions. `argumentation.core.solver_results` defines the typed `SolverUnavailable`, `SolverProcessError`, and `SolverProtocolError` returns. -`argumentation.solver_differential` provides +`argumentation.solving.solver_differential` provides `solver_capability_matrix` and task-aware comparison helpers across native, ICCMA, SAT, clingo, ADF, SETAF, and unsupported backend combinations. @@ -493,13 +508,17 @@ iccma-cli --problem SE-CO --file theory.aba --backend sat Supported problem codes: `SE` (single extension), `DC` (credulous decision), `DS` (skeptical decision). Supported semantics: `CO`, `GR`, `PR`, `ST`, `SST`, `STG`, `ID`, `CF2`. Backends: `auto`, `native`, `sat`. The CLI dispatches into -`argumentation.solver` and reads ICCMA `p af` and `p aba` input files. +`argumentation.solving.solver` and reads ICCMA `p af` and `p aba` input files. +Its module entry point is `argumentation.solving.iccma_cli`. ## Design - Pure-Python algorithms are the reference implementation for package-owned algorithms. Solver adapters are typed boundaries around external tools or optional dependencies. +- The package is organized into layered subpackages with an + `import-linter`-enforced import DAG; an upward import fails CI. See + `docs/architecture.md` for the layer contract. - Frameworks, rules, arguments, and extensions are immutable frozen dataclasses over frozensets. Equality is structural. - Conflict-freeness is checked against the pre-preference attack relation; @@ -509,7 +528,9 @@ Supported problem codes: `SE` (single extension), `DC` (credulous decision), `docs/architecture.md` is the long form. `docs/backends.md` documents the default backend selection rule. `docs/gaps.md` enumerates known limitations -and open workstreams. +and open workstreams. [`CHANGELOG.md`](CHANGELOG.md) records release history, +including the 0.3.0 layered-subpackage reorganization and the complete +old→new import-path table. ## Non-goals @@ -525,11 +546,13 @@ application-side CLI. ```powershell uv sync uv run pyright src +uv run lint-imports uv run pytest -vv ``` Tests are tagged `unit`, `property`, and `differential`. Property tests use Hypothesis. Differential tests cross-check independently implemented package paths where the repository has more than one executable route. +`uv run lint-imports` enforces the layered import DAG. See `CONTRIBUTING.md` for contribution guidelines. diff --git a/bench/asp_vs_sat.py b/bench/asp_vs_sat.py index cd31f4b..985c436 100644 --- a/bench/asp_vs_sat.py +++ b/bench/asp_vs_sat.py @@ -7,8 +7,8 @@ from pathlib import Path from time import perf_counter -from argumentation.aba_asp import solve_aba_with_backend -from argumentation.aspic_encoding import solve_aspic_with_backend +from argumentation.structured.aba.aba_asp import solve_aba_with_backend +from argumentation.structured.aspic.aspic_encoding import solve_aspic_with_backend from bench.instance_gen import aba_chain, aspic_chain diff --git a/bench/instance_gen.py b/bench/instance_gen.py index 29b42fb..9453757 100644 --- a/bench/instance_gen.py +++ b/bench/instance_gen.py @@ -2,8 +2,8 @@ from __future__ import annotations -from argumentation.aba import ABAFramework -from argumentation.aspic import ( +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, ContrarinessFn, GroundAtom, diff --git a/docs/architecture.md b/docs/architecture.md index bf7f1c3..8f3ffe7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,207 +5,284 @@ data types and algorithms over argumentation frameworks. It does not own application-level concepts (storage, persistence, schedulers, application CLIs) — see [Non-goals](#non-goals). -The public surface is grouped into nine tiers. Per-family deep dives live +The distribution name is `formal-argumentation`; the import root is +`argumentation`. The package is organized into layered subpackages. The +module catalogue below is grouped by subpackage; the +[Layered architecture](#layered-architecture) section states the import DAG +and the `import-linter` contract that enforces it. Per-family deep dives live in dedicated docs (see [See also](#see-also)). ## Modules -### Core: Dung, labelling, preference, dispatch +### `core/` — Dung, labelling, preference, dispatch primitives -- `argumentation.dung` — Dung 1995 abstract argumentation frameworks and +- `argumentation.core.dung` — Dung 1995 abstract argumentation frameworks and extension semantics. Core: grounded, complete, preferred, stable. Extended: naive, semi-stable (Caminada 2011), stage, CF2 (Gaggl & Woltran 2013), stage2, eager, ideal (Dung, Mancarella & Toni 2007), and prudent-semantics helpers. -- `argumentation.labelling` — Three-valued IN / OUT / UNDEC labelling and +- `argumentation.core.labelling` — Three-valued IN / OUT / UNDEC labelling and the extension-to-labelling bridge used by accrual and quantitative services. -- `argumentation.preference` — Strict-partial-order helpers and elitist / +- `argumentation.core.preference` — Strict-partial-order helpers and elitist / democratic preference comparisons (Modgil & Prakken 2018 Def 19). +- `argumentation.core.bipolar` — Cayrol-style bipolar argumentation frameworks + with derived defeats and d/s/c-admissibility semantics. +- `argumentation.core.accrual` — Prakken-style weak/strong applicability + helpers and same-conclusion accrual envelopes. +- `argumentation.core.preprocessing` — Framework-simplification helpers shared + by the solver and encoding layers. +- `argumentation.core.scc_recursive` — SCC-recursive extension computation + shared by the Dung semantics. +- `argumentation.core.solver_results` — Shared result dataclasses + `SolverUnavailable`, `SolverProcessError`, `SolverProtocolError`. - `argumentation.semantics` — Generic set-returning semantics dispatch - over argumentation-owned Dung, bipolar, and partial-AF dataclasses. + over argumentation-owned Dung, bipolar, and partial-AF dataclasses. This is + a single top-level module, not a subpackage; it is the topmost layer. -### Structured: ASPIC+, ABA, accrual +### `structured/aspic/` — ASPIC+ structured argumentation -- `argumentation.aspic` — ASPIC+ literals, rules, premise/strict/defeasible - arguments, attacks, defeats, and CSAF construction (Modgil & Prakken - 2018). -- `argumentation.aspic_encoding` — Deterministic ASP-style fact encoding - of ASPIC+ theories (Lehtonen, Niskanen & Järvisalo 2024) and a typed - grounded query surface with a backend-dispatch entry point +- `argumentation.structured.aspic.aspic` — ASPIC+ literals, rules, + premise/strict/defeasible arguments, attacks, defeats, and CSAF + construction (Modgil & Prakken 2018). +- `argumentation.structured.aspic.aspic_encoding` — Deterministic ASP-style + fact encoding of ASPIC+ theories (Lehtonen, Niskanen & Järvisalo 2024) and + a typed grounded query surface with a backend-dispatch entry point (`solve_aspic_with_backend`). -- `argumentation.aspic_incomplete` — ASPIC+ reasoning with optional - ordinary premises by exact completion enumeration; classifies a query - as `stable`, `relevant`, `unknown`, or `unsupported`. -- `argumentation.subjective_aspic` — Wallner-style value filtering helpers - for ASPIC+ subjective knowledge bases and defeasible rules. -- `argumentation.aba` — Flat ABA and ABA+ frameworks over ASPIC literals, - with constructor-level rejection of non-flat frameworks via +- `argumentation.structured.aspic.aspic_incomplete` — ASPIC+ reasoning with + optional ordinary premises by exact completion enumeration; classifies a + query as `stable`, `relevant`, `unknown`, or `unsupported`. +- `argumentation.structured.aspic.subjective_aspic` — Wallner-style value + filtering helpers for ASPIC+ subjective knowledge bases and defeasible + rules. +- `argumentation.structured.aspic.datalog_grounding` — Grounding of Gunray + `DefeasibleTheory` instances into propositional ASPIC+ via + `ground_defeasible_theory(theory) -> GroundedDatalogTheory`. Requires + the `[grounding]` extra. Consumes Gunray rather than redefining the + defeasible-theory schema; Diller (2025) Definition 12 NAP analysis is + not implemented (see `gaps.md`). + +### `structured/aba/` — assumption-based argumentation + +- `argumentation.structured.aba.aba` — Flat ABA and ABA+ frameworks over + ASPIC literals, with constructor-level rejection of non-flat frameworks via `NotFlatABAError` and preference-aware attack reversal (Bondarenko et al. 1997; Čyras & Toni 2016). -- `argumentation.aba_asp` — Clingo-backed flat ABA extension queries - encoding `ABAFramework` into deterministic ASP facts. Requires the +- `argumentation.structured.aba.aba_asp` — Clingo-backed flat ABA extension + queries encoding `ABAFramework` into deterministic ASP facts. Requires the `[asp]` extra. ABA+ ASP is not yet implemented. -- `argumentation.aba_sat` — Pure-Python (no Z3) task-directed support-mask - SAT enumeration for flat ABA stable, complete, and preferred extensions. -- `argumentation.accrual` — Prakken-style weak/strong applicability - helpers and same-conclusion accrual envelopes. +- `argumentation.structured.aba.aba_sat` — Pure-Python (no Z3) task-directed + support-mask SAT enumeration for flat ABA stable, complete, and preferred + extensions. +- `argumentation.structured.aba.aba_decomposition` — SCC-style decomposition + planning for ABA preferred-extension SAT enumeration. +- `argumentation.structured.aba.aba_incremental` — Incremental multi-shot ABA + solving with clingo, plus an incremental-telemetry record. +- `argumentation.structured.aba.aba_preprocessing` — Flat-ABA simplification + and grounded-reduct helpers. +- `argumentation.structured.aba.aba_route_policy` — Shape-driven route policy + for choosing the sparse-narrow native SAT path. +- `argumentation.structured.aba.aba_telemetry` — Structural telemetry keys and + derivations for ABA frameworks. + +### `frameworks/` — specialized framework families + +- `argumentation.frameworks.adf` — Abstract dialectical frameworks with typed + acceptance-condition ASTs, three-valued operator semantics, structural + link classification, and Dung bridges (Brewka & Woltran 2010; Brewka et + al. 2013). +- `argumentation.frameworks.setaf` — SETAFs with collective attacks; grounded + / complete / preferred / stable / semi-stable / stage semantics. See + [`setaf.md`](setaf.md). +- `argumentation.frameworks.setaf_io` — ASPARTIX SETAF facts plus + package-local compact SETAF parser/writer. +- `argumentation.frameworks.caf` — Claim-augmented AFs with inherited and + claim-level views plus concurrence checks. See + [`caf-semantics.md`](caf-semantics.md). +- `argumentation.frameworks.vaf` — Bench-Capon value-based argumentation + frameworks, audience-specific defeat, and objective/subjective acceptance. +- `argumentation.frameworks.vaf_completion` — Value-based argument-chain + construction, line classification, fact-first audiences, and two-value + cycle completion helpers. +- `argumentation.frameworks.partial_af` — Partial argumentation frameworks + with a three-way attack/ignorance/non-attack partition, completion + enumeration, skeptical and credulous acceptance, and Sum / Max / Leximax + merge operators. +- `argumentation.frameworks.practical_reasoning` — Atkinson and Bench-Capon + AATS-backed AS1 practical arguments with CQ5, CQ6, and CQ11 objection + generation. -### Quantitative and bipolar +### `gradual/` — gradual and weighted-bipolar semantics -- `argumentation.bipolar` — Cayrol-style bipolar argumentation frameworks - with derived defeats and d/s/c-admissibility semantics. -- `argumentation.gradual` — Potyka-style quadratic-energy gradual - strengths for weighted bipolar graphs, revised direct-impact - attribution, and exact Shapley-style per-attack impact scores - (Al Anaissy et al. 2024). -- `argumentation.gradual_principles` — Executable balance, directionality, - and monotonicity checks over gradual strength functions. -- `argumentation.ranking` — Categoriser, Burden, and related ranking-based - semantics over Dung AFs. -- `argumentation.ranking_axioms` — Executable ranking postulate checks - over `RankingResult` outputs. -- `argumentation.weighted` — Dunne-style weighted argument systems with - inconsistency-budget grounded semantics and deleted-attack witnesses. -- `argumentation.dfquad` — DF-QuAD aggregation/combination and strength - propagation for quantitative bipolar graphs. -- `argumentation.equational` — Iterative equational fixpoint scoring +- `argumentation.gradual.gradual` — Potyka-style quadratic-energy gradual + strengths for weighted bipolar graphs, revised direct-impact attribution, + and exact Shapley-style per-attack impact scores (Al Anaissy et al. 2024). +- `argumentation.gradual.gradual_principles` — Executable balance, + directionality, and monotonicity checks over gradual strength functions. +- `argumentation.gradual.dfquad` — DF-QuAD aggregation/combination and + strength propagation for quantitative bipolar graphs. +- `argumentation.gradual.equational` — Iterative equational fixpoint scoring schemes over weighted bipolar graphs. -- `argumentation.matt_toni` — Finite zero-sum game strengths for small +- `argumentation.gradual.sensitivity` — Sensitivity analysis over gradual + strength functions. +- `argumentation.gradual.llm_surface` — Dependency-free QBAF construction, + Shapley-style explanation, and contestation witnesses for externally + supplied argumentative LLM proposition graphs. + +### `ranking/` — ranking-based semantics + +- `argumentation.ranking.ranking` — Categoriser, Burden, and related + ranking-based semantics over Dung AFs. +- `argumentation.ranking.ranking_axioms` — Executable ranking postulate checks + over `RankingResult` outputs. +- `argumentation.ranking.weighted` — Dunne-style weighted argument systems + with inconsistency-budget grounded semantics and deleted-attack witnesses. +- `argumentation.ranking.matt_toni` — Finite zero-sum game strengths for small AFs, with explicit intractability signalling for oversized matrices. -### Probabilistic and epistemic +### `probabilistic/` — probabilistic and epistemic argumentation -- `argumentation.probabilistic` — Probabilistic argumentation frameworks - (PrAFs) with primitive-relation uncertainty. Auto-routing strategy - dispatcher across `deterministic`, `exact_enum`, `mc` (Li, Oren & +- `argumentation.probabilistic.probabilistic` — Probabilistic argumentation + frameworks (PrAFs) with primitive-relation uncertainty. Auto-routing + strategy dispatcher across `deterministic`, `exact_enum`, `mc` (Li, Oren & Norman 2012), `exact_dp`, `paper_td` (Popescu & Wallner 2024), and DF-QuAD gradual semantics (Freedman et al. 2025). -- `argumentation.probabilistic_components` — Connected component +- `argumentation.probabilistic.probabilistic_components` — Connected component decomposition over the primitive semantic dependency graph (Hunter & Thimm 2017, Proposition 18). -- `argumentation.probabilistic_treedecomp` — Min-degree treewidth - estimation, tree decomposition computation, nice-tree-decomposition - conversion, and an adapted grounded edge-tracking DP. Exact for the - supported grounded route, but not the full Popescu & Wallner I/O/U - witness-table DP. See `gaps.md` for the asymptotic limitation. -- `argumentation.epistemic` — Hunter-style epistemic language and belief - distributions, labelled epistemic graphs with positive/negative/dependent - labels, Potyka-style linear atomic constraints over probability - labellings, and explicitly approximate belief-grid helpers. The Z3-backed - surface in the package; install the `[z3]` extra. - -### Specialized frameworks - -- `argumentation.adf` — Abstract dialectical frameworks with typed - acceptance-condition ASTs, three-valued operator semantics, structural - link classification, and Dung bridges (Brewka & Woltran 2010; Brewka et - al. 2013). -- `argumentation.setaf` — SETAFs with collective attacks; grounded / - complete / preferred / stable / semi-stable / stage semantics. See - [`setaf.md`](setaf.md). -- `argumentation.setaf_io` — ASPARTIX SETAF facts plus package-local - compact SETAF parser/writer. -- `argumentation.caf` — Claim-augmented AFs with inherited and claim-level - views plus concurrence checks. See [`caf-semantics.md`](caf-semantics.md). -- `argumentation.vaf` — Bench-Capon value-based argumentation frameworks, - audience-specific defeat, and objective/subjective acceptance. -- `argumentation.vaf_completion` — Value-based argument-chain construction, - line classification, fact-first audiences, and two-value cycle - completion helpers. -- `argumentation.practical_reasoning` — Atkinson and Bench-Capon - AATS-backed AS1 practical arguments with CQ5, CQ6, and CQ11 objection - generation. - -### Dynamics, revision, enforcement - -- `argumentation.partial_af` — Partial argumentation frameworks with a - three-way attack/ignorance/non-attack partition, completion enumeration, - skeptical and credulous acceptance, and Sum / Max / Leximax merge - operators. -- `argumentation.af_revision` — AF-level revision: kernel union expansion - (Baumann 2015), revise-by-formula and revise-by-framework (Diller 2015), - and grounded-argument-addition classification (`cayrol_2014_classify_grounded_argument_addition`, - cited to Cayrol, de Saint-Cyr & Lagasquie-Schiex 2010, JAIR 38). -- `argumentation.dynamic` — Dynamic AF update streams with a named +- `argumentation.probabilistic.probabilistic_treedecomp` — Min-degree + treewidth estimation, tree decomposition computation, + nice-tree-decomposition conversion, and an adapted grounded edge-tracking + DP. Exact for the supported grounded route, but not the full Popescu & + Wallner I/O/U witness-table DP. See `gaps.md` for the asymptotic + limitation. +- `argumentation.probabilistic.epistemic` — Hunter-style epistemic language + and belief distributions, labelled epistemic graphs with + positive/negative/dependent labels, Potyka-style linear atomic constraints + over probability labellings, and explicitly approximate belief-grid + helpers. The Z3-backed surface in the package; install the `[z3]` extra. + +### `dynamics/` — revision, enforcement, dynamic update + +- `argumentation.dynamics.af_revision` — AF-level revision: kernel union + expansion (Baumann 2015), revise-by-formula and revise-by-framework + (Diller 2015), and grounded-argument-addition classification + (`cayrol_2014_classify_grounded_argument_addition`, cited to Cayrol, de + Saint-Cyr & Lagasquie-Schiex 2010, JAIR 38). +- `argumentation.dynamics.dynamic` — Dynamic AF update streams with a named recompute oracle, Alfano-Greco-Parisi-style single-attack incremental influenced/reduced-AF updates for grounded, complete, preferred, and stable semantics, and query results with explicit fallback metadata. -- `argumentation.enforcement` — Brute-force minimal-change argument and - extension enforcement with separate typed witnesses for unconstrained +- `argumentation.dynamics.enforcement` — Brute-force minimal-change argument + and extension enforcement with separate typed witnesses for unconstrained fixed-argument edits, conservative Baumann-style normal/strong/weak expansions, and explicit liberal source-to-target semantics changes. See `gaps.md` for the brute-force-vs-Baumann scope. -- `argumentation.approximate` — k-stable semantics, bounded grounded +- `argumentation.dynamics.approximate` — k-stable semantics, bounded grounded iteration, and budgeted semi-stable approximation with exactness metadata. +- `argumentation.dynamics.optimization` — Optimisation helpers over AF + enforcement and revision. -### Encoding and interop - -- `argumentation.iccma` — ICCMA-style AF, ADF, and ABA I/O for interop with - external argumentation solvers. -- `argumentation.iccma_cli` — Argparse `main(argv)` for the ICCMA AF/ABA - CLI, registered as the `iccma-cli` console script. Dispatches to - `argumentation.solver`. -- `argumentation.sat_encoding` — Solver-independent CNF encoding of stable - extensions, plus a reference scan-based enumerator. -- `argumentation.af_sat` — Incremental Z3-backed SAT kernel for Dung AF - acceptance with telemetry (`AfSatKernel`, `SATCheck`, `SATTraceSink`). -- `argumentation.datalog_grounding` — Grounding of Gunray - `DefeasibleTheory` instances into propositional ASPIC+ via - `ground_defeasible_theory(theory) -> GroundedDatalogTheory`. Requires - the `[grounding]` extra. Consumes Gunray rather than redefining the - defeasible-theory schema; Diller (2025) Definition 12 NAP analysis is - not implemented (see `gaps.md`). -- `argumentation.encodings/` — Prebuilt clingo `.lp` modules - (admissible / complete / stable for AF, ASPIC+, and ABA) shipped in the - wheel and concatenated with facts by the clingo subprocess adapter. -- `argumentation.llm_surface` — Dependency-free QBAF construction, - Shapley-style explanation, and contestation witnesses for externally - supplied argumentative LLM proposition graphs. +### `interop/` — exchange-format I/O + +- `argumentation.interop.iccma` — ICCMA-style AF, ADF, and ABA I/O for interop + with external argumentation solvers. -### Solver orchestration +### `solving/` — solver orchestration and SAT encodings -- `argumentation.solver` — Typed solver-result wrappers for Dung, ABA, ADF, - and SETAF tasks. Entry points: `solve_dung_extensions / +- `argumentation.solving.solver` — Typed solver-result wrappers for Dung, ABA, + ADF, and SETAF tasks. Entry points: `solve_dung_extensions / solve_dung_single_extension / solve_dung_acceptance`, `solve_aba_extensions / solve_aba_single_extension / solve_aba_acceptance`, `solve_adf_models`, `solve_setaf_extensions`. Configuration dataclasses: `ICCMAConfig`, `SATConfig`. -- `argumentation.solver_results` — Shared result dataclasses - `SolverUnavailable`, `SolverProcessError`, `SolverProtocolError`. -- `argumentation.solver_differential` — Hosts `solver_capability_matrix` - and task-aware comparison helpers for native, ICCMA, SAT, clingo, ADF, - SETAF, and unsupported backend combinations. -- `argumentation.backends` — Capability detection (`has_clingo`, +- `argumentation.solving.solver_differential` — Hosts + `solver_capability_matrix` and task-aware comparison helpers for native, + ICCMA, SAT, clingo, ADF, SETAF, and unsupported backend combinations. +- `argumentation.solving.backends` — Capability detection (`has_clingo`, `has_z3`), `default_backend(...)` policy, and `backend_choice_reason(...)` diagnostics. See [`backends.md`](backends.md). -- `argumentation.solver_adapters/` — Subprocess adapters as a subpackage: - - `solver_adapters/clingo` — Subprocess driver for ASPIC+/ABA/AF clingo - encodings; parses `accepted_arg(...)` / `accepted_lit(...)` lines. - - `solver_adapters/iccma_aba` — ICCMA-protocol flat-ABA solvers. - - `solver_adapters/iccma_af` — ICCMA-protocol AF solvers, with typed - DC/DS/SE output parsing. +- `argumentation.solving.sat_encoding` — Solver-independent CNF encoding of + stable extensions, plus a reference scan-based enumerator. +- `argumentation.solving.af_sat` — Incremental Z3-backed SAT kernel for Dung + AF acceptance with telemetry (`AfSatKernel`, `SATCheck`, `SATTraceSink`). +- `argumentation.solving.iccma_cli` — Argparse `main(argv)` for the ICCMA + AF/ABA CLI, registered as the `iccma-cli` console script. Dispatches to + `argumentation.solving.solver`. + +### `solver_adapters/` — external-solver subprocess adapters + +- `argumentation.solver_adapters.clingo` — Subprocess driver for + ASPIC+/ABA/AF clingo encodings; parses `accepted_arg(...)` / + `accepted_lit(...)` lines. +- `argumentation.solver_adapters.iccma_aba` — ICCMA-protocol flat-ABA solvers. +- `argumentation.solver_adapters.iccma_af` — ICCMA-protocol AF solvers, with + typed DC/DS/SE output parsing. + +### `encodings/` — prebuilt clingo encodings + +- `argumentation.encodings/` — Prebuilt clingo `.lp` modules + (admissible / complete / stable for AF, ASPIC+, and ABA) shipped in the + wheel and concatenated with facts by the clingo subprocess adapter. + +## Layered architecture + +The package is partitioned into layers. A module may import only from its own +layer or a strictly lower layer; an upward import is forbidden. From the base +upward: + +1. **`core`** — `dung`, `labelling`, `preference`, `bipolar`, `accrual`, + `preprocessing`, `scc_recursive`, `solver_results`. Depends on nothing + else in the package. +2. **`structured.aspic`, `frameworks`, `gradual`, `ranking`** — framework + families built directly on `core`. These four are siblings; `gradual` and + `ranking` are additionally constrained to be independent of each other. +3. **`structured.aba`, `probabilistic`, `dynamics`** — built on `core` and + the layer-2 families. +4. **`interop`** — exchange-format I/O over the framework layers. +5. **`solver_adapters`** — external-solver subprocess adapters. +6. **`solving`** — solver orchestration (`solver`, `solver_differential`, + `backends`, `sat_encoding`, `af_sat`, `iccma_cli`). +7. **`semantics`** — the topmost generic dispatcher. + +The DAG is enforced mechanically by `import-linter`. The +`[tool.importlinter]` contracts in `pyproject.toml` declare a `layers` +contract (base `argumentation.core` up to `argumentation.semantics`) and an +`independence` contract pinning `argumentation.gradual` and +`argumentation.ranking` apart. `uv run lint-imports` checks both; an upward +import fails CI. + +Two function-local imports are sanctioned exceptions, listed in the +contract's `ignore_imports`: + +- `argumentation.structured.aspic.aspic_encoding -> argumentation.solver_adapters.clingo` +- `argumentation.structured.aba.aba_asp -> argumentation.solver_adapters.clingo` + +Both are deferred (function-local) imports of the optional clingo subprocess +adapter, used only when the `[asp]` extra is installed. They are deliberate +and explicitly whitelisted; no other upward import is permitted. ## Backend policy Pure-Python algorithms are the reference implementation. Dung extension *enumeration* has one package-owned execution path: finite set enumeration -in `argumentation.dung`, with `argumentation.labelling` used by the +in `argumentation.core.dung`, with `argumentation.core.labelling` used by the semantic implementations that require labelling projections. -`argumentation.solver.solve_dung_extensions` exposes that path through the -backend name `native`. Other extension-enumeration backend names return -`SolverUnavailable`; this includes the deleted `argumentation.dung_z3` +`argumentation.solving.solver.solve_dung_extensions` exposes that path +through the backend name `native`. Other extension-enumeration backend names +return `SolverUnavailable`; this includes the deleted `argumentation.dung_z3` module name. `backend="iccma"` is supported only for single-extension and acceptance tasks (one ICCMA witness is not full enumeration). ABA, ADF, SETAF, and ASPIC+ have their own native execution paths through -`argumentation.solver`; the SAT backend for AFs uses `argumentation.af_sat` -and the ASP backends for ABA / ASPIC+ route through `argumentation.aba_asp` -/ `argumentation.aspic_encoding`. The `default_backend(...)` policy -function in `argumentation.backends` picks among these without forcing -dispatch — see [`backends.md`](backends.md) for the rule body and the +`argumentation.solving.solver`; the SAT backend for AFs uses +`argumentation.solving.af_sat` and the ASP backends for ABA / ASPIC+ route +through `argumentation.structured.aba.aba_asp` / +`argumentation.structured.aspic.aspic_encoding`. The `default_backend(...)` +policy function in `argumentation.solving.backends` picks among these without +forcing dispatch — see [`backends.md`](backends.md) for the rule body and the canonical backend-string set. ## Solver contracts @@ -218,7 +295,7 @@ Solver calls are separated by task result type: backend-supplied witness or counterexample. unsupported combinations return typed unavailable results before subprocess -invocation. `argumentation.solver_differential` hosts +invocation. `argumentation.solving.solver_differential` hosts `solver_capability_matrix`, the package-owned record of which combinations are live for native, ICCMA, SAT, clingo, ADF, SETAF, and unsupported backend combinations. @@ -242,10 +319,11 @@ identity, storage, merge policy, provenance, or rendering policy. development dependency for tests; it is not used for Dung extension enumeration. The Z3-backed package surfaces are: -- `argumentation.epistemic.constraints_satisfiable` / +- `argumentation.probabilistic.epistemic.constraints_satisfiable` / `constraints_entail` — linear real constraints over argument probability labels. -- `argumentation.af_sat` — incremental SAT kernel for Dung AF acceptance. +- `argumentation.solving.af_sat` — incremental SAT kernel for Dung AF + acceptance. Without `z3-solver`, those entry points raise a runtime error naming the missing dependency (`epistemic.py`, `af_sat.py`). @@ -340,3 +418,5 @@ focused test pins the chosen behaviour. preparation tooling. - [`iccma-2025-data.md`](iccma-2025-data.md) — ICCMA 2025 data and native runner. +- [`../CHANGELOG.md`](../CHANGELOG.md) — release history, including the + 0.3.0 layered-subpackage reorganization. diff --git a/docs/backends.md b/docs/backends.md index 3d4ec7a..5ce6e84 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -1,6 +1,6 @@ # Backend selection -`argumentation.backends` exposes capability detection and the default +`argumentation.solving.backends` exposes capability detection and the default backend selection policy. Solver entry points consume the chosen backend string; `default_backend(...)` is a policy function, not a forced dispatch layer — callers may always override with an explicit `backend=` argument. @@ -8,7 +8,7 @@ layer — callers may always override with an explicit `backend=` argument. ## Capability detection ```python -from argumentation.backends import has_clingo, has_z3 +from argumentation.solving.backends import has_clingo, has_z3 has_clingo() # True if `clingo` is on PATH or the `clingo` Python package is importable has_z3() # True if `z3-solver` is installed (the [z3] extra) @@ -21,7 +21,7 @@ invokes the current Python executable with `-m clingo` ## Default backend rule ```python -from argumentation.backends import default_backend, backend_choice_reason +from argumentation.solving.backends import default_backend, backend_choice_reason backend: str = default_backend( semantics="grounded", @@ -61,21 +61,21 @@ The canonical set of backend strings consumers should compare against: | String | Where used | Implemented by | |---|---|---| | `"asp"` | ASPIC+ grounded path, large-theory routing | `solver_adapters/clingo.py` | -| `"sat"` | AF acceptance (Z3-backed) | `argumentation.af_sat` | -| `"materialized_reference"` | Pure-Python reference projection | `argumentation.aspic_encoding` | -| `"support_reference"` | ABA reference path (alias accepted by `aba_asp`) | `argumentation.aba_asp` | -| `"native"` | In-package native enumeration | `argumentation.solver` | +| `"sat"` | AF acceptance (Z3-backed) | `argumentation.solving.af_sat` | +| `"materialized_reference"` | Pure-Python reference projection | `argumentation.structured.aspic.aspic_encoding` | +| `"support_reference"` | ABA reference path (alias accepted by `aba_asp`) | `argumentation.structured.aba.aba_asp` | +| `"native"` | In-package native enumeration | `argumentation.solving.solver` | | `"iccma"` | External ICCMA-protocol subprocess | `solver_adapters/iccma_af`, `solver_adapters/iccma_aba` | -| `"aspforaba"` | Recognized but currently unimplemented; returns typed `SolverUnavailable` | `argumentation.solver` | +| `"aspforaba"` | Recognized but currently unimplemented; returns typed `SolverUnavailable` | `argumentation.solving.solver` | `aba_asp.run_aba_query` accepts `{"support_reference", "materialized_reference"}` interchangeably for the reference path. ## Entry points that consume a backend choice -- `argumentation.aspic_encoding.solve_aspic_with_backend(theory, *, backend, ...)`. -- `argumentation.aba_asp.run_aba_query(framework, *, backend, ...)`. -- `argumentation.solver.solve_dung_extensions / solve_dung_single_extension / +- `argumentation.structured.aspic.aspic_encoding.solve_aspic_with_backend(theory, *, backend, ...)`. +- `argumentation.structured.aba.aba_asp.run_aba_query(framework, *, backend, ...)`. +- `argumentation.solving.solver.solve_dung_extensions / solve_dung_single_extension / solve_dung_acceptance / solve_aba_extensions / solve_aba_single_extension / solve_aba_acceptance / solve_adf_models / solve_setaf_extensions`. @@ -83,7 +83,7 @@ For ICCMA and SAT paths, the binary, timeout, and trace-sink configuration flow through: ```python -from argumentation.solver import ICCMAConfig, SATConfig +from argumentation.solving.solver import ICCMAConfig, SATConfig ``` ## ICCMA subprocess adapters @@ -103,7 +103,7 @@ construct an `ICCMAConfig(binary=...)` passed explicitly. ## SAT paths -The `"sat"` backend for Dung AFs uses `argumentation.af_sat`'s incremental +The `"sat"` backend for Dung AFs uses `argumentation.solving.af_sat`'s incremental Z3 kernel: - `AfSatKernel` — reusable Z3 solver bound to one AF, supporting per-task @@ -111,7 +111,7 @@ Z3 kernel: - `SATCheck` — typed result wrapper. - `SATTraceSink` — opt-in telemetry hook for benchmark instrumentation. -`argumentation.aba_sat` is a separate, pure-Python (no Z3) bitmask-based +`argumentation.structured.aba.aba_sat` is a separate, pure-Python (no Z3) bitmask-based support enumerator for ABA stable, complete, and preferred extensions. It is not the `"sat"` backend choice; it is its own task-directed surface. diff --git a/docs/caf-semantics.md b/docs/caf-semantics.md index e3ab810..2db8608 100644 --- a/docs/caf-semantics.md +++ b/docs/caf-semantics.md @@ -1,6 +1,6 @@ # Claim-Augmented Argumentation Frameworks -`argumentation.caf` implements finite claim-augmented argumentation frameworks +`argumentation.frameworks.caf` implements finite claim-augmented argumentation frameworks as a semantic surface, not a complexity-theorem surface. ## Implemented model @@ -15,7 +15,7 @@ A `ClaimAugmentedAF` consists of: The module exposes two CAF views, dispatched through a single entry point: ```python -from argumentation.caf import ClaimAugmentedAF, extensions +from argumentation.frameworks.caf import ClaimAugmentedAF, extensions caf = ClaimAugmentedAF(framework=af, claims={"a1": "x", "a2": "x", "a3": "y"}) @@ -76,7 +76,7 @@ This package does not expose those complexity classes or problem labels as runtime APIs. It implements the finite semantic computations above. No theorem or complexity class should be read as an implemented decision procedure unless a corresponding function and tests exist in -`argumentation.caf`. +`argumentation.frameworks.caf`. ## Test standard diff --git a/docs/iccma-2025-data.md b/docs/iccma-2025-data.md index ed1d8aa..c562f7f 100644 --- a/docs/iccma-2025-data.md +++ b/docs/iccma-2025-data.md @@ -122,5 +122,5 @@ The contest tag (`iccma-2025`) is inferred from the `--root` directory name; if you pass a custom `--root`, the tag changes accordingly. The runner uses the package-native ABA path. Library-level -`argumentation.solver.solve_aba_*` surfaces separately support +`argumentation.solving.solver.solve_aba_*` surfaces separately support ICCMA-compatible ABA subprocess dispatch via `ICCMAConfig(...)`. diff --git a/docs/setaf.md b/docs/setaf.md index dae1578..a6cc683 100644 --- a/docs/setaf.md +++ b/docs/setaf.md @@ -1,6 +1,6 @@ # SETAF Semantics and I/O -`argumentation.setaf` models finite SETAFs (argumentation frameworks with +`argumentation.frameworks.setaf` models finite SETAFs (argumentation frameworks with collective attacks). The dataclass `SETAF(arguments, attacks)` is frozen; each collective attack is a `CollectiveAttack = (frozenset[str], str)` with nonempty tail. The constructor coerces tails to `frozenset` and arguments to @@ -35,7 +35,7 @@ argument is not in the framework. | `stage_extensions` | range-maximal conflict-free sets | ```python -from argumentation.setaf import ( +from argumentation.frameworks.setaf import ( SETAF, complete_extensions, grounded_extension, preferred_extensions, stable_extensions, semi_stable_extensions, stage_extensions, ) @@ -52,7 +52,7 @@ the same enumeration. ## I/O formats -`argumentation.setaf_io` supports the ASPARTIX SETAF fact format using +`argumentation.frameworks.setaf_io` supports the ASPARTIX SETAF fact format using `arg/1`, `att/2`, and `mem/2`. `att(Name, Target)` gives the head and `mem(Name, Argument)` facts give the nonempty tail. The parser skips lines beginning with `%` and rejects missing terminating dots, `mem` referencing @@ -61,7 +61,7 @@ sorted, attacks sorted by `(sorted_tail, head)`, attack names emitted as `r{index}`. ```python -from argumentation.setaf_io import parse_aspartix_setaf, write_aspartix_setaf +from argumentation.frameworks.setaf_io import parse_aspartix_setaf, write_aspartix_setaf framework = parse_aspartix_setaf(""" arg(a). diff --git a/pyproject.toml b/pyproject.toml index feb0ba8..18e1127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "formal-argumentation" -version = "0.2.0" +version = "0.3.0" description = "Finite formal argumentation frameworks and semantics: Dung, ASPIC+, bipolar, partial, and revision" readme = "README.md" requires-python = ">=3.11" @@ -69,7 +69,7 @@ Repository = "https://github.com/ctoth/argumentation" Issues = "https://github.com/ctoth/argumentation/issues" [project.scripts] -iccma-cli = "argumentation.iccma_cli:main" +iccma-cli = "argumentation.solving.iccma_cli:main" [build-system] requires = ["hatchling"] @@ -78,11 +78,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/argumentation"] -[tool.hatch.build.targets.wheel.force-include] -"src/argumentation/encodings" = "argumentation/encodings" - [tool.hatch.build.targets.sdist] include = [ + "/CHANGELOG.md", "/CONTRIBUTING.md", "/README.md", "/docs", @@ -97,6 +95,34 @@ venvPath = "." venv = ".venv" include = ["src"] +[tool.importlinter] +root_package = "argumentation" + +[[tool.importlinter.contracts]] +name = "Layered architecture" +type = "layers" +layers = [ + "argumentation.semantics", + "argumentation.solving", + "argumentation.solver_adapters", + "argumentation.interop", + "argumentation.structured.aba | argumentation.probabilistic | argumentation.dynamics", + "argumentation.structured.aspic | argumentation.frameworks | argumentation.gradual | argumentation.ranking", + "argumentation.core", +] +ignore_imports = [ + "argumentation.structured.aspic.aspic_encoding -> argumentation.solver_adapters.clingo", + "argumentation.structured.aba.aba_asp -> argumentation.solver_adapters.clingo", +] + +[[tool.importlinter.contracts]] +name = "gradual and ranking are independent" +type = "independence" +modules = [ + "argumentation.gradual", + "argumentation.ranking", +] + [tool.pytest.ini_options] testpaths = ["tests"] markers = [ @@ -113,6 +139,7 @@ dev = [ "clingo>=5.7", "gunray", "hypothesis>=6.0", + "import-linter>=2.0", "pyright>=1.1.390", "pytest>=8.0", "pytest-timeout>=2.3", diff --git a/reports/codex-review-reorg.md b/reports/codex-review-reorg.md new file mode 100644 index 0000000..8dcf4d9 --- /dev/null +++ b/reports/codex-review-reorg.md @@ -0,0 +1,345 @@ +# Codex review: argumentation package reorg + +Workflow actually used: read `prompts/codex-review-reorg.md`, the approved plan +`C:\Users\Q\.claude\plans\i-think-we-should-vivid-koala.md`, the migration +reports named by the prompt, current source/tests/docs/config, and current +verification output from `uv run lint-imports`, `uv run pyright src`, `uv +build`, targeted structural tests, targeted ABA regression tests, old-path +greps, and wheel inspection. + +## 1. import-linter contract + +### Layer contract matches the current DAG + +Verdict: **OK** + +Evidence: + +- `pyproject.toml:104-111` orders the contract from top to bottom: + `semantics`, `solving`, `solver_adapters`, `interop`, + `structured.aba | probabilistic | dynamics`, + `structured.aspic | frameworks | gradual | ranking`, `core`. +- `uv run lint-imports` passed currently: + `Analyzed 72 files, 121 dependencies`; `Layered architecture KEPT`; + `gradual and ranking are independent KEPT`; `Contracts: 2 kept, 0 broken`. +- Direct source import audit found the expected cross-layer edges, for example + `src/argumentation/interop/iccma.py:13` imports + `argumentation.structured.aba.aba`, and `src/argumentation/solving/solver.py` + imports `structured.aba`, `frameworks`, `core`, and `solver_adapters`. + +Recommended action: no action. + +### Sanctioned upward exceptions are narrow + +Verdict: **OK** + +Evidence: + +- `pyproject.toml:113-116` has exactly two `ignore_imports` entries: + `argumentation.structured.aspic.aspic_encoding -> argumentation.solver_adapters.clingo` + and `argumentation.structured.aba.aba_asp -> argumentation.solver_adapters.clingo`. +- Current source has the corresponding function-local imports at + `src/argumentation/structured/aspic/aspic_encoding.py:206` and + `src/argumentation/structured/aba/aba_asp.py:188`. + +Recommended action: no action. + +## 2. ABA-SAT correctness fix at `5c53816` + +### `fixed_out` is handled at the decomposition boundary + +Verdict: **OK** + +Evidence: + +- `src/argumentation/structured/aba/aba_decomposition.py:90-107` computes + simplification/plan/telemetry, then short-circuits when + `require_assumptions & simplification.fixed_out`. +- `src/argumentation/structured/aba/aba_decomposition.py:106-108` records + validation success and lifted size, then returns `extension=frozenset()`. +- `src/argumentation/structured/aba/aba_sat.py:552-558` converts a decomposed + result that does not satisfy `require_assumptions <= decomposed` into `None`. +- `src/argumentation/structured/aba/aba_preprocessing.py:176` removes both + `fixed_in` and `fixed_out` from residual assumptions; `:248-255` defines + `fixed_out` from contraries derivable from the grounded set. +- `_RealPrefSatSolver` still assumes valid residual keys and would raise on a + bad caller: `src/argumentation/structured/aba/aba_sat.py:1432-1433` indexes + `self.prefsat_in[assumption]`. That is appropriate for this lower-level + primitive. +- Targeted verification passed currently: + `uv run pytest -q tests\structured\aba\test_aba.py::test_preferred_support_sat_preserves_required_assumptions tests\structured\aba\test_aba.py::test_preferred_support_sat_fixed_out_required_assumption_is_unsatisfiable tests\structured\aba\test_aba.py::test_preferred_support_sat_fixed_out_requirement_is_always_unsatisfiable` + -> `3 passed in 2.37s`. + +Recommended action: no action. + +### Regression and property coverage are adequate + +Verdict: **OK** + +Evidence: + +- `tests/structured/aba/test_aba.py:456-510` pins the concrete fixed-out + falsifying example, verifies `fixed_in={a3}`, `fixed_out={a2}`, verifies + fixed-out requirements return `None`, fixed-in requirements still work, and + the unconstrained result is the native preferred extension. +- `tests/structured/aba/test_aba.py:516-541` adds a Hypothesis property: + whenever a drawn required set intersects `fixed_out`, + `sat_support_extension(..., "preferred", require_assumptions=...)` returns + `None` and no native preferred extension satisfies the required set. + +Recommended action: no action. + +## 3. Completeness of import rewrites + +### Shipped encoding comment still names old `argumentation.aba_asp` + +Verdict: **FIX** + +Evidence: + +- Old-path grep over active source/docs found a stale shipped string: + `src/argumentation/encodings/aba_com_incremental.lp:6`: + `% argumentation.aba_asp.encode_aba_theory emits).` +- That `.lp` file ships in the 0.3.0 wheel. Current wheel listing includes + `argumentation/encodings/aba_com_incremental.lp`. +- The correct new path is documented in `CHANGELOG.md:55`: + `argumentation.aba_asp` -> `argumentation.structured.aba.aba_asp`. + +Recommended action: update the comment to +`argumentation.structured.aba.aba_asp.encode_aba_theory`. This is not a runtime +import break, but it is a stale shipped string in the package and fails the +review prompt's string-reference check. + +### Tracked code, tests, bench, and tools have no old flat imports + +Verdict: **OK** except for the `.lp` finding above. + +Evidence: + +- PCRE old-path grep over `src tests bench tools README.md docs CHANGELOG.md + pyproject.toml CONTRIBUTING.md` found no active Python import stragglers. + Remaining old-path hits were the intentional `CHANGELOG.md:40-94` old-to-new + table, layer names such as `argumentation.gradual` / `argumentation.ranking`, + and two package-submodule imports in + `tests/test_workstream_o_arg_gradual_done.py:10-11`. +- `bench/` and `tools/` imports are now layered in the current source audit, + e.g. `tools/iccma2025_run_native.py` imports `argumentation.interop.iccma` + and `argumentation.solving.solver`; `bench/asp_vs_sat.py` imports + `argumentation.structured.aba.aba_asp` and + `argumentation.structured.aspic.aspic_encoding`. + +Recommended action: no action beyond the `.lp` comment. + +### Untracked `scripts/` still contain old imports + +Verdict: **DISCUSS** + +Evidence: + +- `git ls-files -- scripts` produced no tracked files; `git status --short -- + scripts` reports `?? scripts/`. +- The working tree nevertheless contains old imports: + `scripts/probe_sensitivity_delta_sign.py:16-17` imports + `argumentation.dung` and `argumentation.sensitivity`; + `scripts/verify_sensitivity_expectations.py:8-9` imports the same old paths. + +Recommended action: if these scripts are intended to become tracked or remain +usable as local utilities, update them to `argumentation.core.dung` and +`argumentation.gradual.sensitivity`. I am not classifying this as a PR blocker +because the files are untracked. + +## 4. Tests for the breaking structure + +### Structural coverage is present but indirect + +Verdict: **DISCUSS** + +Evidence: + +- `tests/test_import_boundaries.py:12-27` AST-walks `src/argumentation/**/*.py` + and checks import roots, not the exact subpackage layout. +- `tests/test_docs_surface.py:8-57` pins representative new dotted paths in + README and architecture docs, including `argumentation.ranking.ranking`, + `argumentation.gradual.gradual`, + `argumentation.structured.aspic.subjective_aspic`, and + `argumentation.probabilistic.epistemic`. +- Negative old-path assertions remain for the three explicitly allowed + nonexistent modules: + `tests/test_workstream_o_arg_dung_extensions_done.py:42`, + `tests/core/test_dung_extensions_workstream.py:43`, + `tests/test_dfquad_old_path_deleted.py:26`, + `tests/test_workstream_o_arg_gradual_done.py:23`, and + `tests/test_workstream_o_arg_vaf_ranking_done.py:39`. +- `tests/solving/test_iccma_cli.py` imports + `from argumentation.solving import iccma_cli` and exercises CLI behavior, but + it does not assert the installed console entry point resolves. +- Targeted structural/doc/CLI tests passed currently: + `uv run pytest -q tests\test_docs_surface.py tests\test_import_boundaries.py tests\test_dfquad_old_path_deleted.py tests\solving\test_iccma_cli.py` + -> `16 passed in 2.58s`. + +Recommended action: optional hardening: add a tiny smoke test using +`importlib.metadata.entry_points()` for `iccma-cli` and a representative +`importlib.import_module("argumentation.core.dung")` / old-path +`ModuleNotFoundError` check. Existing coverage is probably enough for this +reorg, but the console-script resolution itself is not pinned by tests. + +## 5. Collapsed `__init__.py` + +### Documented break matches the removed public package attributes + +Verdict: **OK** + +Evidence: + +- Pre-reorg `git show e79632c:src/argumentation/__init__.py` showed eager + imports and `__all__` for 41 short module names. +- Current `src/argumentation/__init__.py` is exactly one docstring line: + `"""Finite formal argumentation objects and algorithms."""` +- `CHANGELOG.md:15-18` documents that `import argumentation` no longer eagerly + imports submodules and `argumentation.__all__` was removed. +- `CHANGELOG.md:40-94` gives the old-to-new table for every moved module and + `CHANGELOG.md:96-98` states `solver_adapters` paths are unchanged. + +Recommended action: no action. + +## 6. Documentation accuracy + +### Architecture docs match current structure and contract + +Verdict: **OK** + +Evidence: + +- `docs/architecture.md:19-232` lists the current module groups by subpackage. +- `docs/architecture.md:236-254` describes the same layered architecture as + `pyproject.toml:104-111`, including `interop` above the ABA/probabilistic/ + dynamics layer and `semantics` as the top layer. +- The two sanctioned exceptions in docs match config: + `docs/architecture.md:257-263` and `pyproject.toml:113-116`. +- Targeted docs tests passed: `tests/test_docs_surface.py` included in the + `16 passed` run above. + +Recommended action: no action. + +### README snippets use current import paths + +Verdict: **OK** + +Evidence: + +- `README.md:72` starts the quick-start snippet with + `from argumentation.core.dung import (...)`. +- `README.md:254` uses `from argumentation.ranking.ranking import + categoriser_ranking`. +- `README.md:302` uses `from argumentation.probabilistic.probabilistic import + (...)`. +- `README.md:500-505` documents the `iccma-cli` command, and + `pyproject.toml:72` points the script to + `argumentation.solving.iccma_cli:main`. + +Recommended action: no action. + +### Changelog table spot-check passes + +Verdict: **OK** + +Evidence: + +- Ten sampled table targets from `CHANGELOG.md:40-94` exist at their new paths: + `src/argumentation/core/dung.py`, + `src/argumentation/structured/aspic/aspic_encoding.py`, + `src/argumentation/structured/aba/aba_sat.py`, + `src/argumentation/frameworks/setaf_io.py`, + `src/argumentation/gradual/gradual.py`, + `src/argumentation/ranking/ranking_axioms.py`, + `src/argumentation/probabilistic/probabilistic_treedecomp.py`, + `src/argumentation/interop/iccma.py`, + `src/argumentation/solving/solver.py`, and + `src/argumentation/semantics.py`. + +Recommended action: no action. + +## 7. Hatch build config + +### Wheel ships encodings without duplicate-name warning + +Verdict: **OK** + +Evidence: + +- `pyproject.toml:78-79` has only `packages = ["src/argumentation"]` under the + wheel target; there is no `force-include` table (`Select-String '[tool.hatch'` + only found wheel and sdist tables at `pyproject.toml:78` and `:81`). +- Current `uv build` output: + `Successfully built dist\formal_argumentation-0.3.0.tar.gz` and + `Successfully built dist\formal_argumentation-0.3.0-py3-none-any.whl`; no + duplicate-name warning appeared. +- Current wheel listing includes all 10 encoding files: + `argumentation/encodings/aba_admissible.lp`, + `aba_com_incremental.lp`, `aba_complete.lp`, `aba_stable.lp`, + `aspic_admissible.lp`, `aspic_complete.lp`, `aspic_stable.lp`, + `dung_admissible.lp`, `dung_complete.lp`, `dung_stable.lp`. + +Recommended action: no action. + +## 8. Mechanical rewrite regressions + +### One shipped non-Python comment escaped the rewrite + +Verdict: **FIX** + +Evidence: + +- Same stale path as Check 3: + `src/argumentation/encodings/aba_com_incremental.lp:6` names + `argumentation.aba_asp.encode_aba_theory`. +- This is classic mechanical-rewrite fallout: the Python imports were rewritten, + but a package data comment was outside the likely import-rewrite sweep. + +Recommended action: update the comment to the new path. Search package data +files, not only `.py`, in the fix verification. + +### No pass-body vestigial export tests remain + +Verdict: **OK** + +Evidence: + +- `rg -n -F -- "pass" tests\structured\aba\test_aba_asp_differential.py + tests\frameworks\test_adf_acceptance_condition_ast.py` returned no matches. +- The files named by the prompt were deleted from the old locations and are + absent as vestigial stubs: + `test_aba_asp_module_is_exported_from_package` in C6 and + `test_adf_module_is_exported` in C3. + +Recommended action: no action. + +## 9. Other findings + +### Current verification is green except for the stale shipped comment + +Verdict: **OK** + +Evidence: + +- `uv run pyright src` currently reports `0 errors, 0 warnings, 0 informations` + (plus a pyright upgrade notice). +- `uv run lint-imports` currently reports 2 kept, 0 broken. +- Targeted structural/docs/CLI tests: `16 passed in 2.58s`. +- Targeted ABA tests: `3 passed in 2.37s`. +- I did not run the full pytest suite in this review turn; I read the final + verification report showing `2824 passed, 3 skipped, 1 xfailed`, and I ran the + targeted tests above. + +Recommended action: after fixing the `.lp` comment, rerun the old-path grep and +the normal lightweight gates (`uv run lint-imports`, `uv run pyright src`, and +the targeted docs/structural tests). A full suite is optional for a comment-only +fix, but acceptable as a final gate. + +## Overall verdict + +**NOT-YET**: one FIX-class finding remains: +`src/argumentation/encodings/aba_com_incremental.lp:6` still contains the old +shipped path `argumentation.aba_asp.encode_aba_theory`. The untracked +`scripts/` old imports are DISCUSS-class because they are not tracked PR +content. diff --git a/src/argumentation/__init__.py b/src/argumentation/__init__.py index 852545c..d13903a 100644 --- a/src/argumentation/__init__.py +++ b/src/argumentation/__init__.py @@ -1,89 +1 @@ """Finite formal argumentation objects and algorithms.""" - -from argumentation import ( - af_revision, - aba, - aba_asp, - aba_sat, - accrual, - adf, - approximate, - aspic, - aspic_encoding, - aspic_incomplete, - backends, - bipolar, - caf, - dfquad, - dung, - dynamic, - enforcement, - epistemic, - equational, - gradual, - gradual_principles, - iccma, - labelling, - llm_surface, - matt_toni, - partial_af, - preference, - probabilistic, - practical_reasoning, - ranking, - ranking_axioms, - sat_encoding, - semantics, - sensitivity, - setaf, - setaf_io, - solver_differential, - subjective_aspic, - vaf_completion, - vaf, - weighted, -) - -__all__ = [ - "af_revision", - "aba", - "aba_asp", - "aba_sat", - "accrual", - "adf", - "approximate", - "aspic", - "aspic_encoding", - "aspic_incomplete", - "backends", - "bipolar", - "caf", - "dfquad", - "dung", - "dynamic", - "enforcement", - "epistemic", - "equational", - "gradual", - "gradual_principles", - "iccma", - "labelling", - "llm_surface", - "matt_toni", - "partial_af", - "preference", - "probabilistic", - "practical_reasoning", - "ranking", - "ranking_axioms", - "sat_encoding", - "semantics", - "sensitivity", - "setaf", - "setaf_io", - "solver_differential", - "subjective_aspic", - "vaf_completion", - "vaf", - "weighted", -] diff --git a/src/argumentation/core/__init__.py b/src/argumentation/core/__init__.py new file mode 100644 index 0000000..52f714e --- /dev/null +++ b/src/argumentation/core/__init__.py @@ -0,0 +1 @@ +"""Core layer: abstract argumentation primitives and base semantics.""" diff --git a/src/argumentation/accrual.py b/src/argumentation/core/accrual.py similarity index 95% rename from src/argumentation/accrual.py rename to src/argumentation/core/accrual.py index 186f948..d15587f 100644 --- a/src/argumentation/accrual.py +++ b/src/argumentation/core/accrual.py @@ -1,141 +1,141 @@ -"""Accrual applicability helpers for ASPIC-style arguments.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from argumentation.labelling import Label, Labelling - - -@dataclass(frozen=True) -class AccrualArgument: - """Argument metadata needed for labelling-relative accrual checks.""" - - identifier: str - conclusion: str - undercutters: frozenset[str] = frozenset() - immediate_subarguments: frozenset[str] = frozenset() - - -@dataclass(frozen=True) -class AccrualEnvelope: - """Same-conclusion accrual candidates without enumerating every subset.""" - - conclusion: str - strongly_applicable: frozenset[str] - weakly_applicable: frozenset[str] - - @property - def minimal_required(self) -> frozenset[str]: - return self.strongly_applicable - - @property - def maximal_available(self) -> frozenset[str]: - return self.weakly_applicable - - -def weakly_applicable(argument: AccrualArgument, labelling: Labelling) -> bool: - """Return whether an argument is weakly applicable in ``labelling``. - - Prakken 2019: weak applicability excludes undercutters labelled in and - immediate subarguments labelled out. - """ - _validate_known(argument, labelling) - return ( - not (argument.undercutters & labelling.in_arguments) - and not (argument.immediate_subarguments & labelling.out_arguments) - ) - - -def strongly_applicable(argument: AccrualArgument, labelling: Labelling) -> bool: - """Return whether an argument is strongly applicable in ``labelling``. - - Strong applicability additionally requires all undercutters to be out and - all immediate subarguments to be in. - """ - if not weakly_applicable(argument, labelling): - return False - return all( - labelling.statuses[undercutter] == Label.OUT - for undercutter in argument.undercutters - ) and all( - labelling.statuses[subargument] == Label.IN - for subargument in argument.immediate_subarguments - ) - - -def accrual_envelope( - arguments: frozenset[AccrualArgument], - *, - conclusion: str, - labelling: Labelling, -) -> AccrualEnvelope: - """Return strong and weak same-conclusion accrual candidates.""" - same_conclusion = frozenset( - argument for argument in arguments - if argument.conclusion == conclusion - ) - strong = frozenset( - argument.identifier - for argument in same_conclusion - if strongly_applicable(argument, labelling) - ) - weak = frozenset( - argument.identifier - for argument in same_conclusion - if weakly_applicable(argument, labelling) - ) - return AccrualEnvelope( - conclusion=conclusion, - strongly_applicable=strong, - weakly_applicable=weak, - ) - - -def accrual_grounded_labelling( - arguments: frozenset[AccrualArgument], - *, - max_iterations: int = 1_000, -) -> Labelling: - """Compute the least fixed point of the accrual labelling operator. - - Prakken 2019 studies the Dung-style relation between ordinary labellings - and accrual-aware labellings. This operator keeps the existing package - applicability definitions explicit: strongly applicable arguments become - in, arguments that are not weakly applicable become out, and the remainder - stay undecided until the least fixed point is reached. - """ - if max_iterations <= 0: - raise ValueError("max_iterations must be positive") - identifiers = frozenset(argument.identifier for argument in arguments) - by_identifier = {argument.identifier: argument for argument in arguments} - labelling = Labelling.from_statuses( - arguments=identifiers, - statuses={identifier: Label.UNDEC for identifier in identifiers}, - ) - - for _ in range(max_iterations): - statuses: dict[str, Label] = {} - for identifier, argument in by_identifier.items(): - if strongly_applicable(argument, labelling): - statuses[identifier] = Label.IN - elif not weakly_applicable(argument, labelling): - statuses[identifier] = Label.OUT - else: - statuses[identifier] = Label.UNDEC - next_labelling = Labelling.from_statuses( - arguments=identifiers, - statuses=statuses, - ) - if next_labelling.statuses == labelling.statuses: - return next_labelling - labelling = next_labelling - - raise RuntimeError("accrual labelling did not converge") - - -def _validate_known(argument: AccrualArgument, labelling: Labelling) -> None: - required = {argument.identifier} | set(argument.undercutters) | set(argument.immediate_subarguments) - unknown = sorted(required - labelling.arguments) - if unknown: - raise ValueError(f"labelling is missing accrual arguments: {unknown!r}") +"""Accrual applicability helpers for ASPIC-style arguments.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from argumentation.core.labelling import Label, Labelling + + +@dataclass(frozen=True) +class AccrualArgument: + """Argument metadata needed for labelling-relative accrual checks.""" + + identifier: str + conclusion: str + undercutters: frozenset[str] = frozenset() + immediate_subarguments: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class AccrualEnvelope: + """Same-conclusion accrual candidates without enumerating every subset.""" + + conclusion: str + strongly_applicable: frozenset[str] + weakly_applicable: frozenset[str] + + @property + def minimal_required(self) -> frozenset[str]: + return self.strongly_applicable + + @property + def maximal_available(self) -> frozenset[str]: + return self.weakly_applicable + + +def weakly_applicable(argument: AccrualArgument, labelling: Labelling) -> bool: + """Return whether an argument is weakly applicable in ``labelling``. + + Prakken 2019: weak applicability excludes undercutters labelled in and + immediate subarguments labelled out. + """ + _validate_known(argument, labelling) + return ( + not (argument.undercutters & labelling.in_arguments) + and not (argument.immediate_subarguments & labelling.out_arguments) + ) + + +def strongly_applicable(argument: AccrualArgument, labelling: Labelling) -> bool: + """Return whether an argument is strongly applicable in ``labelling``. + + Strong applicability additionally requires all undercutters to be out and + all immediate subarguments to be in. + """ + if not weakly_applicable(argument, labelling): + return False + return all( + labelling.statuses[undercutter] == Label.OUT + for undercutter in argument.undercutters + ) and all( + labelling.statuses[subargument] == Label.IN + for subargument in argument.immediate_subarguments + ) + + +def accrual_envelope( + arguments: frozenset[AccrualArgument], + *, + conclusion: str, + labelling: Labelling, +) -> AccrualEnvelope: + """Return strong and weak same-conclusion accrual candidates.""" + same_conclusion = frozenset( + argument for argument in arguments + if argument.conclusion == conclusion + ) + strong = frozenset( + argument.identifier + for argument in same_conclusion + if strongly_applicable(argument, labelling) + ) + weak = frozenset( + argument.identifier + for argument in same_conclusion + if weakly_applicable(argument, labelling) + ) + return AccrualEnvelope( + conclusion=conclusion, + strongly_applicable=strong, + weakly_applicable=weak, + ) + + +def accrual_grounded_labelling( + arguments: frozenset[AccrualArgument], + *, + max_iterations: int = 1_000, +) -> Labelling: + """Compute the least fixed point of the accrual labelling operator. + + Prakken 2019 studies the Dung-style relation between ordinary labellings + and accrual-aware labellings. This operator keeps the existing package + applicability definitions explicit: strongly applicable arguments become + in, arguments that are not weakly applicable become out, and the remainder + stay undecided until the least fixed point is reached. + """ + if max_iterations <= 0: + raise ValueError("max_iterations must be positive") + identifiers = frozenset(argument.identifier for argument in arguments) + by_identifier = {argument.identifier: argument for argument in arguments} + labelling = Labelling.from_statuses( + arguments=identifiers, + statuses={identifier: Label.UNDEC for identifier in identifiers}, + ) + + for _ in range(max_iterations): + statuses: dict[str, Label] = {} + for identifier, argument in by_identifier.items(): + if strongly_applicable(argument, labelling): + statuses[identifier] = Label.IN + elif not weakly_applicable(argument, labelling): + statuses[identifier] = Label.OUT + else: + statuses[identifier] = Label.UNDEC + next_labelling = Labelling.from_statuses( + arguments=identifiers, + statuses=statuses, + ) + if next_labelling.statuses == labelling.statuses: + return next_labelling + labelling = next_labelling + + raise RuntimeError("accrual labelling did not converge") + + +def _validate_known(argument: AccrualArgument, labelling: Labelling) -> None: + required = {argument.identifier} | set(argument.undercutters) | set(argument.immediate_subarguments) + unknown = sorted(required - labelling.arguments) + if unknown: + raise ValueError(f"labelling is missing accrual arguments: {unknown!r}") diff --git a/src/argumentation/bipolar.py b/src/argumentation/core/bipolar.py similarity index 96% rename from src/argumentation/bipolar.py rename to src/argumentation/core/bipolar.py index 3aa317c..a1a2246 100644 --- a/src/argumentation/bipolar.py +++ b/src/argumentation/core/bipolar.py @@ -1,555 +1,555 @@ -"""Explicit bipolar argumentation semantics for Cayrol-style frameworks. - -This module implements the Cayrol and Lagasquie-Schiex 2005 abstract -set-defeat account. Amgoud et al. 2008 distinguish richer support modes -(deductive, necessary, evidence-style); those are intentionally not collapsed -into this abstract support relation. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import combinations - -from argumentation.dung import ( - ArgumentationFramework as DungArgumentationFramework, - grounded_extension as dung_grounded_extension, -) - - -@dataclass(frozen=True) -class BipolarArgumentationFramework: - """A finite bipolar argumentation framework. - - Arguments are atomic. Defeats and supports are independent binary - relations, as in Cayrol & Lagasquie-Schiex (2005). - """ - - arguments: frozenset[str] - defeats: frozenset[tuple[str, str]] - supports: frozenset[tuple[str, str]] = frozenset() - - def __post_init__(self) -> None: - arguments = frozenset(self.arguments) - defeats = _normalize_relation("defeats", self.defeats, arguments) - supports = _normalize_relation("supports", self.supports, arguments) - - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "defeats", defeats) - object.__setattr__(self, "supports", supports) - - -def _normalize_relation( - name: str, - relation: frozenset[tuple[str, str]], - arguments: frozenset[str], -) -> frozenset[tuple[str, str]]: - normalized = frozenset((source, target) for source, target in relation) - unknown = sorted( - (source, target) - for source, target in normalized - if source not in arguments or target not in arguments - ) - if unknown: - raise ValueError( - f"{name} must only contain pairs over arguments: {unknown!r}" - ) - return normalized - - -def _support_successors(supports: frozenset[tuple[str, str]]) -> dict[str, frozenset[str]]: - successors: dict[str, set[str]] = {} - for source, target in supports: - successors.setdefault(source, set()).add(target) - return {source: frozenset(targets) for source, targets in successors.items()} - - -def _support_predecessors(supports: frozenset[tuple[str, str]]) -> dict[str, frozenset[str]]: - predecessors: dict[str, set[str]] = {} - for source, target in supports: - predecessors.setdefault(target, set()).add(source) - return {target: frozenset(sources) for target, sources in predecessors.items()} - - -def _attackers_index( - defeats: frozenset[tuple[str, str]], -) -> dict[str, frozenset[str]]: - attackers: dict[str, set[str]] = {} - for source, target in defeats: - attackers.setdefault(target, set()).add(source) - return {target: frozenset(sources) for target, sources in attackers.items()} - - -def _closure_or_compute( - framework: BipolarArgumentationFramework, - defeat_closure: frozenset[tuple[str, str]] | None, -) -> frozenset[tuple[str, str]]: - if defeat_closure is not None: - return defeat_closure - return _defeat_closure(framework.defeats, framework.supports) - - -def support_closure( - args: frozenset[str], - supports: frozenset[tuple[str, str]], -) -> frozenset[str]: - """Return the closure of ``args`` under direct support successors.""" - closure = set(args) - successors = _support_successors(supports) - queue = list(args) - while queue: - current = queue.pop() - for target in successors.get(current, frozenset()): - if target not in closure: - closure.add(target) - queue.append(target) - return frozenset(closure) - - -def _supported_targets( - args: frozenset[str], - supports: frozenset[tuple[str, str]], -) -> frozenset[str]: - supported: set[str] = set() - successors = _support_successors(supports) - for source in args: - seen: set[str] = set() - queue = list(successors.get(source, frozenset())) - seen.update(queue) - while queue: - current = queue.pop() - supported.add(current) - for target in successors.get(current, frozenset()): - if target not in seen: - seen.add(target) - queue.append(target) - return frozenset(supported) - - -def cayrol_derived_defeats( - defeats: frozenset[tuple[str, str]], - supports: frozenset[tuple[str, str]], -) -> frozenset[tuple[str, str]]: - """Return the derived defeats induced by support/defeat interaction. - - This computes Cayrol & Lagasquie-Schiex (2005, Definition 3) - supported and indirect defeats to a fixpoint. - """ - support_reach: dict[str, frozenset[str]] = {} - successors = _support_successors(supports) - for source in successors: - seen = {source} - queue = [source] - reach: set[str] = set() - while queue: - current = queue.pop() - for target in successors.get(current, frozenset()): - if target not in seen: - seen.add(target) - reach.add(target) - queue.append(target) - support_reach[source] = frozenset(reach) - - working_defeats = set(defeats) - all_derived: set[tuple[str, str]] = set() - while True: - new_derived: set[tuple[str, str]] = set() - - for defeated, target in working_defeats: - for source, reachable in support_reach.items(): - if defeated in reachable and source != target and (source, target) not in working_defeats: - new_derived.add((source, target)) - - for source, defeated in working_defeats: - reachable = support_reach.get(defeated) - if not reachable: - continue - for target in reachable: - if source != target and (source, target) not in working_defeats: - new_derived.add((source, target)) - - new_derived = {(source, target) for source, target in new_derived if source != target} - if not new_derived: - break - - working_defeats |= new_derived - all_derived |= new_derived - - return frozenset(all_derived) - - -def derived_set_defeats( - framework: BipolarArgumentationFramework, -) -> frozenset[tuple[str, str]]: - """Return the defeat closure induced by support/defeat interaction.""" - return frozenset( - set(framework.defeats) - | set(cayrol_derived_defeats(framework.defeats, framework.supports)) - ) - - -def _defeat_closure( - defeats: frozenset[tuple[str, str]], - supports: frozenset[tuple[str, str]], -) -> frozenset[tuple[str, str]]: - return frozenset(set(defeats) | set(cayrol_derived_defeats(defeats, supports))) - - -def _set_defeats( - args: frozenset[str], - target: str, - defeat_closure: frozenset[tuple[str, str]], -) -> bool: - return target in { - defeated - for source, defeated in defeat_closure - if source in args - } - - -def _conflict_free( - args: frozenset[str], - defeat_closure: frozenset[tuple[str, str]], -) -> bool: - return not any( - _set_defeats(args, target, defeat_closure) - for target in args - ) - - -def _safe( - args: frozenset[str], - framework: BipolarArgumentationFramework, - defeat_closure: frozenset[tuple[str, str]], -) -> bool: - for arg in framework.arguments: - if _set_defeats(args, arg, defeat_closure) and ( - set_supports(args, arg, framework) or arg in args - ): - return False - return True - - -def set_defeats( - args: frozenset[str], - target: str, - framework: BipolarArgumentationFramework, -) -> bool: - """Return whether ``args`` set-defeats ``target``.""" - return _set_defeats( - args, - target, - _defeat_closure(framework.defeats, framework.supports), - ) - - -def set_supports( - args: frozenset[str], - target: str, - framework: BipolarArgumentationFramework, -) -> bool: - """Return whether ``args`` set-supports ``target``.""" - return target in _supported_targets(args, framework.supports) - - -def support_closed( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Return whether ``args`` is closed under direct support.""" - return support_closure(args, framework.supports) == args - - -def conflict_free( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Cayrol 2005, Definition 6: no set-defeat within the set.""" - return _conflict_free( - args, - _defeat_closure(framework.defeats, framework.supports), - ) - - -def safe( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Cayrol 2005, Definition 7: no set-defeated argument is set-supported.""" - return _safe( - args, - framework, - _defeat_closure(framework.defeats, framework.supports), - ) - - -def defends( - args: frozenset[str], - arg: str, - framework: BipolarArgumentationFramework, - *, - defeat_closure: frozenset[tuple[str, str]] | None = None, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> bool: - """Cayrol 2005, Definition 5: collective defence via set-defeat.""" - closure = _closure_or_compute(framework, defeat_closure) - if attackers_index is None: - attackers_index = _attackers_index(closure) - attackers = attackers_index.get(arg, frozenset()) - for attacker in attackers: - if not any((defender, attacker) in closure for defender in args): - return False - return True - - -def d_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Cayrol 2005, Definition 9.""" - defeat_closure = _defeat_closure(framework.defeats, framework.supports) - return _d_admissible( - args, - framework, - defeat_closure, - _attackers_index(defeat_closure), - ) - - -def _d_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, - defeat_closure: frozenset[tuple[str, str]], - attackers_index: dict[str, frozenset[str]], -) -> bool: - return _conflict_free(args, defeat_closure) and all( - defends( - args, - arg, - framework, - defeat_closure=defeat_closure, - attackers_index=attackers_index, - ) - for arg in args - ) - - -def s_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Cayrol 2005, Definition 10.""" - defeat_closure = _defeat_closure(framework.defeats, framework.supports) - return _s_admissible( - args, - framework, - defeat_closure, - _attackers_index(defeat_closure), - ) - - -def _s_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, - defeat_closure: frozenset[tuple[str, str]], - attackers_index: dict[str, frozenset[str]], -) -> bool: - return _safe(args, framework, defeat_closure) and all( - defends( - args, - arg, - framework, - defeat_closure=defeat_closure, - attackers_index=attackers_index, - ) - for arg in args - ) - - -def c_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, -) -> bool: - """Cayrol 2005, Definition 11.""" - defeat_closure = _defeat_closure(framework.defeats, framework.supports) - return _c_admissible( - args, - framework, - defeat_closure, - _attackers_index(defeat_closure), - ) - - -def _c_admissible( - args: frozenset[str], - framework: BipolarArgumentationFramework, - defeat_closure: frozenset[tuple[str, str]], - attackers_index: dict[str, frozenset[str]], -) -> bool: - return ( - _conflict_free(args, defeat_closure) - and support_closed(args, framework) - and all( - defends( - args, - arg, - framework, - defeat_closure=defeat_closure, - attackers_index=attackers_index, - ) - for arg in args - ) - ) - - -def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = sorted(arguments) - subsets: list[frozenset[str]] = [] - for size in range(len(ordered) + 1): - for subset in combinations(ordered, size): - subsets.append(frozenset(subset)) - return subsets - - -def _maximal_sets( - framework: BipolarArgumentationFramework, - predicate, -) -> list[frozenset[str]]: - defeat_closure = derived_set_defeats(framework) - attackers_index = _attackers_index(defeat_closure) - closure_predicates = { - d_admissible: _d_admissible, - s_admissible: _s_admissible, - c_admissible: _c_admissible, - } - closure_predicate = closure_predicates.get(predicate) - admissible_sets = [ - candidate - for candidate in _all_subsets(framework.arguments) - if ( - closure_predicate( - candidate, - framework, - defeat_closure, - attackers_index, - ) - if closure_predicate is not None - else predicate(candidate, framework) - ) - ] - maximal = [ - candidate - for candidate in admissible_sets - if not any(candidate < other for other in admissible_sets) - ] - return sorted(maximal, key=lambda s: (len(s), tuple(sorted(s)))) - - -def d_preferred_extensions( - framework: BipolarArgumentationFramework, -) -> list[frozenset[str]]: - """Maximal d-admissible sets.""" - return _maximal_sets(framework, d_admissible) - - -def s_preferred_extensions( - framework: BipolarArgumentationFramework, -) -> list[frozenset[str]]: - """Maximal s-admissible sets.""" - return _maximal_sets(framework, s_admissible) - - -def c_preferred_extensions( - framework: BipolarArgumentationFramework, -) -> list[frozenset[str]]: - """Maximal c-admissible sets.""" - return _maximal_sets(framework, c_admissible) - - -def stable_extensions( - framework: BipolarArgumentationFramework, -) -> list[frozenset[str]]: - """Cayrol 2005, Definition 8: conflict-free and defeats every outsider.""" - defeat_closure = derived_set_defeats(framework) - stable: list[frozenset[str]] = [] - for candidate in _all_subsets(framework.arguments): - if not _conflict_free(candidate, defeat_closure): - continue - outsiders = framework.arguments - candidate - if all( - _set_defeats( - candidate, - target, - defeat_closure, - ) - for target in outsiders - ): - stable.append(candidate) - return sorted(stable, key=lambda s: (len(s), tuple(sorted(s)))) - - -def characteristic_fn( - args: frozenset[str], - framework: BipolarArgumentationFramework, - *, - defeat_closure: frozenset[tuple[str, str]] | None = None, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> frozenset[str]: - """Return the Cayrol/Dung characteristic function over set-defeats.""" - closure = _closure_or_compute(framework, defeat_closure) - if attackers_index is None: - attackers_index = _attackers_index(closure) - return frozenset( - argument - for argument in framework.arguments - if defends( - args, - argument, - framework, - defeat_closure=closure, - attackers_index=attackers_index, - ) - ) - - -def bipolar_grounded_extension( - framework: BipolarArgumentationFramework, -) -> frozenset[str]: - """Return the least fixed point over Cayrol set-defeat. - - Cayrol and Lagasquie-Schiex 2005, p. 385, instantiate Dung's framework - with set-defeats; Dung grounded is the least fixed point of the resulting - characteristic function. - """ - defeat_closure = derived_set_defeats(framework) - return dung_grounded_extension( - DungArgumentationFramework( - arguments=framework.arguments, - defeats=defeat_closure, - ) - ) - - -def bipolar_complete_extensions( - framework: BipolarArgumentationFramework, -) -> list[frozenset[str]]: - """Return fixed points of the Cayrol characteristic function.""" - defeat_closure = derived_set_defeats(framework) - attackers_index = _attackers_index(defeat_closure) - completes = [ - candidate - for candidate in _all_subsets(framework.arguments) - if _d_admissible( - candidate, - framework, - defeat_closure, - attackers_index, - ) - and characteristic_fn( - candidate, - framework, - defeat_closure=defeat_closure, - attackers_index=attackers_index, - ) == candidate - ] - return sorted(completes, key=lambda s: (len(s), tuple(sorted(s)))) +"""Explicit bipolar argumentation semantics for Cayrol-style frameworks. + +This module implements the Cayrol and Lagasquie-Schiex 2005 abstract +set-defeat account. Amgoud et al. 2008 distinguish richer support modes +(deductive, necessary, evidence-style); those are intentionally not collapsed +into this abstract support relation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations + +from argumentation.core.dung import ( + ArgumentationFramework as DungArgumentationFramework, + grounded_extension as dung_grounded_extension, +) + + +@dataclass(frozen=True) +class BipolarArgumentationFramework: + """A finite bipolar argumentation framework. + + Arguments are atomic. Defeats and supports are independent binary + relations, as in Cayrol & Lagasquie-Schiex (2005). + """ + + arguments: frozenset[str] + defeats: frozenset[tuple[str, str]] + supports: frozenset[tuple[str, str]] = frozenset() + + def __post_init__(self) -> None: + arguments = frozenset(self.arguments) + defeats = _normalize_relation("defeats", self.defeats, arguments) + supports = _normalize_relation("supports", self.supports, arguments) + + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "defeats", defeats) + object.__setattr__(self, "supports", supports) + + +def _normalize_relation( + name: str, + relation: frozenset[tuple[str, str]], + arguments: frozenset[str], +) -> frozenset[tuple[str, str]]: + normalized = frozenset((source, target) for source, target in relation) + unknown = sorted( + (source, target) + for source, target in normalized + if source not in arguments or target not in arguments + ) + if unknown: + raise ValueError( + f"{name} must only contain pairs over arguments: {unknown!r}" + ) + return normalized + + +def _support_successors(supports: frozenset[tuple[str, str]]) -> dict[str, frozenset[str]]: + successors: dict[str, set[str]] = {} + for source, target in supports: + successors.setdefault(source, set()).add(target) + return {source: frozenset(targets) for source, targets in successors.items()} + + +def _support_predecessors(supports: frozenset[tuple[str, str]]) -> dict[str, frozenset[str]]: + predecessors: dict[str, set[str]] = {} + for source, target in supports: + predecessors.setdefault(target, set()).add(source) + return {target: frozenset(sources) for target, sources in predecessors.items()} + + +def _attackers_index( + defeats: frozenset[tuple[str, str]], +) -> dict[str, frozenset[str]]: + attackers: dict[str, set[str]] = {} + for source, target in defeats: + attackers.setdefault(target, set()).add(source) + return {target: frozenset(sources) for target, sources in attackers.items()} + + +def _closure_or_compute( + framework: BipolarArgumentationFramework, + defeat_closure: frozenset[tuple[str, str]] | None, +) -> frozenset[tuple[str, str]]: + if defeat_closure is not None: + return defeat_closure + return _defeat_closure(framework.defeats, framework.supports) + + +def support_closure( + args: frozenset[str], + supports: frozenset[tuple[str, str]], +) -> frozenset[str]: + """Return the closure of ``args`` under direct support successors.""" + closure = set(args) + successors = _support_successors(supports) + queue = list(args) + while queue: + current = queue.pop() + for target in successors.get(current, frozenset()): + if target not in closure: + closure.add(target) + queue.append(target) + return frozenset(closure) + + +def _supported_targets( + args: frozenset[str], + supports: frozenset[tuple[str, str]], +) -> frozenset[str]: + supported: set[str] = set() + successors = _support_successors(supports) + for source in args: + seen: set[str] = set() + queue = list(successors.get(source, frozenset())) + seen.update(queue) + while queue: + current = queue.pop() + supported.add(current) + for target in successors.get(current, frozenset()): + if target not in seen: + seen.add(target) + queue.append(target) + return frozenset(supported) + + +def cayrol_derived_defeats( + defeats: frozenset[tuple[str, str]], + supports: frozenset[tuple[str, str]], +) -> frozenset[tuple[str, str]]: + """Return the derived defeats induced by support/defeat interaction. + + This computes Cayrol & Lagasquie-Schiex (2005, Definition 3) + supported and indirect defeats to a fixpoint. + """ + support_reach: dict[str, frozenset[str]] = {} + successors = _support_successors(supports) + for source in successors: + seen = {source} + queue = [source] + reach: set[str] = set() + while queue: + current = queue.pop() + for target in successors.get(current, frozenset()): + if target not in seen: + seen.add(target) + reach.add(target) + queue.append(target) + support_reach[source] = frozenset(reach) + + working_defeats = set(defeats) + all_derived: set[tuple[str, str]] = set() + while True: + new_derived: set[tuple[str, str]] = set() + + for defeated, target in working_defeats: + for source, reachable in support_reach.items(): + if defeated in reachable and source != target and (source, target) not in working_defeats: + new_derived.add((source, target)) + + for source, defeated in working_defeats: + reachable = support_reach.get(defeated) + if not reachable: + continue + for target in reachable: + if source != target and (source, target) not in working_defeats: + new_derived.add((source, target)) + + new_derived = {(source, target) for source, target in new_derived if source != target} + if not new_derived: + break + + working_defeats |= new_derived + all_derived |= new_derived + + return frozenset(all_derived) + + +def derived_set_defeats( + framework: BipolarArgumentationFramework, +) -> frozenset[tuple[str, str]]: + """Return the defeat closure induced by support/defeat interaction.""" + return frozenset( + set(framework.defeats) + | set(cayrol_derived_defeats(framework.defeats, framework.supports)) + ) + + +def _defeat_closure( + defeats: frozenset[tuple[str, str]], + supports: frozenset[tuple[str, str]], +) -> frozenset[tuple[str, str]]: + return frozenset(set(defeats) | set(cayrol_derived_defeats(defeats, supports))) + + +def _set_defeats( + args: frozenset[str], + target: str, + defeat_closure: frozenset[tuple[str, str]], +) -> bool: + return target in { + defeated + for source, defeated in defeat_closure + if source in args + } + + +def _conflict_free( + args: frozenset[str], + defeat_closure: frozenset[tuple[str, str]], +) -> bool: + return not any( + _set_defeats(args, target, defeat_closure) + for target in args + ) + + +def _safe( + args: frozenset[str], + framework: BipolarArgumentationFramework, + defeat_closure: frozenset[tuple[str, str]], +) -> bool: + for arg in framework.arguments: + if _set_defeats(args, arg, defeat_closure) and ( + set_supports(args, arg, framework) or arg in args + ): + return False + return True + + +def set_defeats( + args: frozenset[str], + target: str, + framework: BipolarArgumentationFramework, +) -> bool: + """Return whether ``args`` set-defeats ``target``.""" + return _set_defeats( + args, + target, + _defeat_closure(framework.defeats, framework.supports), + ) + + +def set_supports( + args: frozenset[str], + target: str, + framework: BipolarArgumentationFramework, +) -> bool: + """Return whether ``args`` set-supports ``target``.""" + return target in _supported_targets(args, framework.supports) + + +def support_closed( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Return whether ``args`` is closed under direct support.""" + return support_closure(args, framework.supports) == args + + +def conflict_free( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Cayrol 2005, Definition 6: no set-defeat within the set.""" + return _conflict_free( + args, + _defeat_closure(framework.defeats, framework.supports), + ) + + +def safe( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Cayrol 2005, Definition 7: no set-defeated argument is set-supported.""" + return _safe( + args, + framework, + _defeat_closure(framework.defeats, framework.supports), + ) + + +def defends( + args: frozenset[str], + arg: str, + framework: BipolarArgumentationFramework, + *, + defeat_closure: frozenset[tuple[str, str]] | None = None, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> bool: + """Cayrol 2005, Definition 5: collective defence via set-defeat.""" + closure = _closure_or_compute(framework, defeat_closure) + if attackers_index is None: + attackers_index = _attackers_index(closure) + attackers = attackers_index.get(arg, frozenset()) + for attacker in attackers: + if not any((defender, attacker) in closure for defender in args): + return False + return True + + +def d_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Cayrol 2005, Definition 9.""" + defeat_closure = _defeat_closure(framework.defeats, framework.supports) + return _d_admissible( + args, + framework, + defeat_closure, + _attackers_index(defeat_closure), + ) + + +def _d_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, + defeat_closure: frozenset[tuple[str, str]], + attackers_index: dict[str, frozenset[str]], +) -> bool: + return _conflict_free(args, defeat_closure) and all( + defends( + args, + arg, + framework, + defeat_closure=defeat_closure, + attackers_index=attackers_index, + ) + for arg in args + ) + + +def s_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Cayrol 2005, Definition 10.""" + defeat_closure = _defeat_closure(framework.defeats, framework.supports) + return _s_admissible( + args, + framework, + defeat_closure, + _attackers_index(defeat_closure), + ) + + +def _s_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, + defeat_closure: frozenset[tuple[str, str]], + attackers_index: dict[str, frozenset[str]], +) -> bool: + return _safe(args, framework, defeat_closure) and all( + defends( + args, + arg, + framework, + defeat_closure=defeat_closure, + attackers_index=attackers_index, + ) + for arg in args + ) + + +def c_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, +) -> bool: + """Cayrol 2005, Definition 11.""" + defeat_closure = _defeat_closure(framework.defeats, framework.supports) + return _c_admissible( + args, + framework, + defeat_closure, + _attackers_index(defeat_closure), + ) + + +def _c_admissible( + args: frozenset[str], + framework: BipolarArgumentationFramework, + defeat_closure: frozenset[tuple[str, str]], + attackers_index: dict[str, frozenset[str]], +) -> bool: + return ( + _conflict_free(args, defeat_closure) + and support_closed(args, framework) + and all( + defends( + args, + arg, + framework, + defeat_closure=defeat_closure, + attackers_index=attackers_index, + ) + for arg in args + ) + ) + + +def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = sorted(arguments) + subsets: list[frozenset[str]] = [] + for size in range(len(ordered) + 1): + for subset in combinations(ordered, size): + subsets.append(frozenset(subset)) + return subsets + + +def _maximal_sets( + framework: BipolarArgumentationFramework, + predicate, +) -> list[frozenset[str]]: + defeat_closure = derived_set_defeats(framework) + attackers_index = _attackers_index(defeat_closure) + closure_predicates = { + d_admissible: _d_admissible, + s_admissible: _s_admissible, + c_admissible: _c_admissible, + } + closure_predicate = closure_predicates.get(predicate) + admissible_sets = [ + candidate + for candidate in _all_subsets(framework.arguments) + if ( + closure_predicate( + candidate, + framework, + defeat_closure, + attackers_index, + ) + if closure_predicate is not None + else predicate(candidate, framework) + ) + ] + maximal = [ + candidate + for candidate in admissible_sets + if not any(candidate < other for other in admissible_sets) + ] + return sorted(maximal, key=lambda s: (len(s), tuple(sorted(s)))) + + +def d_preferred_extensions( + framework: BipolarArgumentationFramework, +) -> list[frozenset[str]]: + """Maximal d-admissible sets.""" + return _maximal_sets(framework, d_admissible) + + +def s_preferred_extensions( + framework: BipolarArgumentationFramework, +) -> list[frozenset[str]]: + """Maximal s-admissible sets.""" + return _maximal_sets(framework, s_admissible) + + +def c_preferred_extensions( + framework: BipolarArgumentationFramework, +) -> list[frozenset[str]]: + """Maximal c-admissible sets.""" + return _maximal_sets(framework, c_admissible) + + +def stable_extensions( + framework: BipolarArgumentationFramework, +) -> list[frozenset[str]]: + """Cayrol 2005, Definition 8: conflict-free and defeats every outsider.""" + defeat_closure = derived_set_defeats(framework) + stable: list[frozenset[str]] = [] + for candidate in _all_subsets(framework.arguments): + if not _conflict_free(candidate, defeat_closure): + continue + outsiders = framework.arguments - candidate + if all( + _set_defeats( + candidate, + target, + defeat_closure, + ) + for target in outsiders + ): + stable.append(candidate) + return sorted(stable, key=lambda s: (len(s), tuple(sorted(s)))) + + +def characteristic_fn( + args: frozenset[str], + framework: BipolarArgumentationFramework, + *, + defeat_closure: frozenset[tuple[str, str]] | None = None, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> frozenset[str]: + """Return the Cayrol/Dung characteristic function over set-defeats.""" + closure = _closure_or_compute(framework, defeat_closure) + if attackers_index is None: + attackers_index = _attackers_index(closure) + return frozenset( + argument + for argument in framework.arguments + if defends( + args, + argument, + framework, + defeat_closure=closure, + attackers_index=attackers_index, + ) + ) + + +def bipolar_grounded_extension( + framework: BipolarArgumentationFramework, +) -> frozenset[str]: + """Return the least fixed point over Cayrol set-defeat. + + Cayrol and Lagasquie-Schiex 2005, p. 385, instantiate Dung's framework + with set-defeats; Dung grounded is the least fixed point of the resulting + characteristic function. + """ + defeat_closure = derived_set_defeats(framework) + return dung_grounded_extension( + DungArgumentationFramework( + arguments=framework.arguments, + defeats=defeat_closure, + ) + ) + + +def bipolar_complete_extensions( + framework: BipolarArgumentationFramework, +) -> list[frozenset[str]]: + """Return fixed points of the Cayrol characteristic function.""" + defeat_closure = derived_set_defeats(framework) + attackers_index = _attackers_index(defeat_closure) + completes = [ + candidate + for candidate in _all_subsets(framework.arguments) + if _d_admissible( + candidate, + framework, + defeat_closure, + attackers_index, + ) + and characteristic_fn( + candidate, + framework, + defeat_closure=defeat_closure, + attackers_index=attackers_index, + ) == candidate + ] + return sorted(completes, key=lambda s: (len(s), tuple(sorted(s)))) diff --git a/src/argumentation/dung.py b/src/argumentation/core/dung.py similarity index 96% rename from src/argumentation/dung.py rename to src/argumentation/core/dung.py index db76182..1c01704 100644 --- a/src/argumentation/dung.py +++ b/src/argumentation/core/dung.py @@ -1,720 +1,720 @@ -"""Dung's abstract argumentation framework and extension semantics. - -Implements grounded, preferred, stable, and complete extensions -over an abstract argumentation framework AF = (Args, Defeats). - -References: - Dung, P.M. (1995). On the acceptability of arguments and its - fundamental role in nonmonotonic reasoning, logic programming - and n-person games. Artificial Intelligence, 77(2), 321-357. -""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass -from itertools import combinations - - -@dataclass(frozen=True) -class ArgumentationFramework: - """Argumentation framework with attack and defeat relations. - - Arguments are string identifiers. Defeats is a set of - (attacker, target) pairs representing the defeat relation - (attacks surviving preference filter). Attacks is the full - set of attacks before preference filtering. - - Pure Dung semantics use ``defeats`` only. Consumers that need the - full pre-preference attack layer can consult ``attacks`` explicitly. - - References: - Dung 1995: AF = (Args, Defeats) - Modgil & Prakken 2018 Def 14: conflict-free uses attacks, not defeats - """ - - arguments: frozenset[str] - defeats: frozenset[tuple[str, str]] - attacks: frozenset[tuple[str, str]] | None = None - - def __post_init__(self) -> None: - arguments = frozenset(self.arguments) - defeats = _normalize_relation("defeats", self.defeats, arguments) - attacks = ( - None - if self.attacks is None - else _normalize_relation("attacks", self.attacks, arguments) - ) - - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "defeats", defeats) - object.__setattr__(self, "attacks", attacks) - - -def _normalize_relation( - name: str, - relation: frozenset[tuple[str, str]], - arguments: frozenset[str], -) -> frozenset[tuple[str, str]]: - normalized = frozenset((attacker, target) for attacker, target in relation) - unknown = sorted( - (attacker, target) - for attacker, target in normalized - if attacker not in arguments or target not in arguments - ) - if unknown: - raise ValueError( - f"{name} must only contain pairs over arguments: {unknown!r}" - ) - return normalized - - -def _attackers_index( - defeats: frozenset[tuple[str, str]], -) -> dict[str, frozenset[str]]: - """Build target -> attackers adjacency for a defeat relation.""" - attackers: dict[str, set[str]] = {} - for attacker, target in defeats: - attackers.setdefault(target, set()).add(attacker) - return { - target: frozenset(sources) - for target, sources in attackers.items() - } - - -def _targets_index( - defeats: frozenset[tuple[str, str]], -) -> dict[str, frozenset[str]]: - """Build source -> targets adjacency for a defeat relation.""" - targets: dict[str, set[str]] = {} - for attacker, target in defeats: - targets.setdefault(attacker, set()).add(target) - return { - source: frozenset(destinations) - for source, destinations in targets.items() - } - - -def attackers_of( - arg: str, - defeats: frozenset[tuple[str, str]], - *, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> frozenset[str]: - """Return the set of all arguments that defeat `arg`.""" - if attackers_index is None: - attackers_index = _attackers_index(defeats) - return attackers_index.get(arg, frozenset()) - - -def conflict_free(s: frozenset[str], relation: frozenset[tuple[str, str]]) -> bool: - """Check if s is conflict-free w.r.t. a binary relation. - - No argument in s is related to another in s under the given relation. - Per Modgil & Prakken 2018 Def 14, this should be the attack relation - (pre-preference), not the defeat relation. When only defeats are - available (pure Dung AF), pass defeats. - """ - for a, t in relation: - if a in s and t in s: - return False - return True - - -def defends( - s: frozenset[str], - arg: str, - all_args: frozenset[str], # noqa: ARG001 - defeats: frozenset[tuple[str, str]], - *, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> bool: - """Check if s defends arg: for every attacker of arg, s counter-attacks it.""" - if attackers_index is None: - attackers_index = _attackers_index(defeats) - for attacker in attackers_of(arg, defeats, attackers_index=attackers_index): - if not any((d, attacker) in defeats for d in s): - return False - return True - - -def characteristic_fn( - s: frozenset[str], - all_args: frozenset[str], - defeats: frozenset[tuple[str, str]], - *, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> frozenset[str]: - """Characteristic function F(S) = {A in Args | A is defended by S}. - - Reference: Dung 1995, Definition 17. - """ - if attackers_index is None: - attackers_index = _attackers_index(defeats) - return frozenset( - a - for a in all_args - if defends( - s, - a, - all_args, - defeats, - attackers_index=attackers_index, - ) - ) - - -def range_of( - s: frozenset[str], - defeats: frozenset[tuple[str, str]], -) -> frozenset[str]: - """Return ``s`` plus every argument defeated by an argument in ``s``.""" - defeated = frozenset(target for attacker, target in defeats if attacker in s) - return s | defeated - - -def admissible( - s: frozenset[str], - all_args: frozenset[str], - defeats: frozenset[tuple[str, str]], - *, - attacks: frozenset[tuple[str, str]] | None = None, - attackers_index: dict[str, frozenset[str]] | None = None, -) -> bool: - """Check if s is admissible: conflict-free and defends all its members. - - Conflict-free is checked against attacks (Modgil & Prakken 2018 Def 14). - Defense is checked against defeats (Dung 1995 Def 6). - When attacks is None, defeats is used for both. - """ - cf_relation = attacks if attacks is not None else defeats - if not conflict_free(s, cf_relation): - return False - if attackers_index is None: - attackers_index = _attackers_index(defeats) - for a in s: - if not defends( - s, - a, - all_args, - defeats, - attackers_index=attackers_index, - ): - return False - return True - - -def grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: - """Compute the unique grounded extension. - - This is pure Dung grounded semantics: the least fixed point of the - characteristic function over ``defeats`` only. Attack metadata is - ignored here. - - References: - Dung 1995, Definition 20 + Theorem 25 (least fixed point). - """ - attackers_index = _attackers_index(framework.defeats) - targets_index = _targets_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, - *, - max_candidates: int | None = None, -) -> list[frozenset[str]]: - """Compute all complete extensions. - - A complete extension is a fixed point of F that is admissible. - - Reference: Dung 1995, Definition 10. - """ - from argumentation.labelling import ( - DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, - complete_labellings, - ) - - attackers_index = _attackers_index(framework.defeats) - candidate_budget = ( - DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET - if max_candidates is None - else max_candidates - ) - return [ - labelling.extension - for labelling in complete_labellings( - framework, - max_candidates=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]]: - """Compute all preferred extensions. - - A preferred extension is a maximal (w.r.t. set inclusion) admissible set, - equivalently a maximal complete extension. - - Reference: Dung 1995, Definition 8. - """ - completes = complete_extensions(framework) - return [ - extension - for extension in completes - if not any(extension < other for other in completes) - ] - - -def stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute all stable extensions. - - A stable extension is conflict-free and defeats every argument not in it. - When ``framework.attacks`` is present, conflict-freeness is checked against - attacks while outsider coverage is checked against defeats. - - References: - Dung 1995, Definition 12. - Modgil & Prakken 2018, Definition 14. - - WARNING: Stable extensions may not exist. - """ - from argumentation.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) - ] - - -def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = sorted(arguments) - return [ - frozenset(ordered[index] for index in range(len(ordered)) if mask & (1 << index)) - for mask in range(1 << len(ordered)) - ] - - -def _range_maximal_extensions( - candidates: list[frozenset[str]], - defeats: frozenset[tuple[str, str]], -) -> list[frozenset[str]]: - maximal: list[frozenset[str]] = [] - ranges = { - candidate: range_of(candidate, defeats) - for candidate in candidates - } - for candidate in candidates: - candidate_range = ranges[candidate] - if not any(candidate_range < other_range for other_range in ranges.values()): - maximal.append(candidate) - return maximal - - -def semi_stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute all semi-stable extensions. - - A semi-stable extension is a complete extension whose range is maximal - under set inclusion. - - Reference: - Caminada 2011, Definition 2.3. - """ - return _range_maximal_extensions(complete_extensions(framework), framework.defeats) - - -def stage_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute all stage extensions by range-maximal conflict-free sets. - - Gaggl and Woltran 2013, p. 927: stage extensions are conflict-free sets - with maximal range. - """ - args = framework.arguments - cf_relation = framework.attacks if framework.attacks is not None else framework.defeats - candidates: list[frozenset[str]] = [] - for size in range(len(args) + 1): - for subset in combinations(sorted(args), size): - s = frozenset(subset) - if conflict_free(s, cf_relation): - candidates.append(s) - - return _range_maximal_extensions(candidates, framework.defeats) - - -def eager_extension(framework: ArgumentationFramework) -> frozenset[str]: - """Compute the unique eager extension. - - Caminada 2007's eager extension is the least committed semi-stable choice: - if semi-stable is unique, return it; otherwise return the largest - admissible subset of the intersection of all semi-stable extensions. - """ - semi_stables = semi_stable_extensions(framework) - if not semi_stables: - return frozenset() - intersection = frozenset.intersection(*semi_stables) - attackers_index = _attackers_index(framework.defeats) - candidates = [ - candidate - for candidate in _all_subsets(intersection) - if admissible( - candidate, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - attackers_index=attackers_index, - ) - ] - return max(candidates, key=lambda candidate: (len(candidate), tuple(sorted(candidate)))) - - -def _strongly_connected_components( - arguments: frozenset[str], - defeats: frozenset[tuple[str, str]], -) -> list[frozenset[str]]: - index = 0 - stack: list[str] = [] - on_stack: set[str] = set() - indices: dict[str, int] = {} - lowlinks: dict[str, int] = {} - components: list[frozenset[str]] = [] - outgoing: dict[str, list[str]] = {argument: [] for argument in arguments} - for attacker, target in defeats: - outgoing.setdefault(attacker, []).append(target) - - def connect(argument: str) -> None: - nonlocal index - indices[argument] = index - lowlinks[argument] = index - index += 1 - stack.append(argument) - on_stack.add(argument) - - for target in sorted(outgoing.get(argument, [])): - if target not in indices: - connect(target) - lowlinks[argument] = min(lowlinks[argument], lowlinks[target]) - elif target in on_stack: - lowlinks[argument] = min(lowlinks[argument], indices[target]) - - if lowlinks[argument] == indices[argument]: - component: set[str] = set() - while True: - member = stack.pop() - on_stack.remove(member) - component.add(member) - if member == argument: - break - components.append(frozenset(component)) - - for argument in sorted(arguments): - if argument not in indices: - connect(argument) - - return sorted(components, key=lambda component: tuple(sorted(component))) - - -def _subframework( - framework: ArgumentationFramework, - arguments: frozenset[str], -) -> ArgumentationFramework: - defeats = frozenset( - (attacker, target) - for attacker, target in framework.defeats - if attacker in arguments and target in arguments - ) - attacks = ( - None - if framework.attacks is None - else frozenset( - (attacker, target) - for attacker, target in framework.attacks - if attacker in arguments and target in arguments - ) - ) - return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) - - -def naive_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute all maximal conflict-free sets.""" - candidates = [ - candidate - for candidate in _all_subsets(framework.arguments) - if conflict_free(candidate, framework.defeats) - ] - return [ - candidate - for candidate in candidates - if not any(candidate < other for other in candidates) - ] - - -def _component_defeated( - framework: ArgumentationFramework, - candidate: frozenset[str], - components: list[frozenset[str]], -) -> frozenset[str]: - component_by_argument = { - argument: component - for component in components - for argument in component - } - return frozenset( - target - for attacker, target in framework.defeats - if attacker in candidate - and component_by_argument[attacker] != component_by_argument[target] - ) - - -def _is_cf2_extension( - framework: ArgumentationFramework, - candidate: frozenset[str], -) -> bool: - if not candidate <= framework.arguments: - return False - - components = _strongly_connected_components( - framework.arguments, - framework.defeats, - ) - if len(components) <= 1: - return candidate in naive_extensions(framework) - - defeated = _component_defeated(framework, candidate, components) - for component in components: - sub_arguments = component - defeated - subframework = _subframework(framework, sub_arguments) - if not _is_cf2_extension(subframework, candidate & component): - return False - return True - - -def cf2_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute all CF2 extensions by recursive SCC decomposition. - - The base case for a single strongly connected component is the naive - semantics. Solver-backed CF2 reasoning is a later workstream item. - - Reference: - Gaggl and Woltran 2013, Definition 2.7. - """ - return [ - candidate - for candidate in _all_subsets(framework.arguments) - if _is_cf2_extension(framework, candidate) - ] - - -def stage2_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Compute SCC-recursive stage2 extensions. - - Gaggl and Woltran 2013 give the SCC-recursive shape for CF2; stage2 uses - the same component recursion with stage semantics as the single-SCC base. - """ - return [ - candidate - for candidate in _all_subsets(framework.arguments) - if _is_stage2_extension(framework, candidate) - ] - - -def _is_stage2_extension( - framework: ArgumentationFramework, - candidate: frozenset[str], -) -> bool: - if not candidate <= framework.arguments: - return False - - components = _strongly_connected_components( - framework.arguments, - framework.defeats, - ) - if len(components) <= 1: - return candidate in stage_extensions(framework) - - defeated = _component_defeated(framework, candidate, components) - for component in components: - sub_arguments = component - defeated - subframework = _subframework(framework, sub_arguments) - if not _is_stage2_extension(subframework, candidate & component): - return False - return True - - -def indirect_attacks(framework: ArgumentationFramework) -> frozenset[tuple[str, str]]: - """Return odd-length attack paths for prudent semantics. - - Coste-Marquis, Devred, and Marquis 2005, pp. 1-2 define the prudent - indirect-conflict check over odd-length attack paths. - """ - indirect: set[tuple[str, str]] = set() - successors: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in framework.defeats: - successors.setdefault(attacker, set()).add(target) - - for source in framework.arguments: - stack = [(source, target, 1) for target in successors.get(source, set())] - seen: set[tuple[str, int]] = set() - while stack: - origin, current, length = stack.pop() - parity = length % 2 - if (current, parity) in seen: - continue - seen.add((current, parity)) - if length > 1 and parity == 1: - indirect.add((origin, current)) - for target in successors.get(current, set()): - stack.append((origin, target, length + 1)) - return frozenset(indirect) - - -def prudent_conflict_free( - framework: ArgumentationFramework, - candidate: frozenset[str], -) -> bool: - """Return whether ``candidate`` has no prudent indirect conflict.""" - indirect = indirect_attacks(framework) - return not any( - attacker in candidate and target in candidate - for attacker, target in indirect - ) - - -def prudent_admissible( - framework: ArgumentationFramework, - candidate: frozenset[str], -) -> bool: - """Return prudent admissibility: admissible and no indirect conflicts.""" - return prudent_conflict_free(framework, candidate) and admissible( - candidate, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - ) - - -def prudent_preferred_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: - """Return inclusion-maximal prudent-admissible sets.""" - candidates = [ - candidate - for candidate in _all_subsets(framework.arguments) - if prudent_admissible(framework, candidate) - ] - return [ - candidate - for candidate in candidates - if not any(candidate < other for other in candidates) - ] - - -def prudent_grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: - """Return the stationary prudent grounded extension. - - Coste-Marquis, Devred, and Marquis 2005, p. 3 define grounded prudent - semantics by iterating the prudent characteristic function from empty. - """ - current: frozenset[str] = frozenset() - attackers_index = _attackers_index(framework.defeats) - indirect = indirect_attacks(framework) - while True: - next_current = frozenset( - argument - for argument in framework.arguments - if defends( - current, - argument, - framework.arguments, - framework.defeats, - attackers_index=attackers_index, - ) - and not any( - attacker in current | frozenset({argument}) - and target in current | frozenset({argument}) - for attacker, target in indirect - ) - ) - if next_current == current: - return current - current = next_current - - -def ideal_extension(framework: ArgumentationFramework) -> frozenset[str]: - """Compute the unique maximal ideal extension. - - An ideal set is admissible and contained in every preferred extension. The - ideal extension is the unique maximal ideal set. - - Reference: - Dung, Mancarella, and Toni 2007, Definition 2.2 and Theorem 2.1. - """ - preferred = preferred_extensions(framework) - if not preferred: - return frozenset() - - common = set(preferred[0]) - for extension in preferred[1:]: - common.intersection_update(extension) - - attackers_index = _attackers_index(framework.defeats) - candidates = [ - candidate - for candidate in _all_subsets(frozenset(common)) - if admissible( - candidate, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - attackers_index=attackers_index, - ) - ] - maximal = [ - candidate - for candidate in candidates - if not any(candidate < other for other in candidates) - ] - if len(maximal) != 1: - raise AssertionError( - "ideal extension construction must have exactly one maximal " - "admissible subset of the preferred-extension intersection" - ) - return maximal[0] +"""Dung's abstract argumentation framework and extension semantics. + +Implements grounded, preferred, stable, and complete extensions +over an abstract argumentation framework AF = (Args, Defeats). + +References: + Dung, P.M. (1995). On the acceptability of arguments and its + fundamental role in nonmonotonic reasoning, logic programming + and n-person games. Artificial Intelligence, 77(2), 321-357. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from itertools import combinations + + +@dataclass(frozen=True) +class ArgumentationFramework: + """Argumentation framework with attack and defeat relations. + + Arguments are string identifiers. Defeats is a set of + (attacker, target) pairs representing the defeat relation + (attacks surviving preference filter). Attacks is the full + set of attacks before preference filtering. + + Pure Dung semantics use ``defeats`` only. Consumers that need the + full pre-preference attack layer can consult ``attacks`` explicitly. + + References: + Dung 1995: AF = (Args, Defeats) + Modgil & Prakken 2018 Def 14: conflict-free uses attacks, not defeats + """ + + arguments: frozenset[str] + defeats: frozenset[tuple[str, str]] + attacks: frozenset[tuple[str, str]] | None = None + + def __post_init__(self) -> None: + arguments = frozenset(self.arguments) + defeats = _normalize_relation("defeats", self.defeats, arguments) + attacks = ( + None + if self.attacks is None + else _normalize_relation("attacks", self.attacks, arguments) + ) + + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "defeats", defeats) + object.__setattr__(self, "attacks", attacks) + + +def _normalize_relation( + name: str, + relation: frozenset[tuple[str, str]], + arguments: frozenset[str], +) -> frozenset[tuple[str, str]]: + normalized = frozenset((attacker, target) for attacker, target in relation) + unknown = sorted( + (attacker, target) + for attacker, target in normalized + if attacker not in arguments or target not in arguments + ) + if unknown: + raise ValueError( + f"{name} must only contain pairs over arguments: {unknown!r}" + ) + return normalized + + +def _attackers_index( + defeats: frozenset[tuple[str, str]], +) -> dict[str, frozenset[str]]: + """Build target -> attackers adjacency for a defeat relation.""" + attackers: dict[str, set[str]] = {} + for attacker, target in defeats: + attackers.setdefault(target, set()).add(attacker) + return { + target: frozenset(sources) + for target, sources in attackers.items() + } + + +def _targets_index( + defeats: frozenset[tuple[str, str]], +) -> dict[str, frozenset[str]]: + """Build source -> targets adjacency for a defeat relation.""" + targets: dict[str, set[str]] = {} + for attacker, target in defeats: + targets.setdefault(attacker, set()).add(target) + return { + source: frozenset(destinations) + for source, destinations in targets.items() + } + + +def attackers_of( + arg: str, + defeats: frozenset[tuple[str, str]], + *, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> frozenset[str]: + """Return the set of all arguments that defeat `arg`.""" + if attackers_index is None: + attackers_index = _attackers_index(defeats) + return attackers_index.get(arg, frozenset()) + + +def conflict_free(s: frozenset[str], relation: frozenset[tuple[str, str]]) -> bool: + """Check if s is conflict-free w.r.t. a binary relation. + + No argument in s is related to another in s under the given relation. + Per Modgil & Prakken 2018 Def 14, this should be the attack relation + (pre-preference), not the defeat relation. When only defeats are + available (pure Dung AF), pass defeats. + """ + for a, t in relation: + if a in s and t in s: + return False + return True + + +def defends( + s: frozenset[str], + arg: str, + all_args: frozenset[str], # noqa: ARG001 + defeats: frozenset[tuple[str, str]], + *, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> bool: + """Check if s defends arg: for every attacker of arg, s counter-attacks it.""" + if attackers_index is None: + attackers_index = _attackers_index(defeats) + for attacker in attackers_of(arg, defeats, attackers_index=attackers_index): + if not any((d, attacker) in defeats for d in s): + return False + return True + + +def characteristic_fn( + s: frozenset[str], + all_args: frozenset[str], + defeats: frozenset[tuple[str, str]], + *, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> frozenset[str]: + """Characteristic function F(S) = {A in Args | A is defended by S}. + + Reference: Dung 1995, Definition 17. + """ + if attackers_index is None: + attackers_index = _attackers_index(defeats) + return frozenset( + a + for a in all_args + if defends( + s, + a, + all_args, + defeats, + attackers_index=attackers_index, + ) + ) + + +def range_of( + s: frozenset[str], + defeats: frozenset[tuple[str, str]], +) -> frozenset[str]: + """Return ``s`` plus every argument defeated by an argument in ``s``.""" + defeated = frozenset(target for attacker, target in defeats if attacker in s) + return s | defeated + + +def admissible( + s: frozenset[str], + all_args: frozenset[str], + defeats: frozenset[tuple[str, str]], + *, + attacks: frozenset[tuple[str, str]] | None = None, + attackers_index: dict[str, frozenset[str]] | None = None, +) -> bool: + """Check if s is admissible: conflict-free and defends all its members. + + Conflict-free is checked against attacks (Modgil & Prakken 2018 Def 14). + Defense is checked against defeats (Dung 1995 Def 6). + When attacks is None, defeats is used for both. + """ + cf_relation = attacks if attacks is not None else defeats + if not conflict_free(s, cf_relation): + return False + if attackers_index is None: + attackers_index = _attackers_index(defeats) + for a in s: + if not defends( + s, + a, + all_args, + defeats, + attackers_index=attackers_index, + ): + return False + return True + + +def grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: + """Compute the unique grounded extension. + + This is pure Dung grounded semantics: the least fixed point of the + characteristic function over ``defeats`` only. Attack metadata is + ignored here. + + References: + Dung 1995, Definition 20 + Theorem 25 (least fixed point). + """ + attackers_index = _attackers_index(framework.defeats) + targets_index = _targets_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, + *, + max_candidates: int | None = None, +) -> list[frozenset[str]]: + """Compute all complete extensions. + + A complete extension is a fixed point of F that is admissible. + + Reference: Dung 1995, Definition 10. + """ + from argumentation.core.labelling import ( + DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, + complete_labellings, + ) + + attackers_index = _attackers_index(framework.defeats) + candidate_budget = ( + DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET + if max_candidates is None + else max_candidates + ) + return [ + labelling.extension + for labelling in complete_labellings( + framework, + max_candidates=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]]: + """Compute all preferred extensions. + + A preferred extension is a maximal (w.r.t. set inclusion) admissible set, + equivalently a maximal complete extension. + + Reference: Dung 1995, Definition 8. + """ + completes = complete_extensions(framework) + return [ + extension + for extension in completes + if not any(extension < other for other in completes) + ] + + +def stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute all stable extensions. + + A stable extension is conflict-free and defeats every argument not in it. + When ``framework.attacks`` is present, conflict-freeness is checked against + attacks while outsider coverage is checked against defeats. + + References: + Dung 1995, Definition 12. + Modgil & Prakken 2018, Definition 14. + + WARNING: Stable extensions may not exist. + """ + 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) + ] + + +def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = sorted(arguments) + return [ + frozenset(ordered[index] for index in range(len(ordered)) if mask & (1 << index)) + for mask in range(1 << len(ordered)) + ] + + +def _range_maximal_extensions( + candidates: list[frozenset[str]], + defeats: frozenset[tuple[str, str]], +) -> list[frozenset[str]]: + maximal: list[frozenset[str]] = [] + ranges = { + candidate: range_of(candidate, defeats) + for candidate in candidates + } + for candidate in candidates: + candidate_range = ranges[candidate] + if not any(candidate_range < other_range for other_range in ranges.values()): + maximal.append(candidate) + return maximal + + +def semi_stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute all semi-stable extensions. + + A semi-stable extension is a complete extension whose range is maximal + under set inclusion. + + Reference: + Caminada 2011, Definition 2.3. + """ + return _range_maximal_extensions(complete_extensions(framework), framework.defeats) + + +def stage_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute all stage extensions by range-maximal conflict-free sets. + + Gaggl and Woltran 2013, p. 927: stage extensions are conflict-free sets + with maximal range. + """ + args = framework.arguments + cf_relation = framework.attacks if framework.attacks is not None else framework.defeats + candidates: list[frozenset[str]] = [] + for size in range(len(args) + 1): + for subset in combinations(sorted(args), size): + s = frozenset(subset) + if conflict_free(s, cf_relation): + candidates.append(s) + + return _range_maximal_extensions(candidates, framework.defeats) + + +def eager_extension(framework: ArgumentationFramework) -> frozenset[str]: + """Compute the unique eager extension. + + Caminada 2007's eager extension is the least committed semi-stable choice: + if semi-stable is unique, return it; otherwise return the largest + admissible subset of the intersection of all semi-stable extensions. + """ + semi_stables = semi_stable_extensions(framework) + if not semi_stables: + return frozenset() + intersection = frozenset.intersection(*semi_stables) + attackers_index = _attackers_index(framework.defeats) + candidates = [ + candidate + for candidate in _all_subsets(intersection) + if admissible( + candidate, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + attackers_index=attackers_index, + ) + ] + return max(candidates, key=lambda candidate: (len(candidate), tuple(sorted(candidate)))) + + +def _strongly_connected_components( + arguments: frozenset[str], + defeats: frozenset[tuple[str, str]], +) -> list[frozenset[str]]: + index = 0 + stack: list[str] = [] + on_stack: set[str] = set() + indices: dict[str, int] = {} + lowlinks: dict[str, int] = {} + components: list[frozenset[str]] = [] + outgoing: dict[str, list[str]] = {argument: [] for argument in arguments} + for attacker, target in defeats: + outgoing.setdefault(attacker, []).append(target) + + def connect(argument: str) -> None: + nonlocal index + indices[argument] = index + lowlinks[argument] = index + index += 1 + stack.append(argument) + on_stack.add(argument) + + for target in sorted(outgoing.get(argument, [])): + if target not in indices: + connect(target) + lowlinks[argument] = min(lowlinks[argument], lowlinks[target]) + elif target in on_stack: + lowlinks[argument] = min(lowlinks[argument], indices[target]) + + if lowlinks[argument] == indices[argument]: + component: set[str] = set() + while True: + member = stack.pop() + on_stack.remove(member) + component.add(member) + if member == argument: + break + components.append(frozenset(component)) + + for argument in sorted(arguments): + if argument not in indices: + connect(argument) + + return sorted(components, key=lambda component: tuple(sorted(component))) + + +def _subframework( + framework: ArgumentationFramework, + arguments: frozenset[str], +) -> ArgumentationFramework: + defeats = frozenset( + (attacker, target) + for attacker, target in framework.defeats + if attacker in arguments and target in arguments + ) + attacks = ( + None + if framework.attacks is None + else frozenset( + (attacker, target) + for attacker, target in framework.attacks + if attacker in arguments and target in arguments + ) + ) + return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) + + +def naive_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute all maximal conflict-free sets.""" + candidates = [ + candidate + for candidate in _all_subsets(framework.arguments) + if conflict_free(candidate, framework.defeats) + ] + return [ + candidate + for candidate in candidates + if not any(candidate < other for other in candidates) + ] + + +def _component_defeated( + framework: ArgumentationFramework, + candidate: frozenset[str], + components: list[frozenset[str]], +) -> frozenset[str]: + component_by_argument = { + argument: component + for component in components + for argument in component + } + return frozenset( + target + for attacker, target in framework.defeats + if attacker in candidate + and component_by_argument[attacker] != component_by_argument[target] + ) + + +def _is_cf2_extension( + framework: ArgumentationFramework, + candidate: frozenset[str], +) -> bool: + if not candidate <= framework.arguments: + return False + + components = _strongly_connected_components( + framework.arguments, + framework.defeats, + ) + if len(components) <= 1: + return candidate in naive_extensions(framework) + + defeated = _component_defeated(framework, candidate, components) + for component in components: + sub_arguments = component - defeated + subframework = _subframework(framework, sub_arguments) + if not _is_cf2_extension(subframework, candidate & component): + return False + return True + + +def cf2_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute all CF2 extensions by recursive SCC decomposition. + + The base case for a single strongly connected component is the naive + semantics. Solver-backed CF2 reasoning is a later workstream item. + + Reference: + Gaggl and Woltran 2013, Definition 2.7. + """ + return [ + candidate + for candidate in _all_subsets(framework.arguments) + if _is_cf2_extension(framework, candidate) + ] + + +def stage2_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Compute SCC-recursive stage2 extensions. + + Gaggl and Woltran 2013 give the SCC-recursive shape for CF2; stage2 uses + the same component recursion with stage semantics as the single-SCC base. + """ + return [ + candidate + for candidate in _all_subsets(framework.arguments) + if _is_stage2_extension(framework, candidate) + ] + + +def _is_stage2_extension( + framework: ArgumentationFramework, + candidate: frozenset[str], +) -> bool: + if not candidate <= framework.arguments: + return False + + components = _strongly_connected_components( + framework.arguments, + framework.defeats, + ) + if len(components) <= 1: + return candidate in stage_extensions(framework) + + defeated = _component_defeated(framework, candidate, components) + for component in components: + sub_arguments = component - defeated + subframework = _subframework(framework, sub_arguments) + if not _is_stage2_extension(subframework, candidate & component): + return False + return True + + +def indirect_attacks(framework: ArgumentationFramework) -> frozenset[tuple[str, str]]: + """Return odd-length attack paths for prudent semantics. + + Coste-Marquis, Devred, and Marquis 2005, pp. 1-2 define the prudent + indirect-conflict check over odd-length attack paths. + """ + indirect: set[tuple[str, str]] = set() + successors: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in framework.defeats: + successors.setdefault(attacker, set()).add(target) + + for source in framework.arguments: + stack = [(source, target, 1) for target in successors.get(source, set())] + seen: set[tuple[str, int]] = set() + while stack: + origin, current, length = stack.pop() + parity = length % 2 + if (current, parity) in seen: + continue + seen.add((current, parity)) + if length > 1 and parity == 1: + indirect.add((origin, current)) + for target in successors.get(current, set()): + stack.append((origin, target, length + 1)) + return frozenset(indirect) + + +def prudent_conflict_free( + framework: ArgumentationFramework, + candidate: frozenset[str], +) -> bool: + """Return whether ``candidate`` has no prudent indirect conflict.""" + indirect = indirect_attacks(framework) + return not any( + attacker in candidate and target in candidate + for attacker, target in indirect + ) + + +def prudent_admissible( + framework: ArgumentationFramework, + candidate: frozenset[str], +) -> bool: + """Return prudent admissibility: admissible and no indirect conflicts.""" + return prudent_conflict_free(framework, candidate) and admissible( + candidate, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + ) + + +def prudent_preferred_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + """Return inclusion-maximal prudent-admissible sets.""" + candidates = [ + candidate + for candidate in _all_subsets(framework.arguments) + if prudent_admissible(framework, candidate) + ] + return [ + candidate + for candidate in candidates + if not any(candidate < other for other in candidates) + ] + + +def prudent_grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: + """Return the stationary prudent grounded extension. + + Coste-Marquis, Devred, and Marquis 2005, p. 3 define grounded prudent + semantics by iterating the prudent characteristic function from empty. + """ + current: frozenset[str] = frozenset() + attackers_index = _attackers_index(framework.defeats) + indirect = indirect_attacks(framework) + while True: + next_current = frozenset( + argument + for argument in framework.arguments + if defends( + current, + argument, + framework.arguments, + framework.defeats, + attackers_index=attackers_index, + ) + and not any( + attacker in current | frozenset({argument}) + and target in current | frozenset({argument}) + for attacker, target in indirect + ) + ) + if next_current == current: + return current + current = next_current + + +def ideal_extension(framework: ArgumentationFramework) -> frozenset[str]: + """Compute the unique maximal ideal extension. + + An ideal set is admissible and contained in every preferred extension. The + ideal extension is the unique maximal ideal set. + + Reference: + Dung, Mancarella, and Toni 2007, Definition 2.2 and Theorem 2.1. + """ + preferred = preferred_extensions(framework) + if not preferred: + return frozenset() + + common = set(preferred[0]) + for extension in preferred[1:]: + common.intersection_update(extension) + + attackers_index = _attackers_index(framework.defeats) + candidates = [ + candidate + for candidate in _all_subsets(frozenset(common)) + if admissible( + candidate, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + attackers_index=attackers_index, + ) + ] + maximal = [ + candidate + for candidate in candidates + if not any(candidate < other for other in candidates) + ] + if len(maximal) != 1: + raise AssertionError( + "ideal extension construction must have exactly one maximal " + "admissible subset of the preferred-extension intersection" + ) + return maximal[0] diff --git a/src/argumentation/labelling.py b/src/argumentation/core/labelling.py similarity index 95% rename from src/argumentation/labelling.py rename to src/argumentation/core/labelling.py index 5aa8113..cec78ff 100644 --- a/src/argumentation/labelling.py +++ b/src/argumentation/core/labelling.py @@ -1,367 +1,367 @@ -"""Labelling utilities for Dung-style abstract argumentation frameworks.""" - -from __future__ import annotations - -from collections.abc import Iterator -from dataclasses import dataclass -from enum import Enum -from types import MappingProxyType -from typing import Mapping - -from argumentation.dung import ( - ArgumentationFramework, - admissible, - attackers_of, - characteristic_fn, -) - - -class Label(Enum): - """The three standard argument labelling statuses.""" - - IN = "in" - OUT = "out" - UNDEC = "undec" - - -DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET = 65_536 - - -class ExactEnumerationExceeded(RuntimeError): - """Raised when exact complete-labelling enumeration exceeds its budget.""" - - -def _normalize_label(value: Label | str) -> Label: - if isinstance(value, Label): - return value - try: - return Label(value) - except ValueError as exc: - raise ValueError(f"Unknown labelling status: {value!r}") from exc - - -@dataclass(frozen=True) -class Labelling: - """Immutable three-valued labelling over a finite argument set.""" - - statuses: Mapping[str, Label] - - def __post_init__(self) -> None: - normalized = { - argument: _normalize_label(status) - for argument, status in self.statuses.items() - } - object.__setattr__(self, "statuses", MappingProxyType(normalized)) - - @classmethod - def from_statuses( - cls, - *, - arguments: frozenset[str], - statuses: Mapping[str, Label | str], - ) -> Labelling: - """Build a labelling whose status map exactly covers ``arguments``.""" - status_arguments = frozenset(statuses) - if status_arguments != arguments: - missing = sorted(arguments - status_arguments) - extra = sorted(status_arguments - arguments) - raise ValueError( - "statuses must cover exactly the argument set; " - f"missing={missing!r}, extra={extra!r}" - ) - normalized = { - argument: _normalize_label(status) - for argument, status in statuses.items() - } - return cls(normalized) - - @classmethod - def from_extension( - cls, - framework: ArgumentationFramework, - extension: frozenset[str], - ) -> Labelling: - """Convert an extension into its induced in/out/undec labelling. - - Arguments in the extension are labelled ``in``. Arguments outside the - extension defeated by an ``in`` argument are labelled ``out``. All other - outsiders are labelled ``undec``. - """ - unknown = sorted(extension - framework.arguments) - if unknown: - raise ValueError(f"extension contains unknown arguments: {unknown!r}") - - statuses: dict[str, Label] = {} - for argument in framework.arguments: - if argument in extension: - statuses[argument] = Label.IN - elif any( - attacker in extension - for attacker, target in framework.defeats - if target == argument - ): - statuses[argument] = Label.OUT - else: - statuses[argument] = Label.UNDEC - return cls.from_statuses(arguments=framework.arguments, statuses=statuses) - - @property - def arguments(self) -> frozenset[str]: - return frozenset(self.statuses) - - @property - def in_arguments(self) -> frozenset[str]: - return self._arguments_with_label(Label.IN) - - @property - def out_arguments(self) -> frozenset[str]: - return self._arguments_with_label(Label.OUT) - - @property - def undecided_arguments(self) -> frozenset[str]: - return self._arguments_with_label(Label.UNDEC) - - @property - def range(self) -> frozenset[str]: - return self.in_arguments | self.out_arguments - - @property - def extension(self) -> frozenset[str]: - return self.in_arguments - - def _arguments_with_label(self, label: Label) -> frozenset[str]: - return frozenset( - argument - for argument, status in self.statuses.items() - if status == label - ) - - -def legally_in( - labelling: Labelling, - framework: ArgumentationFramework, - argument: str, -) -> bool: - """Return Caminada-legal IN status for one argument. - - Caminada 2006, p. 3: an argument is labelled in iff every defeater is out. - """ - _require_known_argument(framework, argument) - return all( - labelling.statuses[attacker] is Label.OUT - for attacker in attackers_of(argument, framework.defeats) - ) - - -def legally_out( - labelling: Labelling, - framework: ArgumentationFramework, - argument: str, -) -> bool: - """Return Caminada-legal OUT status for one argument. - - Caminada 2006, p. 3: an argument is labelled out iff it has an in defeater. - """ - _require_known_argument(framework, argument) - return any( - labelling.statuses[attacker] is Label.IN - for attacker in attackers_of(argument, framework.defeats) - ) - - -def is_reinstatement_labelling( - labelling: Labelling, - framework: ArgumentationFramework, -) -> bool: - """Return whether ``labelling`` satisfies Caminada reinstatement.""" - if labelling.arguments != framework.arguments: - return False - for argument, status in labelling.statuses.items(): - if status is Label.IN and not legally_in(labelling, framework, argument): - return False - if status is not Label.IN and legally_in(labelling, framework, argument): - return False - if status is Label.OUT and not legally_out(labelling, framework, argument): - return False - if status is not Label.OUT and legally_out(labelling, framework, argument): - return False - return True - - -def complete_labellings( - framework: ArgumentationFramework, - *, - max_candidates: int | None = DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, -) -> list[Labelling]: - """Compute all complete labellings by Caminada 2006 reinstatement.""" - if _is_acyclic(framework): - return [grounded_labelling(framework)] - - results: list[Labelling] = [] - for candidate_count, extension in enumerate(_all_subsets(framework.arguments), start=1): - if max_candidates is not None and candidate_count > max_candidates: - raise ExactEnumerationExceeded( - "complete labellings exact enumeration exceeded " - f"{max_candidates} candidate subsets for " - f"{len(framework.arguments)} arguments" - ) - if characteristic_fn( - extension, - framework.arguments, - framework.defeats, - ) != extension: - continue - if not admissible(extension, framework.arguments, framework.defeats): - continue - labelling = Labelling.from_extension(framework, extension) - if is_reinstatement_labelling(labelling, framework): - results.append(labelling) - return _sort_labellings(results) - - -def grounded_labelling(framework: ArgumentationFramework) -> Labelling: - """Return the unique grounded labelling. - - Caminada 2006, p. 5: grounded is the reinstatement labelling with maximal - undecided set, equivalently minimal in-set. - """ - current: frozenset[str] = frozenset() - while True: - next_current = characteristic_fn( - current, - framework.arguments, - framework.defeats, - ) - if next_current == current: - return Labelling.from_extension(framework, current) - current = next_current - - -def preferred_labellings(framework: ArgumentationFramework) -> list[Labelling]: - """Return complete labellings whose IN sets are inclusion-maximal.""" - labellings = complete_labellings(framework) - return _sort_labellings( - [ - labelling - for labelling in labellings - if not any( - labelling.in_arguments < other.in_arguments - for other in labellings - ) - ] - ) - - -def stable_labellings(framework: ArgumentationFramework) -> list[Labelling]: - """Return complete labellings with no undecided arguments.""" - return _sort_labellings( - [ - labelling - for labelling in complete_labellings(framework) - if not labelling.undecided_arguments - ] - ) - - -def semi_stable_labellings(framework: ArgumentationFramework) -> list[Labelling]: - """Return complete labellings with inclusion-minimal undecided sets. - - Caminada 2006, pp. 6-7: semi-stable semantics is minimal undecidedness. - """ - labellings = complete_labellings(framework) - return _sort_labellings( - [ - labelling - for labelling in labellings - if not any( - other.undecided_arguments < labelling.undecided_arguments - for other in labellings - ) - ] - ) - - -def eager_labelling(framework: ArgumentationFramework) -> Labelling: - """Return the unique eager labelling.""" - from argumentation.dung import eager_extension - - return Labelling.from_extension(framework, eager_extension(framework)) - - -def stage2_labellings(framework: ArgumentationFramework) -> list[Labelling]: - """Return stage2 extensions projected into labellings.""" - from argumentation.dung import stage2_extensions - - return _sort_labellings( - [ - Labelling.from_extension(framework, extension) - for extension in stage2_extensions(framework) - ] - ) - - -def _sort_labellings(labellings: list[Labelling]) -> list[Labelling]: - return sorted( - labellings, - key=lambda labelling: ( - len(labelling.in_arguments), - tuple(sorted(labelling.in_arguments)), - tuple(sorted(labelling.out_arguments)), - ), - ) - - -def _all_subsets(arguments: frozenset[str]) -> Iterator[frozenset[str]]: - ordered = sorted(arguments) - for mask in range(1 << len(ordered)): - yield frozenset( - ordered[index] - for index in range(len(ordered)) - if mask & (1 << index) - ) - - -def _is_acyclic(framework: ArgumentationFramework) -> bool: - outgoing: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in framework.defeats: - outgoing.setdefault(attacker, set()).add(target) - - visiting: set[str] = set() - visited: set[str] = set() - - def visit(argument: str) -> bool: - if argument in visiting: - return False - if argument in visited: - return True - visiting.add(argument) - for target in outgoing.get(argument, set()): - if not visit(target): - return False - visiting.remove(argument) - visited.add(argument) - return True - - return all(visit(argument) for argument in sorted(framework.arguments)) - - -def _require_known_argument(framework: ArgumentationFramework, argument: str) -> None: - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument!r}") - - -__all__ = [ - "Label", - "Labelling", - "ExactEnumerationExceeded", - "complete_labellings", - "eager_labelling", - "grounded_labelling", - "is_reinstatement_labelling", - "legally_in", - "legally_out", - "preferred_labellings", - "semi_stable_labellings", - "stable_labellings", - "stage2_labellings", -] +"""Labelling utilities for Dung-style abstract argumentation frameworks.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import Mapping + +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + attackers_of, + characteristic_fn, +) + + +class Label(Enum): + """The three standard argument labelling statuses.""" + + IN = "in" + OUT = "out" + UNDEC = "undec" + + +DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET = 65_536 + + +class ExactEnumerationExceeded(RuntimeError): + """Raised when exact complete-labelling enumeration exceeds its budget.""" + + +def _normalize_label(value: Label | str) -> Label: + if isinstance(value, Label): + return value + try: + return Label(value) + except ValueError as exc: + raise ValueError(f"Unknown labelling status: {value!r}") from exc + + +@dataclass(frozen=True) +class Labelling: + """Immutable three-valued labelling over a finite argument set.""" + + statuses: Mapping[str, Label] + + def __post_init__(self) -> None: + normalized = { + argument: _normalize_label(status) + for argument, status in self.statuses.items() + } + object.__setattr__(self, "statuses", MappingProxyType(normalized)) + + @classmethod + def from_statuses( + cls, + *, + arguments: frozenset[str], + statuses: Mapping[str, Label | str], + ) -> Labelling: + """Build a labelling whose status map exactly covers ``arguments``.""" + status_arguments = frozenset(statuses) + if status_arguments != arguments: + missing = sorted(arguments - status_arguments) + extra = sorted(status_arguments - arguments) + raise ValueError( + "statuses must cover exactly the argument set; " + f"missing={missing!r}, extra={extra!r}" + ) + normalized = { + argument: _normalize_label(status) + for argument, status in statuses.items() + } + return cls(normalized) + + @classmethod + def from_extension( + cls, + framework: ArgumentationFramework, + extension: frozenset[str], + ) -> Labelling: + """Convert an extension into its induced in/out/undec labelling. + + Arguments in the extension are labelled ``in``. Arguments outside the + extension defeated by an ``in`` argument are labelled ``out``. All other + outsiders are labelled ``undec``. + """ + unknown = sorted(extension - framework.arguments) + if unknown: + raise ValueError(f"extension contains unknown arguments: {unknown!r}") + + statuses: dict[str, Label] = {} + for argument in framework.arguments: + if argument in extension: + statuses[argument] = Label.IN + elif any( + attacker in extension + for attacker, target in framework.defeats + if target == argument + ): + statuses[argument] = Label.OUT + else: + statuses[argument] = Label.UNDEC + return cls.from_statuses(arguments=framework.arguments, statuses=statuses) + + @property + def arguments(self) -> frozenset[str]: + return frozenset(self.statuses) + + @property + def in_arguments(self) -> frozenset[str]: + return self._arguments_with_label(Label.IN) + + @property + def out_arguments(self) -> frozenset[str]: + return self._arguments_with_label(Label.OUT) + + @property + def undecided_arguments(self) -> frozenset[str]: + return self._arguments_with_label(Label.UNDEC) + + @property + def range(self) -> frozenset[str]: + return self.in_arguments | self.out_arguments + + @property + def extension(self) -> frozenset[str]: + return self.in_arguments + + def _arguments_with_label(self, label: Label) -> frozenset[str]: + return frozenset( + argument + for argument, status in self.statuses.items() + if status == label + ) + + +def legally_in( + labelling: Labelling, + framework: ArgumentationFramework, + argument: str, +) -> bool: + """Return Caminada-legal IN status for one argument. + + Caminada 2006, p. 3: an argument is labelled in iff every defeater is out. + """ + _require_known_argument(framework, argument) + return all( + labelling.statuses[attacker] is Label.OUT + for attacker in attackers_of(argument, framework.defeats) + ) + + +def legally_out( + labelling: Labelling, + framework: ArgumentationFramework, + argument: str, +) -> bool: + """Return Caminada-legal OUT status for one argument. + + Caminada 2006, p. 3: an argument is labelled out iff it has an in defeater. + """ + _require_known_argument(framework, argument) + return any( + labelling.statuses[attacker] is Label.IN + for attacker in attackers_of(argument, framework.defeats) + ) + + +def is_reinstatement_labelling( + labelling: Labelling, + framework: ArgumentationFramework, +) -> bool: + """Return whether ``labelling`` satisfies Caminada reinstatement.""" + if labelling.arguments != framework.arguments: + return False + for argument, status in labelling.statuses.items(): + if status is Label.IN and not legally_in(labelling, framework, argument): + return False + if status is not Label.IN and legally_in(labelling, framework, argument): + return False + if status is Label.OUT and not legally_out(labelling, framework, argument): + return False + if status is not Label.OUT and legally_out(labelling, framework, argument): + return False + return True + + +def complete_labellings( + framework: ArgumentationFramework, + *, + max_candidates: int | None = DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, +) -> list[Labelling]: + """Compute all complete labellings by Caminada 2006 reinstatement.""" + if _is_acyclic(framework): + return [grounded_labelling(framework)] + + results: list[Labelling] = [] + for candidate_count, extension in enumerate(_all_subsets(framework.arguments), start=1): + if max_candidates is not None and candidate_count > max_candidates: + raise ExactEnumerationExceeded( + "complete labellings exact enumeration exceeded " + f"{max_candidates} candidate subsets for " + f"{len(framework.arguments)} arguments" + ) + if characteristic_fn( + extension, + framework.arguments, + framework.defeats, + ) != extension: + continue + if not admissible(extension, framework.arguments, framework.defeats): + continue + labelling = Labelling.from_extension(framework, extension) + if is_reinstatement_labelling(labelling, framework): + results.append(labelling) + return _sort_labellings(results) + + +def grounded_labelling(framework: ArgumentationFramework) -> Labelling: + """Return the unique grounded labelling. + + Caminada 2006, p. 5: grounded is the reinstatement labelling with maximal + undecided set, equivalently minimal in-set. + """ + current: frozenset[str] = frozenset() + while True: + next_current = characteristic_fn( + current, + framework.arguments, + framework.defeats, + ) + if next_current == current: + return Labelling.from_extension(framework, current) + current = next_current + + +def preferred_labellings(framework: ArgumentationFramework) -> list[Labelling]: + """Return complete labellings whose IN sets are inclusion-maximal.""" + labellings = complete_labellings(framework) + return _sort_labellings( + [ + labelling + for labelling in labellings + if not any( + labelling.in_arguments < other.in_arguments + for other in labellings + ) + ] + ) + + +def stable_labellings(framework: ArgumentationFramework) -> list[Labelling]: + """Return complete labellings with no undecided arguments.""" + return _sort_labellings( + [ + labelling + for labelling in complete_labellings(framework) + if not labelling.undecided_arguments + ] + ) + + +def semi_stable_labellings(framework: ArgumentationFramework) -> list[Labelling]: + """Return complete labellings with inclusion-minimal undecided sets. + + Caminada 2006, pp. 6-7: semi-stable semantics is minimal undecidedness. + """ + labellings = complete_labellings(framework) + return _sort_labellings( + [ + labelling + for labelling in labellings + if not any( + other.undecided_arguments < labelling.undecided_arguments + for other in labellings + ) + ] + ) + + +def eager_labelling(framework: ArgumentationFramework) -> Labelling: + """Return the unique eager labelling.""" + from argumentation.core.dung import eager_extension + + return Labelling.from_extension(framework, eager_extension(framework)) + + +def stage2_labellings(framework: ArgumentationFramework) -> list[Labelling]: + """Return stage2 extensions projected into labellings.""" + from argumentation.core.dung import stage2_extensions + + return _sort_labellings( + [ + Labelling.from_extension(framework, extension) + for extension in stage2_extensions(framework) + ] + ) + + +def _sort_labellings(labellings: list[Labelling]) -> list[Labelling]: + return sorted( + labellings, + key=lambda labelling: ( + len(labelling.in_arguments), + tuple(sorted(labelling.in_arguments)), + tuple(sorted(labelling.out_arguments)), + ), + ) + + +def _all_subsets(arguments: frozenset[str]) -> Iterator[frozenset[str]]: + ordered = sorted(arguments) + for mask in range(1 << len(ordered)): + yield frozenset( + ordered[index] + for index in range(len(ordered)) + if mask & (1 << index) + ) + + +def _is_acyclic(framework: ArgumentationFramework) -> bool: + outgoing: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in framework.defeats: + outgoing.setdefault(attacker, set()).add(target) + + visiting: set[str] = set() + visited: set[str] = set() + + def visit(argument: str) -> bool: + if argument in visiting: + return False + if argument in visited: + return True + visiting.add(argument) + for target in outgoing.get(argument, set()): + if not visit(target): + return False + visiting.remove(argument) + visited.add(argument) + return True + + return all(visit(argument) for argument in sorted(framework.arguments)) + + +def _require_known_argument(framework: ArgumentationFramework, argument: str) -> None: + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument!r}") + + +__all__ = [ + "Label", + "Labelling", + "ExactEnumerationExceeded", + "complete_labellings", + "eager_labelling", + "grounded_labelling", + "is_reinstatement_labelling", + "legally_in", + "legally_out", + "preferred_labellings", + "semi_stable_labellings", + "stable_labellings", + "stage2_labellings", +] diff --git a/src/argumentation/preference.py b/src/argumentation/core/preference.py similarity index 100% rename from src/argumentation/preference.py rename to src/argumentation/core/preference.py diff --git a/src/argumentation/preprocessing.py b/src/argumentation/core/preprocessing.py similarity index 97% rename from src/argumentation/preprocessing.py rename to src/argumentation/core/preprocessing.py index b15b246..13f0fcb 100644 --- a/src/argumentation/preprocessing.py +++ b/src/argumentation/core/preprocessing.py @@ -1,258 +1,258 @@ -"""Semantics-preserving preprocessing for Dung abstract argumentation frameworks. - -Wave A of the graph-theory speedup workstream: shrink an AF before it is handed -to the SAT (Z3) or ASP encoder, then lift the answer back. This implements the -de-facto standard competitive-solver preprocessing (mu-toksia / pyglaf / -ASPARTIX-V): - -* **Grounded reduct.** Compute the grounded extension ``G`` (least fixed point of - the characteristic function, O(V+E)). Every ``a in G`` is IN in every extension - of every admissibility-based semantics; every ``a`` attacked by ``G`` is OUT in - every such extension. Delete ``G`` and ``G+`` (the arguments ``G`` attacks) plus - all incident attacks; solve only the residual; re-union ``G`` into every answer. -* **Isolated-argument elimination.** An argument with no incoming and no outgoing - attacks is unattacked, hence in ``G`` -- so it is already removed by the grounded - reduct. Tracked here only as a documented consequence. -* **Self-loop sinks.** An argument ``a`` with ``(a, a)`` in the attack relation can - never be in any conflict-free set, so it is OUT in every extension of every - semantics this library supports. If such an ``a`` has *no other* incident edges - (a pure self-loop sink) it can be deleted outright without changing any - extension -- *except for the stable semantics*: ``a`` is attacked only by itself, - so no conflict-free set ever covers it, hence the AF has no stable extension; - deleting ``a`` would spuriously create one. So this removal is gated off for - ``stable``. Self-loop arguments with outgoing attacks are *also* not removed -- - deleting them would spuriously unblock their targets (a target attacked only by - self-attackers can never be IN, but would become unattacked in the residual). - The SAT/ASP encoders already handle ``(a, a)`` correctly via conflict-freeness, - so leaving them in is sound, just less reduced. - -Reductions detected but deliberately **not applied** (see report): - -* **Symmetric-AF special case** (Coste-Marquis et al., ECSQARU 2005): detected via - :func:`is_symmetric_irreflexive`; a full polynomial special-case solver is out of - scope for Wave A. TODO. -* **Baumann / Oikarinen-Woltran kernels** (``af_revision.stable_kernel`` / - ``baumann_2015_kernel``): their preservation guarantees are stated for strong - equivalence / revision, not obviously matched to "solve this task on this AF". - Not applied -- soundness beats speed. TODO: confirm the guarantee per semantics. - -Validity of the grounded reduct by semantics (the only reduction applied): - -* ``complete`` -- standard reduct result: ``complete(AF) = {G u E : E in complete(residual)}``. -* ``preferred`` -- maximal complete; the offset ``G`` is constant so maximality transfers. -* ``stable`` -- ``G`` is in every stable extension, ``G+`` in none; coverage transfers. -* ``semi_stable`` -- semi-stable extensions are complete extensions (so contain ``G``, - exclude ``G+``) with maximal range; range = ``G u G+`` (constant) ``u`` residual-range, - so maximality transfers. -* ``stage`` -- NOT covered by the grounded reduct (see the NOTE on - :data:`GROUNDED_REDUCT_SEMANTICS`): stage extensions are conflict-free, not complete, - and need not contain ``G`` or exclude ``G+``. -* ``grounded`` -- the residual's grounded extension is empty by construction, so - ``grounded(AF) = G`` directly (the reduct degenerates to "return G"). -* ``ideal`` -- contained in the intersection of preferred extensions, all of which - contain ``G`` and exclude ``G+``; the maximal admissible subset transfers. - -Reference: Niskanen & Jaervisalo, "mu-toksia: An Efficient Abstract Argumentation -Reasoner" (KR 2020); Dvorak et al., "ASPARTIX-V19/-V21"; folklore grounded reduct. -""" - -from __future__ import annotations - -from collections.abc import Iterable -from dataclasses import dataclass - -from argumentation.dung import ( - ArgumentationFramework, - _attackers_index, - _targets_index, - grounded_extension, -) - -# Semantics for which the grounded reduct is semantics-preserving (see module docstring). -GROUNDED_REDUCT_SEMANTICS: frozenset[str] = frozenset( - { - "complete", - "preferred", - "stable", - "semi_stable", - "grounded", - "ideal", - } -) -# NOTE: ``admissible`` is also deliberately absent. Admissible sets need not -# contain the grounded extension (the empty set is admissible), so -# ``adm(AF) != {G u E : E in adm(residual)}`` -- the reduct would drop every -# admissible set that does not already contain ``G``. -# NOTE: ``stage`` is deliberately absent. Stage extensions are conflict-free -# range-maximal sets, NOT complete extensions, so they need not contain the -# grounded extension nor exclude the grounded-attacked arguments. Concrete -# counterexample: AF = ({a,b,c}, {(a,a),(b,c),(c,a)}) has grounded {b}, but {c} is -# a stage extension (range {a,c}, incomparable to range {b,c} of {b}). Applying the -# grounded reduct would wrongly force c OUT. Stage gets only the always-sound -# self-loop-sink removal. - - -@dataclass(frozen=True) -class AfSimplification: - """Result of :func:`simplify_af`. - - ``residual`` is the AF to actually solve. ``fixed_in`` are arguments forced IN - in every extension of the (admissibility-based) semantics; they are *not* - present in ``residual``. ``removed_out`` are arguments dropped from ``residual`` - that are OUT in every extension (the grounded-attacked arguments and any - pure self-loop sinks). ``original`` is the framework that was simplified. - """ - - original: ArgumentationFramework - residual: ArgumentationFramework - fixed_in: frozenset[str] - removed_out: frozenset[str] - - @property - def is_trivial(self) -> bool: - """True when the residual is the whole framework (nothing was removed).""" - return not self.fixed_in and not self.removed_out - - def lift(self, residual_extension: Iterable[str]) -> frozenset[str]: - """Map an extension of ``residual`` back to an extension of ``original``.""" - return frozenset(residual_extension) | self.fixed_in - - def lift_all( - self, residual_extensions: Iterable[Iterable[str]] - ) -> list[frozenset[str]]: - """Map a collection of residual extensions back, de-duplicated, order-stable.""" - seen: set[frozenset[str]] = set() - lifted: list[frozenset[str]] = [] - for extension in residual_extensions: - value = self.lift(extension) - if value not in seen: - seen.add(value) - lifted.append(value) - return lifted - - -def is_symmetric_irreflexive(framework: ArgumentationFramework) -> bool: - """True when the defeat relation is symmetric and irreflexive. - - Such AFs are coherent (Coste-Marquis et al., ECSQARU 2005) and several tasks - become polynomial; Wave A only *detects* this, it does not exploit it. - """ - defeats = framework.defeats - for attacker, target in defeats: - if attacker == target: - return False - if (target, attacker) not in defeats: - return False - return bool(defeats) - - -def _pure_self_loop_sinks(framework: ArgumentationFramework) -> frozenset[str]: - """Self-attackers whose only incident edge is the self-loop itself.""" - incident: dict[str, int] = {} - self_loops: set[str] = set() - for attacker, target in framework.defeats: - if attacker == target: - self_loops.add(attacker) - incident[attacker] = incident.get(attacker, 0) + 1 - incident[target] = incident.get(target, 0) + 1 - # A pure self-loop sink ``a`` contributes exactly one edge ``(a, a)`` which the - # loop above counted twice (once as attacker, once as target) -> incident == 2. - return frozenset(arg for arg in self_loops if incident.get(arg, 0) == 2) - - -def simplify_af( - framework: ArgumentationFramework, - *, - semantics: str | None = None, -) -> AfSimplification: - """Compute a semantics-preserving reduced AF and the lift-back data. - - When ``semantics`` is given and is not in :data:`GROUNDED_REDUCT_SEMANTICS`, the - grounded reduct is *not* applied (only the always-sound pure self-loop sink - 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 _normalize_semantics(semantics) in GROUNDED_REDUCT_SEMANTICS - - fixed_in: frozenset[str] = frozenset() - removed_out: frozenset[str] = frozenset() - - if apply_grounded: - grounded = grounded_extension(framework) - attacked_by_grounded = _attacked_by(grounded, framework.defeats) - fixed_in = grounded - removed_out = attacked_by_grounded - # Pure self-loop sink removal is sound for every supported semantics except - # stable (a self-loop sink can never be covered, so it is the obstruction to a - # stable extension existing -- deleting it would spuriously create one). - allow_sink_removal = semantics is None or _normalize_semantics(semantics) != "stable" - if allow_sink_removal: - sinks = _pure_self_loop_sinks(framework) - fixed_in - removed_out = removed_out | sinks - - removed = fixed_in | removed_out - if not removed: - return AfSimplification( - original=framework, - residual=framework, - fixed_in=frozenset(), - removed_out=frozenset(), - ) - - residual_arguments = framework.arguments - removed - residual_defeats = frozenset( - (attacker, target) - for attacker, target in framework.defeats - if attacker in residual_arguments and target in residual_arguments - ) - residual_attacks = ( - None - if framework.attacks is None - else frozenset( - (attacker, target) - for attacker, target in framework.attacks - if attacker in residual_arguments and target in residual_arguments - ) - ) - residual = ArgumentationFramework( - arguments=residual_arguments, - defeats=residual_defeats, - attacks=residual_attacks, - ) - return AfSimplification( - original=framework, - residual=residual, - fixed_in=fixed_in, - removed_out=removed_out, - ) - - -def isolated_arguments(framework: ArgumentationFramework) -> frozenset[str]: - """Arguments with no incoming and no outgoing attacks (a diagnostic helper).""" - attackers = _attackers_index(framework.defeats) - targets = _targets_index(framework.defeats) - return frozenset( - argument - for argument in framework.arguments - if not attackers.get(argument) and not targets.get(argument) - ) - - -def _attacked_by( - arguments: frozenset[str], - defeats: frozenset[tuple[str, str]], -) -> frozenset[str]: - return frozenset(target for attacker, target in defeats if attacker in arguments) - - -def _normalize_semantics(semantics: str) -> str: - return semantics.strip().lower().replace("-", "_") - - -__all__ = [ - "AfSimplification", - "GROUNDED_REDUCT_SEMANTICS", - "isolated_arguments", - "is_symmetric_irreflexive", - "simplify_af", -] +"""Semantics-preserving preprocessing for Dung abstract argumentation frameworks. + +Wave A of the graph-theory speedup workstream: shrink an AF before it is handed +to the SAT (Z3) or ASP encoder, then lift the answer back. This implements the +de-facto standard competitive-solver preprocessing (mu-toksia / pyglaf / +ASPARTIX-V): + +* **Grounded reduct.** Compute the grounded extension ``G`` (least fixed point of + the characteristic function, O(V+E)). Every ``a in G`` is IN in every extension + of every admissibility-based semantics; every ``a`` attacked by ``G`` is OUT in + every such extension. Delete ``G`` and ``G+`` (the arguments ``G`` attacks) plus + all incident attacks; solve only the residual; re-union ``G`` into every answer. +* **Isolated-argument elimination.** An argument with no incoming and no outgoing + attacks is unattacked, hence in ``G`` -- so it is already removed by the grounded + reduct. Tracked here only as a documented consequence. +* **Self-loop sinks.** An argument ``a`` with ``(a, a)`` in the attack relation can + never be in any conflict-free set, so it is OUT in every extension of every + semantics this library supports. If such an ``a`` has *no other* incident edges + (a pure self-loop sink) it can be deleted outright without changing any + extension -- *except for the stable semantics*: ``a`` is attacked only by itself, + so no conflict-free set ever covers it, hence the AF has no stable extension; + deleting ``a`` would spuriously create one. So this removal is gated off for + ``stable``. Self-loop arguments with outgoing attacks are *also* not removed -- + deleting them would spuriously unblock their targets (a target attacked only by + self-attackers can never be IN, but would become unattacked in the residual). + The SAT/ASP encoders already handle ``(a, a)`` correctly via conflict-freeness, + so leaving them in is sound, just less reduced. + +Reductions detected but deliberately **not applied** (see report): + +* **Symmetric-AF special case** (Coste-Marquis et al., ECSQARU 2005): detected via + :func:`is_symmetric_irreflexive`; a full polynomial special-case solver is out of + scope for Wave A. TODO. +* **Baumann / Oikarinen-Woltran kernels** (``af_revision.stable_kernel`` / + ``baumann_2015_kernel``): their preservation guarantees are stated for strong + equivalence / revision, not obviously matched to "solve this task on this AF". + Not applied -- soundness beats speed. TODO: confirm the guarantee per semantics. + +Validity of the grounded reduct by semantics (the only reduction applied): + +* ``complete`` -- standard reduct result: ``complete(AF) = {G u E : E in complete(residual)}``. +* ``preferred`` -- maximal complete; the offset ``G`` is constant so maximality transfers. +* ``stable`` -- ``G`` is in every stable extension, ``G+`` in none; coverage transfers. +* ``semi_stable`` -- semi-stable extensions are complete extensions (so contain ``G``, + exclude ``G+``) with maximal range; range = ``G u G+`` (constant) ``u`` residual-range, + so maximality transfers. +* ``stage`` -- NOT covered by the grounded reduct (see the NOTE on + :data:`GROUNDED_REDUCT_SEMANTICS`): stage extensions are conflict-free, not complete, + and need not contain ``G`` or exclude ``G+``. +* ``grounded`` -- the residual's grounded extension is empty by construction, so + ``grounded(AF) = G`` directly (the reduct degenerates to "return G"). +* ``ideal`` -- contained in the intersection of preferred extensions, all of which + contain ``G`` and exclude ``G+``; the maximal admissible subset transfers. + +Reference: Niskanen & Jaervisalo, "mu-toksia: An Efficient Abstract Argumentation +Reasoner" (KR 2020); Dvorak et al., "ASPARTIX-V19/-V21"; folklore grounded reduct. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from argumentation.core.dung import ( + ArgumentationFramework, + _attackers_index, + _targets_index, + grounded_extension, +) + +# Semantics for which the grounded reduct is semantics-preserving (see module docstring). +GROUNDED_REDUCT_SEMANTICS: frozenset[str] = frozenset( + { + "complete", + "preferred", + "stable", + "semi_stable", + "grounded", + "ideal", + } +) +# NOTE: ``admissible`` is also deliberately absent. Admissible sets need not +# contain the grounded extension (the empty set is admissible), so +# ``adm(AF) != {G u E : E in adm(residual)}`` -- the reduct would drop every +# admissible set that does not already contain ``G``. +# NOTE: ``stage`` is deliberately absent. Stage extensions are conflict-free +# range-maximal sets, NOT complete extensions, so they need not contain the +# grounded extension nor exclude the grounded-attacked arguments. Concrete +# counterexample: AF = ({a,b,c}, {(a,a),(b,c),(c,a)}) has grounded {b}, but {c} is +# a stage extension (range {a,c}, incomparable to range {b,c} of {b}). Applying the +# grounded reduct would wrongly force c OUT. Stage gets only the always-sound +# self-loop-sink removal. + + +@dataclass(frozen=True) +class AfSimplification: + """Result of :func:`simplify_af`. + + ``residual`` is the AF to actually solve. ``fixed_in`` are arguments forced IN + in every extension of the (admissibility-based) semantics; they are *not* + present in ``residual``. ``removed_out`` are arguments dropped from ``residual`` + that are OUT in every extension (the grounded-attacked arguments and any + pure self-loop sinks). ``original`` is the framework that was simplified. + """ + + original: ArgumentationFramework + residual: ArgumentationFramework + fixed_in: frozenset[str] + removed_out: frozenset[str] + + @property + def is_trivial(self) -> bool: + """True when the residual is the whole framework (nothing was removed).""" + return not self.fixed_in and not self.removed_out + + def lift(self, residual_extension: Iterable[str]) -> frozenset[str]: + """Map an extension of ``residual`` back to an extension of ``original``.""" + return frozenset(residual_extension) | self.fixed_in + + def lift_all( + self, residual_extensions: Iterable[Iterable[str]] + ) -> list[frozenset[str]]: + """Map a collection of residual extensions back, de-duplicated, order-stable.""" + seen: set[frozenset[str]] = set() + lifted: list[frozenset[str]] = [] + for extension in residual_extensions: + value = self.lift(extension) + if value not in seen: + seen.add(value) + lifted.append(value) + return lifted + + +def is_symmetric_irreflexive(framework: ArgumentationFramework) -> bool: + """True when the defeat relation is symmetric and irreflexive. + + Such AFs are coherent (Coste-Marquis et al., ECSQARU 2005) and several tasks + become polynomial; Wave A only *detects* this, it does not exploit it. + """ + defeats = framework.defeats + for attacker, target in defeats: + if attacker == target: + return False + if (target, attacker) not in defeats: + return False + return bool(defeats) + + +def _pure_self_loop_sinks(framework: ArgumentationFramework) -> frozenset[str]: + """Self-attackers whose only incident edge is the self-loop itself.""" + incident: dict[str, int] = {} + self_loops: set[str] = set() + for attacker, target in framework.defeats: + if attacker == target: + self_loops.add(attacker) + incident[attacker] = incident.get(attacker, 0) + 1 + incident[target] = incident.get(target, 0) + 1 + # A pure self-loop sink ``a`` contributes exactly one edge ``(a, a)`` which the + # loop above counted twice (once as attacker, once as target) -> incident == 2. + return frozenset(arg for arg in self_loops if incident.get(arg, 0) == 2) + + +def simplify_af( + framework: ArgumentationFramework, + *, + semantics: str | None = None, +) -> AfSimplification: + """Compute a semantics-preserving reduced AF and the lift-back data. + + When ``semantics`` is given and is not in :data:`GROUNDED_REDUCT_SEMANTICS`, the + grounded reduct is *not* applied (only the always-sound pure self-loop sink + 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 _normalize_semantics(semantics) in GROUNDED_REDUCT_SEMANTICS + + fixed_in: frozenset[str] = frozenset() + removed_out: frozenset[str] = frozenset() + + if apply_grounded: + grounded = grounded_extension(framework) + attacked_by_grounded = _attacked_by(grounded, framework.defeats) + fixed_in = grounded + removed_out = attacked_by_grounded + # Pure self-loop sink removal is sound for every supported semantics except + # stable (a self-loop sink can never be covered, so it is the obstruction to a + # stable extension existing -- deleting it would spuriously create one). + allow_sink_removal = semantics is None or _normalize_semantics(semantics) != "stable" + if allow_sink_removal: + sinks = _pure_self_loop_sinks(framework) - fixed_in + removed_out = removed_out | sinks + + removed = fixed_in | removed_out + if not removed: + return AfSimplification( + original=framework, + residual=framework, + fixed_in=frozenset(), + removed_out=frozenset(), + ) + + residual_arguments = framework.arguments - removed + residual_defeats = frozenset( + (attacker, target) + for attacker, target in framework.defeats + if attacker in residual_arguments and target in residual_arguments + ) + residual_attacks = ( + None + if framework.attacks is None + else frozenset( + (attacker, target) + for attacker, target in framework.attacks + if attacker in residual_arguments and target in residual_arguments + ) + ) + residual = ArgumentationFramework( + arguments=residual_arguments, + defeats=residual_defeats, + attacks=residual_attacks, + ) + return AfSimplification( + original=framework, + residual=residual, + fixed_in=fixed_in, + removed_out=removed_out, + ) + + +def isolated_arguments(framework: ArgumentationFramework) -> frozenset[str]: + """Arguments with no incoming and no outgoing attacks (a diagnostic helper).""" + attackers = _attackers_index(framework.defeats) + targets = _targets_index(framework.defeats) + return frozenset( + argument + for argument in framework.arguments + if not attackers.get(argument) and not targets.get(argument) + ) + + +def _attacked_by( + arguments: frozenset[str], + defeats: frozenset[tuple[str, str]], +) -> frozenset[str]: + return frozenset(target for attacker, target in defeats if attacker in arguments) + + +def _normalize_semantics(semantics: str) -> str: + return semantics.strip().lower().replace("-", "_") + + +__all__ = [ + "AfSimplification", + "GROUNDED_REDUCT_SEMANTICS", + "isolated_arguments", + "is_symmetric_irreflexive", + "simplify_af", +] diff --git a/src/argumentation/scc_recursive.py b/src/argumentation/core/scc_recursive.py similarity index 95% rename from src/argumentation/scc_recursive.py rename to src/argumentation/core/scc_recursive.py index 035bf0c..c58c17a 100644 --- a/src/argumentation/scc_recursive.py +++ b/src/argumentation/core/scc_recursive.py @@ -1,367 +1,367 @@ -"""SCC-recursive solving for the SCC-recursive Dung semantics (complete, preferred, stable). - -Wave B2 of the graph-theory speedup workstream. Implements the Baroni-Giacomin-Guida -(AIJ 168, 2005) SCC-recursive schema (Def 20, Thm 43) on top of the Wave A -:func:`argumentation.preprocessing.simplify_af` grounded reduct. - -Pipeline for ``complete`` / ``preferred`` / ``stable`` enumeration: - -1. ``simplify_af(framework, semantics=...)`` -> residual AF + lift data. -2. SCC-decompose the residual; if it is one SCC (or trivially small / empty) skip - straight to the flat base solve -- zero decomposition overhead. -3. Otherwise process SCCs in topological order of the condensation; for each SCC - ``S`` and each partial extension ``E`` chosen over ancestor SCCs, compute the - ``D(S,E)`` / ``U(S,E)`` / ``UP(S,E)`` sets per [BG&G05] Def 18, restrict to - ``UP(S,E)``, recurse ``GF(AF|UP(S,E), U(S,E) cap C)``; cross-product the - per-SCC partial results. -4. Lift each residual extension back through ``simplification.lift``. - -Base function (single-SCC sub-AF ``AF`` with membership restricted to ``C``): - -* complete -> ``CE(AF, C)`` -- admissible-in-``C`` sets ``E`` with - ``F_AF(E) cap C subseteq E`` ([BG&G05] CE def, Def 23). -* preferred -> ``PE(AF, C)`` -- the ``subseteq``-maximal elements of ``CE(AF, C)``. -* stable -> ``SE(AF, C)`` = ``SE(AF)`` -- ``C`` is provably inert for stable - ([BG&G05] p. 188). - -Resolution of spec ``UNRESOLVED`` item #1 (the ``(AF, C)``-restricted base solve -encoding): rather than threading a "force not-IN" constraint through the Z3 -single-extension finders in ``af_sat.py`` (which (a) cannot enumerate and (b) only -expose a "force OUT" knob, which the spec flags as the *wrong* semantics for the -``C`` restriction), we enumerate the base case directly over the subsets of the -sub-AF using the ``argumentation.dung`` primitives (``admissible``, -``characteristic_fn``, ``conflict_free``). Sub-AFs handed to the base solve are -single SCCs of the post-preprocessing residual, hence small, so brute-force -enumeration over their subsets is cheap and -- critically -- exact against the -``[BG&G05]`` definitions, which the oracle tests confirm. This is *not* a -reimplementation of the flat SAT solver: it is the same finite-candidate -enumeration ``dung.complete_extensions`` / ``preferred_extensions`` / -``stable_extensions`` already use, specialised with the ``C`` membership filter. - -DC/DS (credulous/skeptical acceptance) for these three semantics is routed through -full enumeration (spec ``UNRESOLVED`` item #2 -- query-driven pruning deferred); -correct, not maximally clever, as the prompt permits for this wave. - -Hard stop: only complete / preferred / stable. Semi-stable, stage, grounded, ideal, -admissible keep their existing flat paths -- they are not (cleanly) SCC-recursive. -""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass, field -from itertools import combinations - -from argumentation.dung import ( - ArgumentationFramework, - _attackers_index, - _strongly_connected_components, - _subframework, - admissible, - characteristic_fn, - complete_extensions, - preferred_extensions, - stable_extensions, -) -from argumentation.preprocessing import AfSimplification, simplify_af - -SCC_RECURSIVE_SEMANTICS: frozenset[str] = frozenset( - {"complete", "preferred", "stable"} -) - - -@dataclass -class _SolveTelemetry: - """Diagnostics for the most recent SCC-recursive solve (test/inspection only).""" - - semantics: str | None = None - residual_size: int | None = None - residual_scc_count: int | None = None - flat_fast_path: bool | None = None - decompose_requested: bool | None = None - notes: list[str] = field(default_factory=list) - - def reset(self) -> None: - self.semantics = None - self.residual_size = None - self.residual_scc_count = None - self.flat_fast_path = None - self.decompose_requested = None - self.notes = [] - - -LAST_SOLVE = _SolveTelemetry() - - -# --------------------------------------------------------------------------- # -# Base function: standard semantics on a single sub-AF, membership restricted to C -# --------------------------------------------------------------------------- # - - -def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = sorted(arguments) - out: list[frozenset[str]] = [] - for size in range(len(ordered) + 1): - for combo in combinations(ordered, size): - out.append(frozenset(combo)) - return out - - -def _base_complete_in_c( - af: ArgumentationFramework, c: frozenset[str] -) -> list[frozenset[str]]: - attackers_index = _attackers_index(af.defeats) - out: list[frozenset[str]] = [] - for candidate in _all_subsets(af.arguments): - if not candidate <= c: - continue - if not admissible( - candidate, - af.arguments, - af.defeats, - attacks=af.attacks, - attackers_index=attackers_index, - ): - continue - # F_AF(candidate) cap C subseteq candidate (fixpoint condition, in C) - defended = characteristic_fn( - candidate, af.arguments, af.defeats, attackers_index=attackers_index - ) - if (defended & c) <= candidate: - out.append(candidate) - return out - - -def _base_preferred_in_c( - af: ArgumentationFramework, c: frozenset[str] -) -> list[frozenset[str]]: - completes = _base_complete_in_c(af, c) - return [e for e in completes if not any(e < other for other in completes)] - - -def _flat_enumerate( - semantics: str, af: ArgumentationFramework -) -> list[frozenset[str]]: - """The existing flat (non-SCC, non-preprocessed) enumerator for ``semantics``. - - This is exactly the path ``decompose=False`` and the previous (pre-Wave-B2) - ``solver`` / ``sat_encoding`` code used -- we do *not* reimplement it. - """ - if semantics == "complete": - return complete_extensions(af) - if semantics == "preferred": - return preferred_extensions(af) - if semantics == "stable": - return stable_extensions(af) - raise ValueError(f"not an SCC-recursive semantics: {semantics!r}") - - -def _base_solve( - semantics: str, af: ArgumentationFramework, c: frozenset[str] -) -> list[frozenset[str]]: - # When membership is unrestricted (top-level call, or any sub-AF reached with - # C == its whole argument set) the (AF, C) base function collapses to the plain - # semantics -> use the existing flat enumerator. The brute-force-over-subsets - # path is only taken when C genuinely restricts, which (per the schema) happens - # only at recursion depth >= 1 on small disconnected sub-AFs of a single SCC. - if c >= af.arguments: - return _flat_enumerate(semantics, af) - if semantics == "complete": - return _base_complete_in_c(af, c) - if semantics == "preferred": - return _base_preferred_in_c(af, c) - if semantics == "stable": - # C is provably inert for stable ([BG&G05] p. 188): SE(AF, C) = SE(AF). - return _flat_enumerate(semantics, af) - raise ValueError(f"not an SCC-recursive semantics: {semantics!r}") - - -# --------------------------------------------------------------------------- # -# Condensation + topological order -# --------------------------------------------------------------------------- # - - -def _topological_scc_order( - sccs: list[frozenset[str]], - defeats: frozenset[tuple[str, str]], -) -> list[frozenset[str]]: - """Return the SCCs in a topological order of the condensation (parents first).""" - index_of: dict[str, int] = {} - for i, scc in enumerate(sccs): - for arg in scc: - index_of[arg] = i - succ: list[set[int]] = [set() for _ in sccs] - indeg = [0] * len(sccs) - for src, dst in defeats: - i, j = index_of[src], index_of[dst] - if i != j and j not in succ[i]: - succ[i].add(j) - indeg[j] += 1 - # deterministic: break ties by sorted member tuple - order_key = [tuple(sorted(scc)) for scc in sccs] - ready = deque(sorted((i for i in range(len(sccs)) if indeg[i] == 0), key=lambda i: order_key[i])) - result: list[int] = [] - while ready: - i = ready.popleft() - result.append(i) - new_ready: list[int] = [] - for j in succ[i]: - indeg[j] -= 1 - if indeg[j] == 0: - new_ready.append(j) - for j in sorted(new_ready, key=lambda j: order_key[j]): - ready.append(j) - if len(result) != len(sccs): # pragma: no cover -- condensation is always a DAG - raise RuntimeError("condensation is not a DAG -- SCC computation is wrong") - return [sccs[i] for i in result] - - -# --------------------------------------------------------------------------- # -# The recursive enumerator GF(AF, C) -# --------------------------------------------------------------------------- # - - -def _gf( - semantics: str, af: ArgumentationFramework, c: frozenset[str] -) -> list[frozenset[str]]: - sccs = _strongly_connected_components(af.arguments, af.defeats) - if len(sccs) <= 1: - return _base_solve(semantics, af, c) - - attackers_index = _attackers_index(af.defeats) - scc_of: dict[str, frozenset[str]] = {} - for scc in sccs: - for arg in scc: - scc_of[arg] = scc - - order = _topological_scc_order(sccs, af.defeats) - - partials: list[frozenset[str]] = [frozenset()] - for scc in order: - # outparents(S): arguments outside S that attack some node of S - outparents = frozenset( - src for src, dst in af.defeats if dst in scc and src not in scc - ) - new_partials: list[frozenset[str]] = [] - for e in partials: - e_out = e & outparents # part of E that lies outside S - d_set = { - a - for a in scc - if any((b, a) in af.defeats for b in e_out) - } - up_set = scc - d_set - u_set = set() - for a in up_set: - # a not attacked from outside by E: - if any((b, a) in af.defeats for b in e_out): - continue - ext_attackers = (attackers_index.get(a, frozenset()) & outparents) - # every external attacker b of a is itself attacked by E - if all(any((d, b) in af.defeats for d in e) for b in ext_attackers): - u_set.add(a) - sub_af = _subframework(af, frozenset(up_set)) - sub_c = frozenset(u_set) & c - for e_s in _gf(semantics, sub_af, sub_c): - new_partials.append(e | e_s) - partials = new_partials - return partials - - -# --------------------------------------------------------------------------- # -# Public enumeration API -# --------------------------------------------------------------------------- # - - -def _normalize_semantics(semantics: str) -> str: - return semantics.strip().lower().replace("_", "-") - - -def scc_extensions( - framework: ArgumentationFramework, - semantics: str, - *, - decompose: bool = True, -) -> list[frozenset[str]]: - """Enumerate complete / preferred / stable extensions via the SCC-recursive schema. - - ``decompose=False`` opts out of the SCC layer (and the preprocessing layer): - the framework is solved by the base function directly. The result is always - identical to the flat path -- this is a transparent speedup. - """ - semantics = _normalize_semantics(semantics) - if semantics not in SCC_RECURSIVE_SEMANTICS: - raise ValueError( - f"scc_extensions only handles {sorted(SCC_RECURSIVE_SEMANTICS)}, got {semantics!r}" - ) - - LAST_SOLVE.reset() - LAST_SOLVE.semantics = semantics - LAST_SOLVE.decompose_requested = decompose - - if not decompose: - LAST_SOLVE.flat_fast_path = True - LAST_SOLVE.notes.append("decompose=False -> flat base solve on whole AF") - return _base_solve(semantics, framework, framework.arguments) - - simplification: AfSimplification = simplify_af(framework, semantics=semantics) - residual = simplification.residual - LAST_SOLVE.residual_size = len(residual.arguments) - - sccs = _strongly_connected_components(residual.arguments, residual.defeats) - LAST_SOLVE.residual_scc_count = len(sccs) - - if len(sccs) <= 1: - # Empty residual or one SCC: skip decomposition machinery, call the base - # (flat) solve on the residual directly. Zero SCC-layer overhead. - LAST_SOLVE.flat_fast_path = True - LAST_SOLVE.notes.append( - f"residual has {len(sccs)} SCC(s) -> flat base solve on residual" - ) - residual_extensions = _base_solve(semantics, residual, residual.arguments) - else: - LAST_SOLVE.flat_fast_path = False - LAST_SOLVE.notes.append(f"residual has {len(sccs)} SCCs -> SCC recursion") - residual_extensions = _gf(semantics, residual, residual.arguments) - - return simplification.lift_all(residual_extensions) - - -# --------------------------------------------------------------------------- # -# DC/DS via enumeration -# --------------------------------------------------------------------------- # - - -def scc_credulously_accepted( - framework: ArgumentationFramework, - semantics: str, - argument: str, - *, - decompose: bool = True, -) -> bool: - """DC-sigma: is ``argument`` in some sigma-extension? (sigma in {complete, preferred, stable}).""" - return any( - argument in extension - for extension in scc_extensions(framework, semantics, decompose=decompose) - ) - - -def scc_skeptically_accepted( - framework: ArgumentationFramework, - semantics: str, - argument: str, - *, - decompose: bool = True, -) -> bool: - """DS-sigma: is ``argument`` in every sigma-extension? (vacuously True if none).""" - extensions = scc_extensions(framework, semantics, decompose=decompose) - return all(argument in extension for extension in extensions) - - -__all__ = [ - "LAST_SOLVE", - "SCC_RECURSIVE_SEMANTICS", - "scc_credulously_accepted", - "scc_extensions", - "scc_skeptically_accepted", -] +"""SCC-recursive solving for the SCC-recursive Dung semantics (complete, preferred, stable). + +Wave B2 of the graph-theory speedup workstream. Implements the Baroni-Giacomin-Guida +(AIJ 168, 2005) SCC-recursive schema (Def 20, Thm 43) on top of the Wave A +:func:`argumentation.core.preprocessing.simplify_af` grounded reduct. + +Pipeline for ``complete`` / ``preferred`` / ``stable`` enumeration: + +1. ``simplify_af(framework, semantics=...)`` -> residual AF + lift data. +2. SCC-decompose the residual; if it is one SCC (or trivially small / empty) skip + straight to the flat base solve -- zero decomposition overhead. +3. Otherwise process SCCs in topological order of the condensation; for each SCC + ``S`` and each partial extension ``E`` chosen over ancestor SCCs, compute the + ``D(S,E)`` / ``U(S,E)`` / ``UP(S,E)`` sets per [BG&G05] Def 18, restrict to + ``UP(S,E)``, recurse ``GF(AF|UP(S,E), U(S,E) cap C)``; cross-product the + per-SCC partial results. +4. Lift each residual extension back through ``simplification.lift``. + +Base function (single-SCC sub-AF ``AF`` with membership restricted to ``C``): + +* complete -> ``CE(AF, C)`` -- admissible-in-``C`` sets ``E`` with + ``F_AF(E) cap C subseteq E`` ([BG&G05] CE def, Def 23). +* preferred -> ``PE(AF, C)`` -- the ``subseteq``-maximal elements of ``CE(AF, C)``. +* stable -> ``SE(AF, C)`` = ``SE(AF)`` -- ``C`` is provably inert for stable + ([BG&G05] p. 188). + +Resolution of spec ``UNRESOLVED`` item #1 (the ``(AF, C)``-restricted base solve +encoding): rather than threading a "force not-IN" constraint through the Z3 +single-extension finders in ``af_sat.py`` (which (a) cannot enumerate and (b) only +expose a "force OUT" knob, which the spec flags as the *wrong* semantics for the +``C`` restriction), we enumerate the base case directly over the subsets of the +sub-AF using the ``argumentation.core.dung`` primitives (``admissible``, +``characteristic_fn``, ``conflict_free``). Sub-AFs handed to the base solve are +single SCCs of the post-preprocessing residual, hence small, so brute-force +enumeration over their subsets is cheap and -- critically -- exact against the +``[BG&G05]`` definitions, which the oracle tests confirm. This is *not* a +reimplementation of the flat SAT solver: it is the same finite-candidate +enumeration ``dung.complete_extensions`` / ``preferred_extensions`` / +``stable_extensions`` already use, specialised with the ``C`` membership filter. + +DC/DS (credulous/skeptical acceptance) for these three semantics is routed through +full enumeration (spec ``UNRESOLVED`` item #2 -- query-driven pruning deferred); +correct, not maximally clever, as the prompt permits for this wave. + +Hard stop: only complete / preferred / stable. Semi-stable, stage, grounded, ideal, +admissible keep their existing flat paths -- they are not (cleanly) SCC-recursive. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from itertools import combinations + +from argumentation.core.dung import ( + ArgumentationFramework, + _attackers_index, + _strongly_connected_components, + _subframework, + admissible, + characteristic_fn, + complete_extensions, + preferred_extensions, + stable_extensions, +) +from argumentation.core.preprocessing import AfSimplification, simplify_af + +SCC_RECURSIVE_SEMANTICS: frozenset[str] = frozenset( + {"complete", "preferred", "stable"} +) + + +@dataclass +class _SolveTelemetry: + """Diagnostics for the most recent SCC-recursive solve (test/inspection only).""" + + semantics: str | None = None + residual_size: int | None = None + residual_scc_count: int | None = None + flat_fast_path: bool | None = None + decompose_requested: bool | None = None + notes: list[str] = field(default_factory=list) + + def reset(self) -> None: + self.semantics = None + self.residual_size = None + self.residual_scc_count = None + self.flat_fast_path = None + self.decompose_requested = None + self.notes = [] + + +LAST_SOLVE = _SolveTelemetry() + + +# --------------------------------------------------------------------------- # +# Base function: standard semantics on a single sub-AF, membership restricted to C +# --------------------------------------------------------------------------- # + + +def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = sorted(arguments) + out: list[frozenset[str]] = [] + for size in range(len(ordered) + 1): + for combo in combinations(ordered, size): + out.append(frozenset(combo)) + return out + + +def _base_complete_in_c( + af: ArgumentationFramework, c: frozenset[str] +) -> list[frozenset[str]]: + attackers_index = _attackers_index(af.defeats) + out: list[frozenset[str]] = [] + for candidate in _all_subsets(af.arguments): + if not candidate <= c: + continue + if not admissible( + candidate, + af.arguments, + af.defeats, + attacks=af.attacks, + attackers_index=attackers_index, + ): + continue + # F_AF(candidate) cap C subseteq candidate (fixpoint condition, in C) + defended = characteristic_fn( + candidate, af.arguments, af.defeats, attackers_index=attackers_index + ) + if (defended & c) <= candidate: + out.append(candidate) + return out + + +def _base_preferred_in_c( + af: ArgumentationFramework, c: frozenset[str] +) -> list[frozenset[str]]: + completes = _base_complete_in_c(af, c) + return [e for e in completes if not any(e < other for other in completes)] + + +def _flat_enumerate( + semantics: str, af: ArgumentationFramework +) -> list[frozenset[str]]: + """The existing flat (non-SCC, non-preprocessed) enumerator for ``semantics``. + + This is exactly the path ``decompose=False`` and the previous (pre-Wave-B2) + ``solver`` / ``sat_encoding`` code used -- we do *not* reimplement it. + """ + if semantics == "complete": + return complete_extensions(af) + if semantics == "preferred": + return preferred_extensions(af) + if semantics == "stable": + return stable_extensions(af) + raise ValueError(f"not an SCC-recursive semantics: {semantics!r}") + + +def _base_solve( + semantics: str, af: ArgumentationFramework, c: frozenset[str] +) -> list[frozenset[str]]: + # When membership is unrestricted (top-level call, or any sub-AF reached with + # C == its whole argument set) the (AF, C) base function collapses to the plain + # semantics -> use the existing flat enumerator. The brute-force-over-subsets + # path is only taken when C genuinely restricts, which (per the schema) happens + # only at recursion depth >= 1 on small disconnected sub-AFs of a single SCC. + if c >= af.arguments: + return _flat_enumerate(semantics, af) + if semantics == "complete": + return _base_complete_in_c(af, c) + if semantics == "preferred": + return _base_preferred_in_c(af, c) + if semantics == "stable": + # C is provably inert for stable ([BG&G05] p. 188): SE(AF, C) = SE(AF). + return _flat_enumerate(semantics, af) + raise ValueError(f"not an SCC-recursive semantics: {semantics!r}") + + +# --------------------------------------------------------------------------- # +# Condensation + topological order +# --------------------------------------------------------------------------- # + + +def _topological_scc_order( + sccs: list[frozenset[str]], + defeats: frozenset[tuple[str, str]], +) -> list[frozenset[str]]: + """Return the SCCs in a topological order of the condensation (parents first).""" + index_of: dict[str, int] = {} + for i, scc in enumerate(sccs): + for arg in scc: + index_of[arg] = i + succ: list[set[int]] = [set() for _ in sccs] + indeg = [0] * len(sccs) + for src, dst in defeats: + i, j = index_of[src], index_of[dst] + if i != j and j not in succ[i]: + succ[i].add(j) + indeg[j] += 1 + # deterministic: break ties by sorted member tuple + order_key = [tuple(sorted(scc)) for scc in sccs] + ready = deque(sorted((i for i in range(len(sccs)) if indeg[i] == 0), key=lambda i: order_key[i])) + result: list[int] = [] + while ready: + i = ready.popleft() + result.append(i) + new_ready: list[int] = [] + for j in succ[i]: + indeg[j] -= 1 + if indeg[j] == 0: + new_ready.append(j) + for j in sorted(new_ready, key=lambda j: order_key[j]): + ready.append(j) + if len(result) != len(sccs): # pragma: no cover -- condensation is always a DAG + raise RuntimeError("condensation is not a DAG -- SCC computation is wrong") + return [sccs[i] for i in result] + + +# --------------------------------------------------------------------------- # +# The recursive enumerator GF(AF, C) +# --------------------------------------------------------------------------- # + + +def _gf( + semantics: str, af: ArgumentationFramework, c: frozenset[str] +) -> list[frozenset[str]]: + sccs = _strongly_connected_components(af.arguments, af.defeats) + if len(sccs) <= 1: + return _base_solve(semantics, af, c) + + attackers_index = _attackers_index(af.defeats) + scc_of: dict[str, frozenset[str]] = {} + for scc in sccs: + for arg in scc: + scc_of[arg] = scc + + order = _topological_scc_order(sccs, af.defeats) + + partials: list[frozenset[str]] = [frozenset()] + for scc in order: + # outparents(S): arguments outside S that attack some node of S + outparents = frozenset( + src for src, dst in af.defeats if dst in scc and src not in scc + ) + new_partials: list[frozenset[str]] = [] + for e in partials: + e_out = e & outparents # part of E that lies outside S + d_set = { + a + for a in scc + if any((b, a) in af.defeats for b in e_out) + } + up_set = scc - d_set + u_set = set() + for a in up_set: + # a not attacked from outside by E: + if any((b, a) in af.defeats for b in e_out): + continue + ext_attackers = (attackers_index.get(a, frozenset()) & outparents) + # every external attacker b of a is itself attacked by E + if all(any((d, b) in af.defeats for d in e) for b in ext_attackers): + u_set.add(a) + sub_af = _subframework(af, frozenset(up_set)) + sub_c = frozenset(u_set) & c + for e_s in _gf(semantics, sub_af, sub_c): + new_partials.append(e | e_s) + partials = new_partials + return partials + + +# --------------------------------------------------------------------------- # +# Public enumeration API +# --------------------------------------------------------------------------- # + + +def _normalize_semantics(semantics: str) -> str: + return semantics.strip().lower().replace("_", "-") + + +def scc_extensions( + framework: ArgumentationFramework, + semantics: str, + *, + decompose: bool = True, +) -> list[frozenset[str]]: + """Enumerate complete / preferred / stable extensions via the SCC-recursive schema. + + ``decompose=False`` opts out of the SCC layer (and the preprocessing layer): + the framework is solved by the base function directly. The result is always + identical to the flat path -- this is a transparent speedup. + """ + semantics = _normalize_semantics(semantics) + if semantics not in SCC_RECURSIVE_SEMANTICS: + raise ValueError( + f"scc_extensions only handles {sorted(SCC_RECURSIVE_SEMANTICS)}, got {semantics!r}" + ) + + LAST_SOLVE.reset() + LAST_SOLVE.semantics = semantics + LAST_SOLVE.decompose_requested = decompose + + if not decompose: + LAST_SOLVE.flat_fast_path = True + LAST_SOLVE.notes.append("decompose=False -> flat base solve on whole AF") + return _base_solve(semantics, framework, framework.arguments) + + simplification: AfSimplification = simplify_af(framework, semantics=semantics) + residual = simplification.residual + LAST_SOLVE.residual_size = len(residual.arguments) + + sccs = _strongly_connected_components(residual.arguments, residual.defeats) + LAST_SOLVE.residual_scc_count = len(sccs) + + if len(sccs) <= 1: + # Empty residual or one SCC: skip decomposition machinery, call the base + # (flat) solve on the residual directly. Zero SCC-layer overhead. + LAST_SOLVE.flat_fast_path = True + LAST_SOLVE.notes.append( + f"residual has {len(sccs)} SCC(s) -> flat base solve on residual" + ) + residual_extensions = _base_solve(semantics, residual, residual.arguments) + else: + LAST_SOLVE.flat_fast_path = False + LAST_SOLVE.notes.append(f"residual has {len(sccs)} SCCs -> SCC recursion") + residual_extensions = _gf(semantics, residual, residual.arguments) + + return simplification.lift_all(residual_extensions) + + +# --------------------------------------------------------------------------- # +# DC/DS via enumeration +# --------------------------------------------------------------------------- # + + +def scc_credulously_accepted( + framework: ArgumentationFramework, + semantics: str, + argument: str, + *, + decompose: bool = True, +) -> bool: + """DC-sigma: is ``argument`` in some sigma-extension? (sigma in {complete, preferred, stable}).""" + return any( + argument in extension + for extension in scc_extensions(framework, semantics, decompose=decompose) + ) + + +def scc_skeptically_accepted( + framework: ArgumentationFramework, + semantics: str, + argument: str, + *, + decompose: bool = True, +) -> bool: + """DS-sigma: is ``argument`` in every sigma-extension? (vacuously True if none).""" + extensions = scc_extensions(framework, semantics, decompose=decompose) + return all(argument in extension for extension in extensions) + + +__all__ = [ + "LAST_SOLVE", + "SCC_RECURSIVE_SEMANTICS", + "scc_credulously_accepted", + "scc_extensions", + "scc_skeptically_accepted", +] diff --git a/src/argumentation/solver_results.py b/src/argumentation/core/solver_results.py similarity index 100% rename from src/argumentation/solver_results.py rename to src/argumentation/core/solver_results.py diff --git a/src/argumentation/dynamics/__init__.py b/src/argumentation/dynamics/__init__.py new file mode 100644 index 0000000..245313e --- /dev/null +++ b/src/argumentation/dynamics/__init__.py @@ -0,0 +1 @@ +"""Dynamics layer: framework change, enforcement, and revision.""" diff --git a/src/argumentation/af_revision.py b/src/argumentation/dynamics/af_revision.py similarity index 99% rename from src/argumentation/af_revision.py rename to src/argumentation/dynamics/af_revision.py index 7b78e59..459eb5b 100644 --- a/src/argumentation/af_revision.py +++ b/src/argumentation/dynamics/af_revision.py @@ -8,7 +8,7 @@ from itertools import combinations from typing import Protocol -from argumentation.dung import ArgumentationFramework, grounded_extension, stable_extensions +from argumentation.core.dung import ArgumentationFramework, grounded_extension, stable_extensions class ExtensionConstraint(Protocol): diff --git a/src/argumentation/approximate.py b/src/argumentation/dynamics/approximate.py similarity index 99% rename from src/argumentation/approximate.py rename to src/argumentation/dynamics/approximate.py index cea62fc..f74d888 100644 --- a/src/argumentation/approximate.py +++ b/src/argumentation/dynamics/approximate.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from itertools import combinations -from argumentation.dung import ( +from argumentation.core.dung import ( ArgumentationFramework, admissible, characteristic_fn, diff --git a/src/argumentation/dynamic.py b/src/argumentation/dynamics/dynamic.py similarity index 96% rename from src/argumentation/dynamic.py rename to src/argumentation/dynamics/dynamic.py index 06e38e2..387fcda 100644 --- a/src/argumentation/dynamic.py +++ b/src/argumentation/dynamics/dynamic.py @@ -1,531 +1,531 @@ -"""Dynamic abstract argumentation with recompute and incremental updates. - -The recompute oracle remains the executable specification for update streams. -For single attack updates, this module also implements the influenced-set and -reduced-AF construction from Alfano, Greco, and Parisi 2017, Algorithm 1. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, cast - -from argumentation.dung import ArgumentationFramework -from argumentation.enforcement import SemanticsName, extensions_for - - -UpdateKind = Literal["add_arg", "del_arg", "add_att", "del_att"] - - -@dataclass(frozen=True) -class DynamicUpdate: - kind: UpdateKind - source: str - target: str | None = None - - -@dataclass(frozen=True) -class DynamicRecomputeOracle: - """Recompute-from-scratch oracle for dynamic update streams.""" - - framework: ArgumentationFramework - - def apply(self, update: DynamicUpdate) -> DynamicRecomputeOracle: - dynamic = DynamicArgumentationFramework(self.framework) - dynamic.apply(update) - return DynamicRecomputeOracle(dynamic.framework) - - def apply_all(self, updates: tuple[DynamicUpdate, ...]) -> DynamicRecomputeOracle: - oracle = self - for update in updates: - oracle = oracle.apply(update) - return oracle - - def extensions(self, semantics: SemanticsName) -> tuple[frozenset[str], ...]: - return extensions_for(self.framework, semantics) - - -@dataclass(frozen=True) -class IncrementalUpdateResult: - """Result and instrumentation for a single incremental update.""" - - original_framework: ArgumentationFramework - updated_framework: ArgumentationFramework - update: DynamicUpdate - semantics: SemanticsName - initial_extension: frozenset[str] - influenced: frozenset[str] - reduced_framework: ArgumentationFramework - reduced_extension: frozenset[str] | None - extension: frozenset[str] | None - used_incremental: bool - fallback_reason: str | None = None - - -@dataclass(frozen=True) -class DynamicAcceptanceAnswer: - """Acceptance query answer with an extension witness when available.""" - - argument: str - semantics: SemanticsName - mode: Literal["credulous", "skeptical"] - accepted: bool - witness: frozenset[str] | None = None - counterexample: frozenset[str] | None = None - - -@dataclass -class DynamicArgumentationFramework: - framework: ArgumentationFramework - - def add_argument(self, argument: str) -> None: - self.framework = ArgumentationFramework( - arguments=self.framework.arguments | {argument}, - defeats=self.framework.defeats, - ) - - def remove_argument(self, argument: str) -> None: - remaining = self.framework.arguments - {argument} - self.framework = ArgumentationFramework( - arguments=remaining, - defeats=frozenset( - (attacker, target) - for attacker, target in self.framework.defeats - if attacker in remaining and target in remaining - ), - ) - - def add_attack(self, attacker: str, target: str) -> None: - self._require_arguments(attacker, target) - self.framework = ArgumentationFramework( - arguments=self.framework.arguments, - defeats=self.framework.defeats | {(attacker, target)}, - ) - - def remove_attack(self, attacker: str, target: str) -> None: - self.framework = ArgumentationFramework( - arguments=self.framework.arguments, - defeats=self.framework.defeats - {(attacker, target)}, - ) - - def query_credulous( - self, - argument: str, - *, - semantics: SemanticsName, - ) -> bool: - self._require_arguments(argument) - return any( - argument in extension - for extension in extensions_for(self.framework, semantics) - ) - - def query_skeptical( - self, - argument: str, - *, - semantics: SemanticsName, - ) -> bool: - self._require_arguments(argument) - extensions = extensions_for(self.framework, semantics) - return bool(extensions) and all(argument in extension for extension in extensions) - - def apply(self, update: DynamicUpdate) -> None: - if update.kind == "add_arg": - self.add_argument(update.source) - elif update.kind == "del_arg": - self.remove_argument(update.source) - elif update.kind == "add_att": - if update.target is None: - raise ValueError("add_att update requires a target") - self.add_attack(update.source, update.target) - elif update.kind == "del_att": - if update.target is None: - raise ValueError("del_att update requires a target") - self.remove_attack(update.source, update.target) - else: - raise ValueError(f"unsupported dynamic update: {update.kind}") - - def _require_arguments(self, *arguments: str) -> None: - unknown = sorted(set(arguments) - self.framework.arguments) - if unknown: - raise ValueError(f"unknown arguments: {unknown!r}") - - -def _apply_update( - framework: ArgumentationFramework, - update: DynamicUpdate, -) -> ArgumentationFramework: - dynamic = DynamicArgumentationFramework(framework) - dynamic.apply(update) - return dynamic.framework - - -def _extension_status( - framework: ArgumentationFramework, - extension: frozenset[str], -) -> dict[str, Literal["IN", "OUT", "UN"]]: - attacked = frozenset( - target for attacker, target in framework.defeats if attacker in extension - ) - return { - argument: ( - "IN" - if argument in extension - else "OUT" - if argument in attacked - else "UN" - ) - for argument in framework.arguments - } - - -def _attacked_by_extension( - framework: ArgumentationFramework, - extension: frozenset[str], -) -> frozenset[str]: - return frozenset( - target for attacker, target in framework.defeats if attacker in extension - ) - - -def _reachable_from( - framework: ArgumentationFramework, - source: str, -) -> frozenset[str]: - if source not in framework.arguments: - return frozenset() - reachable: set[str] = {source} - frontier = [source] - while frontier: - current = frontier.pop() - for attacker, target in framework.defeats: - if attacker == current and target not in reachable: - reachable.add(target) - frontier.append(target) - return frozenset(reachable) - - -def _first_extension( - framework: ArgumentationFramework, - semantics: SemanticsName, -) -> frozenset[str] | None: - extensions = extensions_for(framework, semantics) - if not extensions: - return None - return sorted(extensions, key=lambda extension: (len(extension), tuple(sorted(extension))))[0] - - -def _is_extension( - framework: ArgumentationFramework, - semantics: SemanticsName, - candidate: frozenset[str], -) -> bool: - return candidate in extensions_for(framework, semantics) - - -def _validate_single_attack_update( - framework: ArgumentationFramework, - update: DynamicUpdate, -) -> tuple[str, str]: - if update.kind not in {"add_att", "del_att"}: - raise ValueError("incremental_extension_update supports single attack updates") - if update.target is None: - raise ValueError(f"{update.kind} update requires a target") - if update.source not in framework.arguments or update.target not in framework.arguments: - raise ValueError("single attack updates require existing arguments") - if update.kind == "add_att" and (update.source, update.target) in framework.defeats: - raise ValueError("add_att update requires a missing attack") - if update.kind == "del_att" and (update.source, update.target) not in framework.defeats: - raise ValueError("del_att update requires an existing attack") - return update.source, update.target - - -def influenced_arguments( - framework: ArgumentationFramework, - update: DynamicUpdate, - *, - semantics: SemanticsName, - initial_extension: frozenset[str], -) -> frozenset[str]: - """Return Alfano et al.'s influenced set for a single attack update.""" - attacker, target = _validate_single_attack_update(framework, update) - updated = _apply_update(framework, update) - if _is_extension(updated, semantics, initial_extension): - return frozenset() - - reachable = _reachable_from(framework, target) - if any( - source != attacker - and source in initial_extension - and source not in reachable - for source, attacked in framework.defeats - if attacked == target - ): - return frozenset() - - influenced: set[str] = {target} - while True: - next_influenced = set(influenced) - for source, attacked in framework.defeats: - if source not in influenced: - continue - defended_from_outside_reach = any( - defender in initial_extension and defender not in reachable - for defender, defended in framework.defeats - if defended == attacked - ) - if not defended_from_outside_reach: - next_influenced.add(attacked) - if next_influenced == influenced: - return frozenset(influenced) - influenced = next_influenced - - -def reduced_framework( - framework: ArgumentationFramework, - update: DynamicUpdate, - *, - semantics: SemanticsName, - initial_extension: frozenset[str], -) -> ArgumentationFramework: - """Return Alfano et al.'s reduced AF for a single attack update.""" - influenced = influenced_arguments( - framework, - update, - semantics=semantics, - initial_extension=initial_extension, - ) - if not influenced: - return ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - - updated = _apply_update(framework, update) - old_attacked = _attacked_by_extension(framework, initial_extension) - arguments = set(influenced) - defeats = { - defeat - for defeat in updated.defeats - if defeat[0] in influenced and defeat[1] in influenced - } - for source, target in updated.defeats: - if source not in influenced and source in initial_extension and target in influenced: - arguments.add(source) - arguments.add(target) - defeats.add((source, target)) - if ( - target in influenced - and source not in influenced - and source not in initial_extension - and source not in old_attacked - ): - arguments.add(target) - defeats.add((target, target)) - return ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(defeats), - ) - - -def incremental_extension_update( - framework: ArgumentationFramework, - update: DynamicUpdate, - *, - semantics: SemanticsName, - initial_extension: frozenset[str] | None = None, -) -> IncrementalUpdateResult: - """Run Alfano et al.'s single-update incremental extension algorithm.""" - if semantics not in {"complete", "preferred", "stable", "grounded"}: - raise ValueError(f"unsupported dynamic incremental semantics: {semantics}") - _validate_single_attack_update(framework, update) - if initial_extension is None: - initial_extension = _first_extension(framework, semantics) - if initial_extension is None: - raise ValueError(f"no initial {semantics} extension exists") - if not _is_extension(framework, semantics, initial_extension): - raise ValueError("initial_extension is not an extension of the original framework") - - updated = _apply_update(framework, update) - influenced = influenced_arguments( - framework, - update, - semantics=semantics, - initial_extension=initial_extension, - ) - empty = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - if not influenced: - return IncrementalUpdateResult( - original_framework=framework, - updated_framework=updated, - update=update, - semantics=semantics, - initial_extension=initial_extension, - influenced=influenced, - reduced_framework=empty, - reduced_extension=None, - extension=initial_extension, - used_incremental=True, - ) - - reduced = reduced_framework( - framework, - update, - semantics=semantics, - initial_extension=initial_extension, - ) - reduced_extension = _first_extension(reduced, semantics) - if reduced_extension is not None: - candidate = (initial_extension - influenced) | reduced_extension - if _is_extension(updated, semantics, candidate): - return IncrementalUpdateResult( - original_framework=framework, - updated_framework=updated, - update=update, - semantics=semantics, - initial_extension=initial_extension, - influenced=influenced, - reduced_framework=reduced, - reduced_extension=reduced_extension, - extension=candidate, - used_incremental=True, - ) - - fallback_extension = _first_extension(updated, semantics) - return IncrementalUpdateResult( - original_framework=framework, - updated_framework=updated, - update=update, - semantics=semantics, - initial_extension=initial_extension, - influenced=influenced, - reduced_framework=reduced, - reduced_extension=reduced_extension, - extension=fallback_extension, - used_incremental=False, - fallback_reason=( - "reduced_solver_no_extension" - if reduced_extension is None - else "combined_extension_invalid" - ), - ) - - -@dataclass -class IncrementalDynamicArgumentationFramework: - """Stateful dynamic AF using Algorithm 1 where its preconditions hold.""" - - framework: ArgumentationFramework - semantics: SemanticsName - current_extension: frozenset[str] | None = None - last_result: IncrementalUpdateResult | None = None - - def __post_init__(self) -> None: - if self.current_extension is None: - self.current_extension = _first_extension(self.framework, self.semantics) - elif not _is_extension(self.framework, self.semantics, self.current_extension): - raise ValueError("current_extension is not valid for the initial framework") - - def apply(self, update: DynamicUpdate) -> IncrementalUpdateResult: - if update.kind in {"add_att", "del_att"}: - try: - result = incremental_extension_update( - self.framework, - update, - semantics=self.semantics, - initial_extension=self.current_extension, - ) - except ValueError as exc: - result = self._recompute_result(update, str(exc)) - else: - result = self._recompute_result(update, "unsupported_update_kind") - self.framework = result.updated_framework - self.current_extension = result.extension - self.last_result = result - return result - - def query_credulous(self, argument: str) -> DynamicAcceptanceAnswer: - self._require_argument(argument) - extensions = extensions_for(self.framework, self.semantics) - witness = next( - (extension for extension in extensions if argument in extension), - None, - ) - return DynamicAcceptanceAnswer( - argument=argument, - semantics=self.semantics, - mode="credulous", - accepted=witness is not None, - witness=witness, - counterexample=None if witness is not None else (extensions[0] if extensions else None), - ) - - def query_skeptical(self, argument: str) -> DynamicAcceptanceAnswer: - self._require_argument(argument) - extensions = extensions_for(self.framework, self.semantics) - counterexample = next( - (extension for extension in extensions if argument not in extension), - None, - ) - accepted = bool(extensions) and counterexample is None - return DynamicAcceptanceAnswer( - argument=argument, - semantics=self.semantics, - mode="skeptical", - accepted=accepted, - witness=extensions[0] if accepted else None, - counterexample=counterexample, - ) - - def _recompute_result( - self, - update: DynamicUpdate, - reason: str, - ) -> IncrementalUpdateResult: - updated = _apply_update(self.framework, update) - extension = _first_extension(updated, self.semantics) - return IncrementalUpdateResult( - original_framework=self.framework, - updated_framework=updated, - update=update, - semantics=self.semantics, - initial_extension=self.current_extension or frozenset(), - influenced=frozenset(), - reduced_framework=ArgumentationFramework( - arguments=frozenset(), - defeats=frozenset(), - ), - reduced_extension=None, - extension=extension, - used_incremental=False, - fallback_reason=reason, - ) - - def _require_argument(self, argument: str) -> None: - if argument not in self.framework.arguments: - raise ValueError(f"unknown argument: {argument}") - - -def parse_update_stream(text: str) -> tuple[DynamicUpdate, ...]: - """Parse a compact dynamic update stream.""" - updates: list[DynamicUpdate] = [] - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts[0] in {"add_arg", "del_arg"} and len(parts) == 2: - updates.append(DynamicUpdate(cast(UpdateKind, parts[0]), parts[1])) - continue - if parts[0] in {"add_att", "del_att"} and len(parts) == 3: - updates.append(DynamicUpdate(cast(UpdateKind, parts[0]), parts[1], parts[2])) - continue - raise ValueError(f"invalid dynamic update line {line_number}: {line!r}") - return tuple(updates) - - -def apply_update_stream( - dynamic: DynamicArgumentationFramework, - updates: tuple[DynamicUpdate, ...], -) -> DynamicArgumentationFramework: - for update in updates: - dynamic.apply(update) - return dynamic +"""Dynamic abstract argumentation with recompute and incremental updates. + +The recompute oracle remains the executable specification for update streams. +For single attack updates, this module also implements the influenced-set and +reduced-AF construction from Alfano, Greco, and Parisi 2017, Algorithm 1. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, cast + +from argumentation.core.dung import ArgumentationFramework +from argumentation.dynamics.enforcement import SemanticsName, extensions_for + + +UpdateKind = Literal["add_arg", "del_arg", "add_att", "del_att"] + + +@dataclass(frozen=True) +class DynamicUpdate: + kind: UpdateKind + source: str + target: str | None = None + + +@dataclass(frozen=True) +class DynamicRecomputeOracle: + """Recompute-from-scratch oracle for dynamic update streams.""" + + framework: ArgumentationFramework + + def apply(self, update: DynamicUpdate) -> DynamicRecomputeOracle: + dynamic = DynamicArgumentationFramework(self.framework) + dynamic.apply(update) + return DynamicRecomputeOracle(dynamic.framework) + + def apply_all(self, updates: tuple[DynamicUpdate, ...]) -> DynamicRecomputeOracle: + oracle = self + for update in updates: + oracle = oracle.apply(update) + return oracle + + def extensions(self, semantics: SemanticsName) -> tuple[frozenset[str], ...]: + return extensions_for(self.framework, semantics) + + +@dataclass(frozen=True) +class IncrementalUpdateResult: + """Result and instrumentation for a single incremental update.""" + + original_framework: ArgumentationFramework + updated_framework: ArgumentationFramework + update: DynamicUpdate + semantics: SemanticsName + initial_extension: frozenset[str] + influenced: frozenset[str] + reduced_framework: ArgumentationFramework + reduced_extension: frozenset[str] | None + extension: frozenset[str] | None + used_incremental: bool + fallback_reason: str | None = None + + +@dataclass(frozen=True) +class DynamicAcceptanceAnswer: + """Acceptance query answer with an extension witness when available.""" + + argument: str + semantics: SemanticsName + mode: Literal["credulous", "skeptical"] + accepted: bool + witness: frozenset[str] | None = None + counterexample: frozenset[str] | None = None + + +@dataclass +class DynamicArgumentationFramework: + framework: ArgumentationFramework + + def add_argument(self, argument: str) -> None: + self.framework = ArgumentationFramework( + arguments=self.framework.arguments | {argument}, + defeats=self.framework.defeats, + ) + + def remove_argument(self, argument: str) -> None: + remaining = self.framework.arguments - {argument} + self.framework = ArgumentationFramework( + arguments=remaining, + defeats=frozenset( + (attacker, target) + for attacker, target in self.framework.defeats + if attacker in remaining and target in remaining + ), + ) + + def add_attack(self, attacker: str, target: str) -> None: + self._require_arguments(attacker, target) + self.framework = ArgumentationFramework( + arguments=self.framework.arguments, + defeats=self.framework.defeats | {(attacker, target)}, + ) + + def remove_attack(self, attacker: str, target: str) -> None: + self.framework = ArgumentationFramework( + arguments=self.framework.arguments, + defeats=self.framework.defeats - {(attacker, target)}, + ) + + def query_credulous( + self, + argument: str, + *, + semantics: SemanticsName, + ) -> bool: + self._require_arguments(argument) + return any( + argument in extension + for extension in extensions_for(self.framework, semantics) + ) + + def query_skeptical( + self, + argument: str, + *, + semantics: SemanticsName, + ) -> bool: + self._require_arguments(argument) + extensions = extensions_for(self.framework, semantics) + return bool(extensions) and all(argument in extension for extension in extensions) + + def apply(self, update: DynamicUpdate) -> None: + if update.kind == "add_arg": + self.add_argument(update.source) + elif update.kind == "del_arg": + self.remove_argument(update.source) + elif update.kind == "add_att": + if update.target is None: + raise ValueError("add_att update requires a target") + self.add_attack(update.source, update.target) + elif update.kind == "del_att": + if update.target is None: + raise ValueError("del_att update requires a target") + self.remove_attack(update.source, update.target) + else: + raise ValueError(f"unsupported dynamic update: {update.kind}") + + def _require_arguments(self, *arguments: str) -> None: + unknown = sorted(set(arguments) - self.framework.arguments) + if unknown: + raise ValueError(f"unknown arguments: {unknown!r}") + + +def _apply_update( + framework: ArgumentationFramework, + update: DynamicUpdate, +) -> ArgumentationFramework: + dynamic = DynamicArgumentationFramework(framework) + dynamic.apply(update) + return dynamic.framework + + +def _extension_status( + framework: ArgumentationFramework, + extension: frozenset[str], +) -> dict[str, Literal["IN", "OUT", "UN"]]: + attacked = frozenset( + target for attacker, target in framework.defeats if attacker in extension + ) + return { + argument: ( + "IN" + if argument in extension + else "OUT" + if argument in attacked + else "UN" + ) + for argument in framework.arguments + } + + +def _attacked_by_extension( + framework: ArgumentationFramework, + extension: frozenset[str], +) -> frozenset[str]: + return frozenset( + target for attacker, target in framework.defeats if attacker in extension + ) + + +def _reachable_from( + framework: ArgumentationFramework, + source: str, +) -> frozenset[str]: + if source not in framework.arguments: + return frozenset() + reachable: set[str] = {source} + frontier = [source] + while frontier: + current = frontier.pop() + for attacker, target in framework.defeats: + if attacker == current and target not in reachable: + reachable.add(target) + frontier.append(target) + return frozenset(reachable) + + +def _first_extension( + framework: ArgumentationFramework, + semantics: SemanticsName, +) -> frozenset[str] | None: + extensions = extensions_for(framework, semantics) + if not extensions: + return None + return sorted(extensions, key=lambda extension: (len(extension), tuple(sorted(extension))))[0] + + +def _is_extension( + framework: ArgumentationFramework, + semantics: SemanticsName, + candidate: frozenset[str], +) -> bool: + return candidate in extensions_for(framework, semantics) + + +def _validate_single_attack_update( + framework: ArgumentationFramework, + update: DynamicUpdate, +) -> tuple[str, str]: + if update.kind not in {"add_att", "del_att"}: + raise ValueError("incremental_extension_update supports single attack updates") + if update.target is None: + raise ValueError(f"{update.kind} update requires a target") + if update.source not in framework.arguments or update.target not in framework.arguments: + raise ValueError("single attack updates require existing arguments") + if update.kind == "add_att" and (update.source, update.target) in framework.defeats: + raise ValueError("add_att update requires a missing attack") + if update.kind == "del_att" and (update.source, update.target) not in framework.defeats: + raise ValueError("del_att update requires an existing attack") + return update.source, update.target + + +def influenced_arguments( + framework: ArgumentationFramework, + update: DynamicUpdate, + *, + semantics: SemanticsName, + initial_extension: frozenset[str], +) -> frozenset[str]: + """Return Alfano et al.'s influenced set for a single attack update.""" + attacker, target = _validate_single_attack_update(framework, update) + updated = _apply_update(framework, update) + if _is_extension(updated, semantics, initial_extension): + return frozenset() + + reachable = _reachable_from(framework, target) + if any( + source != attacker + and source in initial_extension + and source not in reachable + for source, attacked in framework.defeats + if attacked == target + ): + return frozenset() + + influenced: set[str] = {target} + while True: + next_influenced = set(influenced) + for source, attacked in framework.defeats: + if source not in influenced: + continue + defended_from_outside_reach = any( + defender in initial_extension and defender not in reachable + for defender, defended in framework.defeats + if defended == attacked + ) + if not defended_from_outside_reach: + next_influenced.add(attacked) + if next_influenced == influenced: + return frozenset(influenced) + influenced = next_influenced + + +def reduced_framework( + framework: ArgumentationFramework, + update: DynamicUpdate, + *, + semantics: SemanticsName, + initial_extension: frozenset[str], +) -> ArgumentationFramework: + """Return Alfano et al.'s reduced AF for a single attack update.""" + influenced = influenced_arguments( + framework, + update, + semantics=semantics, + initial_extension=initial_extension, + ) + if not influenced: + return ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + + updated = _apply_update(framework, update) + old_attacked = _attacked_by_extension(framework, initial_extension) + arguments = set(influenced) + defeats = { + defeat + for defeat in updated.defeats + if defeat[0] in influenced and defeat[1] in influenced + } + for source, target in updated.defeats: + if source not in influenced and source in initial_extension and target in influenced: + arguments.add(source) + arguments.add(target) + defeats.add((source, target)) + if ( + target in influenced + and source not in influenced + and source not in initial_extension + and source not in old_attacked + ): + arguments.add(target) + defeats.add((target, target)) + return ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(defeats), + ) + + +def incremental_extension_update( + framework: ArgumentationFramework, + update: DynamicUpdate, + *, + semantics: SemanticsName, + initial_extension: frozenset[str] | None = None, +) -> IncrementalUpdateResult: + """Run Alfano et al.'s single-update incremental extension algorithm.""" + if semantics not in {"complete", "preferred", "stable", "grounded"}: + raise ValueError(f"unsupported dynamic incremental semantics: {semantics}") + _validate_single_attack_update(framework, update) + if initial_extension is None: + initial_extension = _first_extension(framework, semantics) + if initial_extension is None: + raise ValueError(f"no initial {semantics} extension exists") + if not _is_extension(framework, semantics, initial_extension): + raise ValueError("initial_extension is not an extension of the original framework") + + updated = _apply_update(framework, update) + influenced = influenced_arguments( + framework, + update, + semantics=semantics, + initial_extension=initial_extension, + ) + empty = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + if not influenced: + return IncrementalUpdateResult( + original_framework=framework, + updated_framework=updated, + update=update, + semantics=semantics, + initial_extension=initial_extension, + influenced=influenced, + reduced_framework=empty, + reduced_extension=None, + extension=initial_extension, + used_incremental=True, + ) + + reduced = reduced_framework( + framework, + update, + semantics=semantics, + initial_extension=initial_extension, + ) + reduced_extension = _first_extension(reduced, semantics) + if reduced_extension is not None: + candidate = (initial_extension - influenced) | reduced_extension + if _is_extension(updated, semantics, candidate): + return IncrementalUpdateResult( + original_framework=framework, + updated_framework=updated, + update=update, + semantics=semantics, + initial_extension=initial_extension, + influenced=influenced, + reduced_framework=reduced, + reduced_extension=reduced_extension, + extension=candidate, + used_incremental=True, + ) + + fallback_extension = _first_extension(updated, semantics) + return IncrementalUpdateResult( + original_framework=framework, + updated_framework=updated, + update=update, + semantics=semantics, + initial_extension=initial_extension, + influenced=influenced, + reduced_framework=reduced, + reduced_extension=reduced_extension, + extension=fallback_extension, + used_incremental=False, + fallback_reason=( + "reduced_solver_no_extension" + if reduced_extension is None + else "combined_extension_invalid" + ), + ) + + +@dataclass +class IncrementalDynamicArgumentationFramework: + """Stateful dynamic AF using Algorithm 1 where its preconditions hold.""" + + framework: ArgumentationFramework + semantics: SemanticsName + current_extension: frozenset[str] | None = None + last_result: IncrementalUpdateResult | None = None + + def __post_init__(self) -> None: + if self.current_extension is None: + self.current_extension = _first_extension(self.framework, self.semantics) + elif not _is_extension(self.framework, self.semantics, self.current_extension): + raise ValueError("current_extension is not valid for the initial framework") + + def apply(self, update: DynamicUpdate) -> IncrementalUpdateResult: + if update.kind in {"add_att", "del_att"}: + try: + result = incremental_extension_update( + self.framework, + update, + semantics=self.semantics, + initial_extension=self.current_extension, + ) + except ValueError as exc: + result = self._recompute_result(update, str(exc)) + else: + result = self._recompute_result(update, "unsupported_update_kind") + self.framework = result.updated_framework + self.current_extension = result.extension + self.last_result = result + return result + + def query_credulous(self, argument: str) -> DynamicAcceptanceAnswer: + self._require_argument(argument) + extensions = extensions_for(self.framework, self.semantics) + witness = next( + (extension for extension in extensions if argument in extension), + None, + ) + return DynamicAcceptanceAnswer( + argument=argument, + semantics=self.semantics, + mode="credulous", + accepted=witness is not None, + witness=witness, + counterexample=None if witness is not None else (extensions[0] if extensions else None), + ) + + def query_skeptical(self, argument: str) -> DynamicAcceptanceAnswer: + self._require_argument(argument) + extensions = extensions_for(self.framework, self.semantics) + counterexample = next( + (extension for extension in extensions if argument not in extension), + None, + ) + accepted = bool(extensions) and counterexample is None + return DynamicAcceptanceAnswer( + argument=argument, + semantics=self.semantics, + mode="skeptical", + accepted=accepted, + witness=extensions[0] if accepted else None, + counterexample=counterexample, + ) + + def _recompute_result( + self, + update: DynamicUpdate, + reason: str, + ) -> IncrementalUpdateResult: + updated = _apply_update(self.framework, update) + extension = _first_extension(updated, self.semantics) + return IncrementalUpdateResult( + original_framework=self.framework, + updated_framework=updated, + update=update, + semantics=self.semantics, + initial_extension=self.current_extension or frozenset(), + influenced=frozenset(), + reduced_framework=ArgumentationFramework( + arguments=frozenset(), + defeats=frozenset(), + ), + reduced_extension=None, + extension=extension, + used_incremental=False, + fallback_reason=reason, + ) + + def _require_argument(self, argument: str) -> None: + if argument not in self.framework.arguments: + raise ValueError(f"unknown argument: {argument}") + + +def parse_update_stream(text: str) -> tuple[DynamicUpdate, ...]: + """Parse a compact dynamic update stream.""" + updates: list[DynamicUpdate] = [] + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[0] in {"add_arg", "del_arg"} and len(parts) == 2: + updates.append(DynamicUpdate(cast(UpdateKind, parts[0]), parts[1])) + continue + if parts[0] in {"add_att", "del_att"} and len(parts) == 3: + updates.append(DynamicUpdate(cast(UpdateKind, parts[0]), parts[1], parts[2])) + continue + raise ValueError(f"invalid dynamic update line {line_number}: {line!r}") + return tuple(updates) + + +def apply_update_stream( + dynamic: DynamicArgumentationFramework, + updates: tuple[DynamicUpdate, ...], +) -> DynamicArgumentationFramework: + for update in updates: + dynamic.apply(update) + return dynamic diff --git a/src/argumentation/enforcement.py b/src/argumentation/dynamics/enforcement.py similarity index 97% rename from src/argumentation/enforcement.py rename to src/argumentation/dynamics/enforcement.py index 1aab7e3..0d3ed91 100644 --- a/src/argumentation/enforcement.py +++ b/src/argumentation/dynamics/enforcement.py @@ -1,681 +1,681 @@ -"""Unconstrained minimal-change enforcement for abstract AFs. - -The module provides a brute-force reference oracle for small Dung AFs. It is -intended as the executable specification for later SAT/MaxSAT-backed -enforcement: enumerate bounded add/remove edits, apply each edit, and keep the -least-cost witness that makes the requested acceptance condition true. - -This is not Baumann-style expansion enforcement: it may add or remove attacks -between existing arguments, and the edit data type can represent argument and -attack deletions. Those operations are deliberately outside the conservative, -normal, strong, and weak expansion settings used in Baumann's framework. - -References: - Baumann, R. (2012). What does it take to enforce an argument? - Wallner, Niskanen, and Jarvisalo (2017). Complexity results and - algorithms for extension enforcement in abstract argumentation. - Baumann, Doutre, Mailly, and Wallner (2021). Enforcement in formal - argumentation. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import combinations -from typing import Callable, Literal - -from argumentation.dung import ( - ArgumentationFramework, - cf2_extensions, - complete_extensions, - grounded_extension, - ideal_extension, - preferred_extensions, - semi_stable_extensions, - stable_extensions, - stage_extensions, -) - - -SemanticsName = Literal[ - "grounded", - "complete", - "preferred", - "stable", - "semi-stable", - "stage", - "ideal", - "cf2", -] -EnforcementMode = Literal["credulous", "skeptical", "extension"] -ExtensionVariant = Literal["strict", "non-strict"] -ExpansionKind = Literal["normal", "strong", "weak"] - - -@dataclass(frozen=True) -class AFEdit: - """Hamming edit over arguments and defeats.""" - - add_arguments: frozenset[str] = frozenset() - remove_arguments: frozenset[str] = frozenset() - add_defeats: frozenset[tuple[str, str]] = frozenset() - remove_defeats: frozenset[tuple[str, str]] = frozenset() - - @property - def cost(self) -> int: - return ( - len(self.add_arguments) - + len(self.remove_arguments) - + len(self.add_defeats) - + len(self.remove_defeats) - ) - - -@dataclass(frozen=True) -class EnforcementResult: - """Minimal edit and witness framework for an enforcement query.""" - - mode: EnforcementMode - semantics: SemanticsName - edit: AFEdit - witness_framework: ArgumentationFramework - extensions: tuple[frozenset[str], ...] - accepted_arguments: frozenset[str] - - @property - def cost(self) -> int: - return self.edit.cost - - -@dataclass(frozen=True) -class Expansion: - """Baumann-style expansion witness from an original AF to an expanded AF.""" - - original: ArgumentationFramework - expanded: ArgumentationFramework - - @property - def new_arguments(self) -> frozenset[str]: - return self.expanded.arguments - self.original.arguments - - @property - def added_defeats(self) -> frozenset[tuple[str, str]]: - return self.expanded.defeats - self.original.defeats - - @property - def cost(self) -> int: - return len(self.new_arguments) + len(self.added_defeats) - - -@dataclass(frozen=True) -class ExpansionEnforcementResult: - """Minimal expansion witness for an enforcement query.""" - - mode: EnforcementMode - semantics: SemanticsName - kind: ExpansionKind - expansion: Expansion - witness_framework: ArgumentationFramework - extensions: tuple[frozenset[str], ...] - accepted_arguments: frozenset[str] - source_semantics: SemanticsName | None = None - - @property - def cost(self) -> int: - return self.expansion.cost - - -def apply_edit(framework: ArgumentationFramework, edit: AFEdit) -> ArgumentationFramework: - """Return the AF obtained by applying ``edit`` to ``framework``.""" - arguments = (framework.arguments | edit.add_arguments) - edit.remove_arguments - defeats = (framework.defeats - edit.remove_defeats) | edit.add_defeats - defeats = frozenset( - (attacker, target) - for attacker, target in defeats - if attacker in arguments and target in arguments - ) - attacks = ( - None - if framework.attacks is None - else frozenset( - (attacker, target) - for attacker, target in framework.attacks - if attacker in arguments and target in arguments - ) - ) - return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) - - -def build_expansion( - framework: ArgumentationFramework, - *, - new_arguments: frozenset[str] = frozenset(), - added_defeats: frozenset[tuple[str, str]] = frozenset(), -) -> ArgumentationFramework: - """Return ``framework`` expanded with fresh arguments and added defeats.""" - overlap = framework.arguments & new_arguments - if overlap: - raise ValueError(f"new arguments already exist: {sorted(overlap)!r}") - arguments = framework.arguments | new_arguments - unknown = frozenset( - argument - for defeat in added_defeats - for argument in defeat - if argument not in arguments - ) - if unknown: - raise ValueError(f"added defeats mention unknown arguments: {sorted(unknown)!r}") - defeats = framework.defeats | added_defeats - attacks = None if framework.attacks is None else framework.attacks | added_defeats - return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) - - -def is_expansion( - original: ArgumentationFramework, - expanded: ArgumentationFramework, -) -> bool: - """Return whether ``expanded`` preserves all old arguments and defeats.""" - return original.arguments <= expanded.arguments and original.defeats <= expanded.defeats - - -def is_normal_expansion( - original: ArgumentationFramework, - expanded: ArgumentationFramework, -) -> bool: - """Return whether ``expanded`` is a normal expansion of ``original``. - - Baumann normal expansions preserve the old AF and every added attack has - at least one endpoint among the freshly added arguments. - """ - if not is_expansion(original, expanded): - return False - new_arguments = expanded.arguments - original.arguments - return all( - attacker in new_arguments or target in new_arguments - for attacker, target in expanded.defeats - original.defeats - ) - - -def is_strong_expansion( - original: ArgumentationFramework, - expanded: ArgumentationFramework, -) -> bool: - """Return whether ``expanded`` is a strong expansion of ``original``.""" - if not is_normal_expansion(original, expanded): - return False - new_arguments = expanded.arguments - original.arguments - return all( - not (attacker in original.arguments and target in new_arguments) - for attacker, target in expanded.defeats - original.defeats - ) - - -def is_weak_expansion( - original: ArgumentationFramework, - expanded: ArgumentationFramework, -) -> bool: - """Return whether ``expanded`` is a weak expansion of ``original``.""" - if not is_normal_expansion(original, expanded): - return False - new_arguments = expanded.arguments - original.arguments - return all( - not (attacker in new_arguments and target in original.arguments) - for attacker, target in expanded.defeats - original.defeats - ) - - -def extensions_for( - framework: ArgumentationFramework, - semantics: SemanticsName, -) -> tuple[frozenset[str], ...]: - """Return extensions for the supported Dung semantics.""" - if semantics == "grounded": - return (grounded_extension(framework),) - if semantics == "complete": - return tuple(complete_extensions(framework)) - if semantics == "preferred": - return tuple(preferred_extensions(framework)) - if semantics == "stable": - return tuple(stable_extensions(framework)) - if semantics == "semi-stable": - return tuple(semi_stable_extensions(framework)) - if semantics == "stage": - return tuple(stage_extensions(framework)) - if semantics == "ideal": - return (ideal_extension(framework),) - if semantics == "cf2": - return tuple(cf2_extensions(framework)) - raise ValueError(f"unsupported semantics: {semantics}") - - -def _credulously_accepted(extensions: tuple[frozenset[str], ...]) -> frozenset[str]: - return frozenset().union(*extensions) if extensions else frozenset() - - -def _skeptically_accepted(extensions: tuple[frozenset[str], ...]) -> frozenset[str]: - if not extensions: - return frozenset() - return frozenset.intersection(*extensions) - - -def _all_attack_edits( - framework: ArgumentationFramework, - *, - max_cost: int, -) -> list[AFEdit]: - possible_defeats = frozenset( - (attacker, target) - for attacker in framework.arguments - for target in framework.arguments - ) - removable = sorted(framework.defeats) - addable = sorted(possible_defeats - framework.defeats) - edits: list[AFEdit] = [] - for remove_count in range(max_cost + 1): - for add_count in range(max_cost - remove_count + 1): - for remove_defeats in combinations(removable, remove_count): - for add_defeats in combinations(addable, add_count): - edits.append( - AFEdit( - add_defeats=frozenset(add_defeats), - remove_defeats=frozenset(remove_defeats), - ) - ) - return sorted( - edits, - key=lambda edit: ( - edit.cost, - tuple(sorted(edit.remove_defeats)), - tuple(sorted(edit.add_defeats)), - ), - ) - - -def _minimal_result( - framework: ArgumentationFramework, - *, - semantics: SemanticsName, - max_cost: int, - mode: EnforcementMode, - accepts: Callable[[tuple[frozenset[str], ...]], bool], -) -> EnforcementResult: - for edit in _all_attack_edits(framework, max_cost=max_cost): - witness = apply_edit(framework, edit) - extensions = extensions_for(witness, semantics) - if not accepts(extensions): - continue - accepted = ( - _skeptically_accepted(extensions) - if mode == "skeptical" - else _credulously_accepted(extensions) - ) - return EnforcementResult( - mode=mode, - semantics=semantics, - edit=edit, - witness_framework=witness, - extensions=extensions, - accepted_arguments=accepted, - ) - raise ValueError( - f"no {mode} {semantics} enforcement found within max_cost={max_cost}" - ) - - -def _expansion_predicate(kind: ExpansionKind) -> Callable[ - [ArgumentationFramework, ArgumentationFramework], - bool, -]: - if kind == "normal": - return is_normal_expansion - if kind == "strong": - return is_strong_expansion - if kind == "weak": - return is_weak_expansion - raise ValueError(f"unsupported expansion kind: {kind}") - - -def _all_expansions( - framework: ArgumentationFramework, - *, - kind: ExpansionKind, - candidate_new_arguments: frozenset[str], - max_new_arguments: int, - max_added_defeats: int, -) -> list[Expansion]: - if max_new_arguments < 0: - raise ValueError("max_new_arguments must be non-negative") - if max_added_defeats < 0: - raise ValueError("max_added_defeats must be non-negative") - overlap = framework.arguments & candidate_new_arguments - if overlap: - raise ValueError(f"candidate new arguments already exist: {sorted(overlap)!r}") - - predicate = _expansion_predicate(kind) - expansions: list[Expansion] = [] - new_argument_pool = sorted(candidate_new_arguments) - for new_count in range(min(max_new_arguments, len(new_argument_pool)) + 1): - for new_arguments_tuple in combinations(new_argument_pool, new_count): - new_arguments = frozenset(new_arguments_tuple) - arguments = framework.arguments | new_arguments - possible_added_defeats = sorted( - (attacker, target) - for attacker in arguments - for target in arguments - if (attacker, target) not in framework.defeats - and (attacker in new_arguments or target in new_arguments) - ) - for added_count in range(min(max_added_defeats, len(possible_added_defeats)) + 1): - for added_defeats_tuple in combinations(possible_added_defeats, added_count): - expanded = build_expansion( - framework, - new_arguments=new_arguments, - added_defeats=frozenset(added_defeats_tuple), - ) - if predicate(framework, expanded): - expansions.append(Expansion(framework, expanded)) - - return sorted( - expansions, - key=lambda expansion: ( - expansion.cost, - tuple(sorted(expansion.new_arguments)), - tuple(sorted(expansion.added_defeats)), - ), - ) - - -def _minimal_expansion_result( - framework: ArgumentationFramework, - *, - semantics: SemanticsName, - kind: ExpansionKind, - candidate_new_arguments: frozenset[str], - max_new_arguments: int, - max_added_defeats: int, - mode: EnforcementMode, - accepts: Callable[[tuple[frozenset[str], ...]], bool], - source_semantics: SemanticsName | None = None, -) -> ExpansionEnforcementResult: - for expansion in _all_expansions( - framework, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - ): - extensions = extensions_for(expansion.expanded, semantics) - if not accepts(extensions): - continue - accepted = ( - _skeptically_accepted(extensions) - if mode == "skeptical" - else _credulously_accepted(extensions) - ) - return ExpansionEnforcementResult( - mode=mode, - semantics=semantics, - kind=kind, - expansion=expansion, - witness_framework=expansion.expanded, - extensions=extensions, - accepted_arguments=accepted, - source_semantics=source_semantics, - ) - raise ValueError( - f"no {kind} expansion {mode} {semantics} enforcement found " - f"within max_new_arguments={max_new_arguments}, " - f"max_added_defeats={max_added_defeats}" - ) - - -def enforce_credulous( - framework: ArgumentationFramework, - argument: str, - *, - semantics: SemanticsName = "preferred", - max_cost: int = 2, -) -> EnforcementResult: - """Minimally edit defeats so ``argument`` appears in some extension.""" - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_result( - framework, - semantics=semantics, - max_cost=max_cost, - mode="credulous", - accepts=lambda extensions: any(argument in extension for extension in extensions), - ) - - -def enforce_skeptical( - framework: ArgumentationFramework, - argument: str, - *, - semantics: SemanticsName = "preferred", - max_cost: int = 2, -) -> EnforcementResult: - """Minimally edit defeats so ``argument`` appears in every extension.""" - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_result( - framework, - semantics=semantics, - max_cost=max_cost, - mode="skeptical", - accepts=lambda extensions: bool(extensions) - and all(argument in extension for extension in extensions), - ) - - -def enforce_extension( - framework: ArgumentationFramework, - target: frozenset[str], - *, - semantics: SemanticsName = "preferred", - variant: ExtensionVariant = "strict", - max_cost: int = 2, -) -> EnforcementResult: - """Minimally edit defeats so ``target`` is accepted under ``semantics``. - - ``variant="strict"`` requires ``target`` itself to be an extension. - ``variant="non-strict"`` requires an extension containing ``target``. - """ - if not target <= framework.arguments: - raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") - if variant not in {"strict", "non-strict"}: - raise ValueError(f"unsupported enforcement variant: {variant}") - return _minimal_result( - framework, - semantics=semantics, - max_cost=max_cost, - mode="extension", - accepts=( - lambda extensions: target in extensions - if variant == "strict" - else any(target <= extension for extension in extensions) - ), - ) - - -def enforce_expansion_credulous( - framework: ArgumentationFramework, - argument: str, - *, - semantics: SemanticsName = "preferred", - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal Baumann-style expansion credulously enforcing ``argument``.""" - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_expansion_result( - framework, - semantics=semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="credulous", - accepts=lambda extensions: any(argument in extension for extension in extensions), - ) - - -def enforce_expansion_skeptical( - framework: ArgumentationFramework, - argument: str, - *, - semantics: SemanticsName = "preferred", - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal Baumann-style expansion skeptically enforcing ``argument``.""" - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_expansion_result( - framework, - semantics=semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="skeptical", - accepts=lambda extensions: bool(extensions) - and all(argument in extension for extension in extensions), - ) - - -def enforce_expansion_extension( - framework: ArgumentationFramework, - target: frozenset[str], - *, - semantics: SemanticsName = "preferred", - variant: ExtensionVariant = "strict", - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal Baumann-style expansion enforcing an extension target.""" - if not target <= framework.arguments: - raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") - if variant not in {"strict", "non-strict"}: - raise ValueError(f"unsupported enforcement variant: {variant}") - return _minimal_expansion_result( - framework, - semantics=semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="extension", - accepts=( - lambda extensions: target in extensions - if variant == "strict" - else any(target <= extension for extension in extensions) - ), - ) - - -def _validate_liberal_semantics( - source_semantics: SemanticsName, - target_semantics: SemanticsName, -) -> None: - if source_semantics == target_semantics: - raise ValueError("liberal enforcement requires source_semantics != target_semantics") - - -def enforce_liberal_expansion_credulous( - framework: ArgumentationFramework, - argument: str, - *, - source_semantics: SemanticsName, - target_semantics: SemanticsName, - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal liberal expansion credulously enforcing ``argument``.""" - _validate_liberal_semantics(source_semantics, target_semantics) - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_expansion_result( - framework, - semantics=target_semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="credulous", - accepts=lambda extensions: any(argument in extension for extension in extensions), - source_semantics=source_semantics, - ) - - -def enforce_liberal_expansion_skeptical( - framework: ArgumentationFramework, - argument: str, - *, - source_semantics: SemanticsName, - target_semantics: SemanticsName, - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal liberal expansion skeptically enforcing ``argument``.""" - _validate_liberal_semantics(source_semantics, target_semantics) - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument}") - return _minimal_expansion_result( - framework, - semantics=target_semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="skeptical", - accepts=lambda extensions: bool(extensions) - and all(argument in extension for extension in extensions), - source_semantics=source_semantics, - ) - - -def enforce_liberal_expansion_extension( - framework: ArgumentationFramework, - target: frozenset[str], - *, - source_semantics: SemanticsName, - target_semantics: SemanticsName, - variant: ExtensionVariant = "strict", - kind: ExpansionKind = "normal", - candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), - max_new_arguments: int = 1, - max_added_defeats: int = 2, -) -> ExpansionEnforcementResult: - """Find a minimal liberal expansion enforcing an extension target.""" - _validate_liberal_semantics(source_semantics, target_semantics) - if not target <= framework.arguments: - raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") - if variant not in {"strict", "non-strict"}: - raise ValueError(f"unsupported enforcement variant: {variant}") - return _minimal_expansion_result( - framework, - semantics=target_semantics, - kind=kind, - candidate_new_arguments=candidate_new_arguments, - max_new_arguments=max_new_arguments, - max_added_defeats=max_added_defeats, - mode="extension", - accepts=( - lambda extensions: target in extensions - if variant == "strict" - else any(target <= extension for extension in extensions) - ), - source_semantics=source_semantics, - ) +"""Unconstrained minimal-change enforcement for abstract AFs. + +The module provides a brute-force reference oracle for small Dung AFs. It is +intended as the executable specification for later SAT/MaxSAT-backed +enforcement: enumerate bounded add/remove edits, apply each edit, and keep the +least-cost witness that makes the requested acceptance condition true. + +This is not Baumann-style expansion enforcement: it may add or remove attacks +between existing arguments, and the edit data type can represent argument and +attack deletions. Those operations are deliberately outside the conservative, +normal, strong, and weak expansion settings used in Baumann's framework. + +References: + Baumann, R. (2012). What does it take to enforce an argument? + Wallner, Niskanen, and Jarvisalo (2017). Complexity results and + algorithms for extension enforcement in abstract argumentation. + Baumann, Doutre, Mailly, and Wallner (2021). Enforcement in formal + argumentation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations +from typing import Callable, Literal + +from argumentation.core.dung import ( + ArgumentationFramework, + cf2_extensions, + complete_extensions, + grounded_extension, + ideal_extension, + preferred_extensions, + semi_stable_extensions, + stable_extensions, + stage_extensions, +) + + +SemanticsName = Literal[ + "grounded", + "complete", + "preferred", + "stable", + "semi-stable", + "stage", + "ideal", + "cf2", +] +EnforcementMode = Literal["credulous", "skeptical", "extension"] +ExtensionVariant = Literal["strict", "non-strict"] +ExpansionKind = Literal["normal", "strong", "weak"] + + +@dataclass(frozen=True) +class AFEdit: + """Hamming edit over arguments and defeats.""" + + add_arguments: frozenset[str] = frozenset() + remove_arguments: frozenset[str] = frozenset() + add_defeats: frozenset[tuple[str, str]] = frozenset() + remove_defeats: frozenset[tuple[str, str]] = frozenset() + + @property + def cost(self) -> int: + return ( + len(self.add_arguments) + + len(self.remove_arguments) + + len(self.add_defeats) + + len(self.remove_defeats) + ) + + +@dataclass(frozen=True) +class EnforcementResult: + """Minimal edit and witness framework for an enforcement query.""" + + mode: EnforcementMode + semantics: SemanticsName + edit: AFEdit + witness_framework: ArgumentationFramework + extensions: tuple[frozenset[str], ...] + accepted_arguments: frozenset[str] + + @property + def cost(self) -> int: + return self.edit.cost + + +@dataclass(frozen=True) +class Expansion: + """Baumann-style expansion witness from an original AF to an expanded AF.""" + + original: ArgumentationFramework + expanded: ArgumentationFramework + + @property + def new_arguments(self) -> frozenset[str]: + return self.expanded.arguments - self.original.arguments + + @property + def added_defeats(self) -> frozenset[tuple[str, str]]: + return self.expanded.defeats - self.original.defeats + + @property + def cost(self) -> int: + return len(self.new_arguments) + len(self.added_defeats) + + +@dataclass(frozen=True) +class ExpansionEnforcementResult: + """Minimal expansion witness for an enforcement query.""" + + mode: EnforcementMode + semantics: SemanticsName + kind: ExpansionKind + expansion: Expansion + witness_framework: ArgumentationFramework + extensions: tuple[frozenset[str], ...] + accepted_arguments: frozenset[str] + source_semantics: SemanticsName | None = None + + @property + def cost(self) -> int: + return self.expansion.cost + + +def apply_edit(framework: ArgumentationFramework, edit: AFEdit) -> ArgumentationFramework: + """Return the AF obtained by applying ``edit`` to ``framework``.""" + arguments = (framework.arguments | edit.add_arguments) - edit.remove_arguments + defeats = (framework.defeats - edit.remove_defeats) | edit.add_defeats + defeats = frozenset( + (attacker, target) + for attacker, target in defeats + if attacker in arguments and target in arguments + ) + attacks = ( + None + if framework.attacks is None + else frozenset( + (attacker, target) + for attacker, target in framework.attacks + if attacker in arguments and target in arguments + ) + ) + return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) + + +def build_expansion( + framework: ArgumentationFramework, + *, + new_arguments: frozenset[str] = frozenset(), + added_defeats: frozenset[tuple[str, str]] = frozenset(), +) -> ArgumentationFramework: + """Return ``framework`` expanded with fresh arguments and added defeats.""" + overlap = framework.arguments & new_arguments + if overlap: + raise ValueError(f"new arguments already exist: {sorted(overlap)!r}") + arguments = framework.arguments | new_arguments + unknown = frozenset( + argument + for defeat in added_defeats + for argument in defeat + if argument not in arguments + ) + if unknown: + raise ValueError(f"added defeats mention unknown arguments: {sorted(unknown)!r}") + defeats = framework.defeats | added_defeats + attacks = None if framework.attacks is None else framework.attacks | added_defeats + return ArgumentationFramework(arguments=arguments, defeats=defeats, attacks=attacks) + + +def is_expansion( + original: ArgumentationFramework, + expanded: ArgumentationFramework, +) -> bool: + """Return whether ``expanded`` preserves all old arguments and defeats.""" + return original.arguments <= expanded.arguments and original.defeats <= expanded.defeats + + +def is_normal_expansion( + original: ArgumentationFramework, + expanded: ArgumentationFramework, +) -> bool: + """Return whether ``expanded`` is a normal expansion of ``original``. + + Baumann normal expansions preserve the old AF and every added attack has + at least one endpoint among the freshly added arguments. + """ + if not is_expansion(original, expanded): + return False + new_arguments = expanded.arguments - original.arguments + return all( + attacker in new_arguments or target in new_arguments + for attacker, target in expanded.defeats - original.defeats + ) + + +def is_strong_expansion( + original: ArgumentationFramework, + expanded: ArgumentationFramework, +) -> bool: + """Return whether ``expanded`` is a strong expansion of ``original``.""" + if not is_normal_expansion(original, expanded): + return False + new_arguments = expanded.arguments - original.arguments + return all( + not (attacker in original.arguments and target in new_arguments) + for attacker, target in expanded.defeats - original.defeats + ) + + +def is_weak_expansion( + original: ArgumentationFramework, + expanded: ArgumentationFramework, +) -> bool: + """Return whether ``expanded`` is a weak expansion of ``original``.""" + if not is_normal_expansion(original, expanded): + return False + new_arguments = expanded.arguments - original.arguments + return all( + not (attacker in new_arguments and target in original.arguments) + for attacker, target in expanded.defeats - original.defeats + ) + + +def extensions_for( + framework: ArgumentationFramework, + semantics: SemanticsName, +) -> tuple[frozenset[str], ...]: + """Return extensions for the supported Dung semantics.""" + if semantics == "grounded": + return (grounded_extension(framework),) + if semantics == "complete": + return tuple(complete_extensions(framework)) + if semantics == "preferred": + return tuple(preferred_extensions(framework)) + if semantics == "stable": + return tuple(stable_extensions(framework)) + if semantics == "semi-stable": + return tuple(semi_stable_extensions(framework)) + if semantics == "stage": + return tuple(stage_extensions(framework)) + if semantics == "ideal": + return (ideal_extension(framework),) + if semantics == "cf2": + return tuple(cf2_extensions(framework)) + raise ValueError(f"unsupported semantics: {semantics}") + + +def _credulously_accepted(extensions: tuple[frozenset[str], ...]) -> frozenset[str]: + return frozenset().union(*extensions) if extensions else frozenset() + + +def _skeptically_accepted(extensions: tuple[frozenset[str], ...]) -> frozenset[str]: + if not extensions: + return frozenset() + return frozenset.intersection(*extensions) + + +def _all_attack_edits( + framework: ArgumentationFramework, + *, + max_cost: int, +) -> list[AFEdit]: + possible_defeats = frozenset( + (attacker, target) + for attacker in framework.arguments + for target in framework.arguments + ) + removable = sorted(framework.defeats) + addable = sorted(possible_defeats - framework.defeats) + edits: list[AFEdit] = [] + for remove_count in range(max_cost + 1): + for add_count in range(max_cost - remove_count + 1): + for remove_defeats in combinations(removable, remove_count): + for add_defeats in combinations(addable, add_count): + edits.append( + AFEdit( + add_defeats=frozenset(add_defeats), + remove_defeats=frozenset(remove_defeats), + ) + ) + return sorted( + edits, + key=lambda edit: ( + edit.cost, + tuple(sorted(edit.remove_defeats)), + tuple(sorted(edit.add_defeats)), + ), + ) + + +def _minimal_result( + framework: ArgumentationFramework, + *, + semantics: SemanticsName, + max_cost: int, + mode: EnforcementMode, + accepts: Callable[[tuple[frozenset[str], ...]], bool], +) -> EnforcementResult: + for edit in _all_attack_edits(framework, max_cost=max_cost): + witness = apply_edit(framework, edit) + extensions = extensions_for(witness, semantics) + if not accepts(extensions): + continue + accepted = ( + _skeptically_accepted(extensions) + if mode == "skeptical" + else _credulously_accepted(extensions) + ) + return EnforcementResult( + mode=mode, + semantics=semantics, + edit=edit, + witness_framework=witness, + extensions=extensions, + accepted_arguments=accepted, + ) + raise ValueError( + f"no {mode} {semantics} enforcement found within max_cost={max_cost}" + ) + + +def _expansion_predicate(kind: ExpansionKind) -> Callable[ + [ArgumentationFramework, ArgumentationFramework], + bool, +]: + if kind == "normal": + return is_normal_expansion + if kind == "strong": + return is_strong_expansion + if kind == "weak": + return is_weak_expansion + raise ValueError(f"unsupported expansion kind: {kind}") + + +def _all_expansions( + framework: ArgumentationFramework, + *, + kind: ExpansionKind, + candidate_new_arguments: frozenset[str], + max_new_arguments: int, + max_added_defeats: int, +) -> list[Expansion]: + if max_new_arguments < 0: + raise ValueError("max_new_arguments must be non-negative") + if max_added_defeats < 0: + raise ValueError("max_added_defeats must be non-negative") + overlap = framework.arguments & candidate_new_arguments + if overlap: + raise ValueError(f"candidate new arguments already exist: {sorted(overlap)!r}") + + predicate = _expansion_predicate(kind) + expansions: list[Expansion] = [] + new_argument_pool = sorted(candidate_new_arguments) + for new_count in range(min(max_new_arguments, len(new_argument_pool)) + 1): + for new_arguments_tuple in combinations(new_argument_pool, new_count): + new_arguments = frozenset(new_arguments_tuple) + arguments = framework.arguments | new_arguments + possible_added_defeats = sorted( + (attacker, target) + for attacker in arguments + for target in arguments + if (attacker, target) not in framework.defeats + and (attacker in new_arguments or target in new_arguments) + ) + for added_count in range(min(max_added_defeats, len(possible_added_defeats)) + 1): + for added_defeats_tuple in combinations(possible_added_defeats, added_count): + expanded = build_expansion( + framework, + new_arguments=new_arguments, + added_defeats=frozenset(added_defeats_tuple), + ) + if predicate(framework, expanded): + expansions.append(Expansion(framework, expanded)) + + return sorted( + expansions, + key=lambda expansion: ( + expansion.cost, + tuple(sorted(expansion.new_arguments)), + tuple(sorted(expansion.added_defeats)), + ), + ) + + +def _minimal_expansion_result( + framework: ArgumentationFramework, + *, + semantics: SemanticsName, + kind: ExpansionKind, + candidate_new_arguments: frozenset[str], + max_new_arguments: int, + max_added_defeats: int, + mode: EnforcementMode, + accepts: Callable[[tuple[frozenset[str], ...]], bool], + source_semantics: SemanticsName | None = None, +) -> ExpansionEnforcementResult: + for expansion in _all_expansions( + framework, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + ): + extensions = extensions_for(expansion.expanded, semantics) + if not accepts(extensions): + continue + accepted = ( + _skeptically_accepted(extensions) + if mode == "skeptical" + else _credulously_accepted(extensions) + ) + return ExpansionEnforcementResult( + mode=mode, + semantics=semantics, + kind=kind, + expansion=expansion, + witness_framework=expansion.expanded, + extensions=extensions, + accepted_arguments=accepted, + source_semantics=source_semantics, + ) + raise ValueError( + f"no {kind} expansion {mode} {semantics} enforcement found " + f"within max_new_arguments={max_new_arguments}, " + f"max_added_defeats={max_added_defeats}" + ) + + +def enforce_credulous( + framework: ArgumentationFramework, + argument: str, + *, + semantics: SemanticsName = "preferred", + max_cost: int = 2, +) -> EnforcementResult: + """Minimally edit defeats so ``argument`` appears in some extension.""" + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_result( + framework, + semantics=semantics, + max_cost=max_cost, + mode="credulous", + accepts=lambda extensions: any(argument in extension for extension in extensions), + ) + + +def enforce_skeptical( + framework: ArgumentationFramework, + argument: str, + *, + semantics: SemanticsName = "preferred", + max_cost: int = 2, +) -> EnforcementResult: + """Minimally edit defeats so ``argument`` appears in every extension.""" + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_result( + framework, + semantics=semantics, + max_cost=max_cost, + mode="skeptical", + accepts=lambda extensions: bool(extensions) + and all(argument in extension for extension in extensions), + ) + + +def enforce_extension( + framework: ArgumentationFramework, + target: frozenset[str], + *, + semantics: SemanticsName = "preferred", + variant: ExtensionVariant = "strict", + max_cost: int = 2, +) -> EnforcementResult: + """Minimally edit defeats so ``target`` is accepted under ``semantics``. + + ``variant="strict"`` requires ``target`` itself to be an extension. + ``variant="non-strict"`` requires an extension containing ``target``. + """ + if not target <= framework.arguments: + raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") + if variant not in {"strict", "non-strict"}: + raise ValueError(f"unsupported enforcement variant: {variant}") + return _minimal_result( + framework, + semantics=semantics, + max_cost=max_cost, + mode="extension", + accepts=( + lambda extensions: target in extensions + if variant == "strict" + else any(target <= extension for extension in extensions) + ), + ) + + +def enforce_expansion_credulous( + framework: ArgumentationFramework, + argument: str, + *, + semantics: SemanticsName = "preferred", + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal Baumann-style expansion credulously enforcing ``argument``.""" + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_expansion_result( + framework, + semantics=semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="credulous", + accepts=lambda extensions: any(argument in extension for extension in extensions), + ) + + +def enforce_expansion_skeptical( + framework: ArgumentationFramework, + argument: str, + *, + semantics: SemanticsName = "preferred", + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal Baumann-style expansion skeptically enforcing ``argument``.""" + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_expansion_result( + framework, + semantics=semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="skeptical", + accepts=lambda extensions: bool(extensions) + and all(argument in extension for extension in extensions), + ) + + +def enforce_expansion_extension( + framework: ArgumentationFramework, + target: frozenset[str], + *, + semantics: SemanticsName = "preferred", + variant: ExtensionVariant = "strict", + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal Baumann-style expansion enforcing an extension target.""" + if not target <= framework.arguments: + raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") + if variant not in {"strict", "non-strict"}: + raise ValueError(f"unsupported enforcement variant: {variant}") + return _minimal_expansion_result( + framework, + semantics=semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="extension", + accepts=( + lambda extensions: target in extensions + if variant == "strict" + else any(target <= extension for extension in extensions) + ), + ) + + +def _validate_liberal_semantics( + source_semantics: SemanticsName, + target_semantics: SemanticsName, +) -> None: + if source_semantics == target_semantics: + raise ValueError("liberal enforcement requires source_semantics != target_semantics") + + +def enforce_liberal_expansion_credulous( + framework: ArgumentationFramework, + argument: str, + *, + source_semantics: SemanticsName, + target_semantics: SemanticsName, + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal liberal expansion credulously enforcing ``argument``.""" + _validate_liberal_semantics(source_semantics, target_semantics) + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_expansion_result( + framework, + semantics=target_semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="credulous", + accepts=lambda extensions: any(argument in extension for extension in extensions), + source_semantics=source_semantics, + ) + + +def enforce_liberal_expansion_skeptical( + framework: ArgumentationFramework, + argument: str, + *, + source_semantics: SemanticsName, + target_semantics: SemanticsName, + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal liberal expansion skeptically enforcing ``argument``.""" + _validate_liberal_semantics(source_semantics, target_semantics) + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument}") + return _minimal_expansion_result( + framework, + semantics=target_semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="skeptical", + accepts=lambda extensions: bool(extensions) + and all(argument in extension for extension in extensions), + source_semantics=source_semantics, + ) + + +def enforce_liberal_expansion_extension( + framework: ArgumentationFramework, + target: frozenset[str], + *, + source_semantics: SemanticsName, + target_semantics: SemanticsName, + variant: ExtensionVariant = "strict", + kind: ExpansionKind = "normal", + candidate_new_arguments: frozenset[str] = frozenset({"x1", "x2"}), + max_new_arguments: int = 1, + max_added_defeats: int = 2, +) -> ExpansionEnforcementResult: + """Find a minimal liberal expansion enforcing an extension target.""" + _validate_liberal_semantics(source_semantics, target_semantics) + if not target <= framework.arguments: + raise ValueError(f"target contains unknown arguments: {sorted(target - framework.arguments)!r}") + if variant not in {"strict", "non-strict"}: + raise ValueError(f"unsupported enforcement variant: {variant}") + return _minimal_expansion_result( + framework, + semantics=target_semantics, + kind=kind, + candidate_new_arguments=candidate_new_arguments, + max_new_arguments=max_new_arguments, + max_added_defeats=max_added_defeats, + mode="extension", + accepts=( + lambda extensions: target in extensions + if variant == "strict" + else any(target <= extension for extension in extensions) + ), + source_semantics=source_semantics, + ) diff --git a/src/argumentation/optimization.py b/src/argumentation/dynamics/optimization.py similarity index 96% rename from src/argumentation/optimization.py rename to src/argumentation/dynamics/optimization.py index 3e375e4..d24b79b 100644 --- a/src/argumentation/optimization.py +++ b/src/argumentation/dynamics/optimization.py @@ -1,287 +1,287 @@ -"""OMT-backed optimization semantics for abstract argumentation. - -This module turns Dung-style argumentation constraints into a Z3 Optimize -problem. The semantics constraints cite Dung 1995, p.326, where -conflict-free, acceptability, and admissibility are defined. The lexicographic -objective behaviour cites Bjorner and Phan 2014, p.7, and Sebastiani and -Trentin 2015, p.450, which describe lexicographic multi-objective OMT. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Literal - -from argumentation.dung import ArgumentationFramework - - -OptimizationDirection = Literal["maximize", "minimize"] -OptimizationSemantics = Literal["conflict_free", "admissible"] -OptimizationStatus = Literal["optimal", "unsat", "unknown", "unavailable"] - - -@dataclass(frozen=True) -class OptimizationFeature: - """Integer-valued argument feature used by an optimization objective.""" - - argument: str - name: str - value: int - - -@dataclass(frozen=True) -class OptimizationObjective: - """One lexicographic objective over accepted argument features.""" - - name: str - direction: OptimizationDirection - priority: int = 0 - weight: int = 1 - - def __post_init__(self) -> None: - if self.direction not in {"maximize", "minimize"}: - raise ValueError(f"unknown objective direction: {self.direction}") - if self.weight <= 0: - raise ValueError("objective weight must be positive") - - -@dataclass(frozen=True) -class OptimizationPolicy: - """Optimization policy for selecting one candidate argument. - - ``semantics`` currently supports the Dung constraints needed by the chess - sidecar: conflict-free sets and admissible sets. See Dung 1995, p.326. - """ - - semantics: OptimizationSemantics = "conflict_free" - objectives: tuple[OptimizationObjective, ...] = () - candidates: frozenset[str] = frozenset() - required: frozenset[str] = frozenset() - forbidden: frozenset[str] = frozenset() - - def __post_init__(self) -> None: - if self.semantics not in {"conflict_free", "admissible"}: - raise ValueError(f"unknown optimization semantics: {self.semantics}") - object.__setattr__(self, "objectives", tuple(self.objectives)) - object.__setattr__(self, "candidates", frozenset(self.candidates)) - object.__setattr__(self, "required", frozenset(self.required)) - object.__setattr__(self, "forbidden", frozenset(self.forbidden)) - - -@dataclass(frozen=True) -class OptimizationResult: - """Result of optimizing an argumentation framework.""" - - status: OptimizationStatus - selected_arguments: frozenset[str] = frozenset() - selected_candidate: str | None = None - objective_values: dict[str, int] = field(default_factory=dict) - backend: str = "z3" - trace: dict[str, Any] = field(default_factory=dict) - - -def optimize_framework( - framework: ArgumentationFramework, - policy: OptimizationPolicy, - features: tuple[OptimizationFeature, ...] | list[OptimizationFeature], -) -> OptimizationResult: - """Return an optimal accepted candidate under ``policy``. - - The selected set is constrained by Dung conflict-freeness or admissibility. - Objectives are applied lexicographically over accepted-argument feature - sums, following the OMT combination pattern in Bjorner-Phan 2014 and - Sebastiani-Trentin 2015. - """ - - _validate_policy(framework, policy) - z3 = _import_z3() - if z3 is None: - return OptimizationResult( - status="unavailable", - trace={"reason": "z3-solver is not importable"}, - ) - - arguments = tuple(sorted(framework.arguments)) - candidates = tuple(sorted(policy.candidates)) - variables = { - argument: z3.Bool(f"in_{_z3_safe_name(argument)}") - for argument in arguments - } - optimizer = z3.Optimize() - optimizer.set(priority="lex") - - _add_candidate_constraints(z3, optimizer, variables, candidates) - _add_required_forbidden_constraints(optimizer, variables, policy) - _add_conflict_free_constraints(z3, optimizer, variables, framework) - if policy.semantics == "admissible": - _add_admissibility_constraints(z3, optimizer, variables, framework) - - objective_terms = _objective_terms(z3, variables, policy.objectives, tuple(features)) - for objective in sorted(policy.objectives, key=lambda item: (item.priority, item.name)): - term = objective_terms[objective.name] - if objective.direction == "maximize": - optimizer.maximize(term) - else: - optimizer.minimize(term) - tie_break = z3.Sum( - [ - z3.If(variables[candidate], rank, 0) - for rank, candidate in enumerate(candidates) - ] - ) - optimizer.minimize(tie_break) - - check = optimizer.check() - if check == z3.unsat: - return OptimizationResult(status="unsat") - if check != z3.sat: - return OptimizationResult(status="unknown", trace={"z3_status": str(check)}) - - model = optimizer.model() - selected_arguments = frozenset( - argument - for argument in arguments - if bool(model.eval(variables[argument], model_completion=True)) - ) - selected_candidate = next( - (candidate for candidate in candidates if candidate in selected_arguments), - None, - ) - objective_values = { - objective.name: _model_int(model.eval(objective_terms[objective.name], model_completion=True)) - for objective in policy.objectives - } - objective_values["tie_break"] = _model_int(model.eval(tie_break, model_completion=True)) - return OptimizationResult( - status="optimal", - selected_arguments=selected_arguments, - selected_candidate=selected_candidate, - objective_values=objective_values, - trace={ - "semantics": policy.semantics, - "objectives": [ - { - "name": objective.name, - "direction": objective.direction, - "priority": objective.priority, - "weight": objective.weight, - } - for objective in policy.objectives - ], - }, - ) - - -def _validate_policy(framework: ArgumentationFramework, policy: OptimizationPolicy) -> None: - unknown = (policy.candidates | policy.required | policy.forbidden) - framework.arguments - if unknown: - raise ValueError(f"policy references unknown arguments: {sorted(unknown)!r}") - if not policy.candidates: - raise ValueError("optimization policy requires at least one candidate") - overlap = policy.required & policy.forbidden - if overlap: - raise ValueError(f"arguments cannot be both required and forbidden: {sorted(overlap)!r}") - - -def _add_candidate_constraints( - z3: Any, - optimizer: Any, - variables: dict[str, Any], - candidates: tuple[str, ...], -) -> None: - optimizer.add( - z3.Sum([z3.If(variables[candidate], 1, 0) for candidate in candidates]) == 1 - ) - - -def _add_required_forbidden_constraints( - optimizer: Any, - variables: dict[str, Any], - policy: OptimizationPolicy, -) -> None: - for argument in sorted(policy.required): - optimizer.add(variables[argument]) - for argument in sorted(policy.forbidden): - optimizer.add(~variables[argument]) - - -def _add_conflict_free_constraints( - z3: Any, - optimizer: Any, - variables: dict[str, Any], - framework: ArgumentationFramework, -) -> None: - # Dung 1995, p.326, Definition 5: a conflict-free set contains no attacker - # and target pair from the attack relation. - for attacker, target in sorted(framework.defeats): - optimizer.add(z3.Not(z3.And(variables[attacker], variables[target]))) - - -def _add_admissibility_constraints( - z3: Any, - optimizer: Any, - variables: dict[str, Any], - framework: ArgumentationFramework, -) -> None: - # Dung 1995, p.326, Definition 6: each selected argument must be acceptable - # with respect to the selected set, i.e. every attacker is counter-attacked - # by some selected argument. - attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - defenders: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in framework.defeats: - attackers[target].add(attacker) - defenders[target].add(attacker) - - for argument in sorted(framework.arguments): - for attacker in sorted(attackers[argument]): - selected_defenders = [ - variables[defender] - for defender in sorted(defenders[attacker]) - ] - defended = z3.Or(selected_defenders) if selected_defenders else z3.BoolVal(False) - optimizer.add(z3.Implies(variables[argument], defended)) - - -def _objective_terms( - z3: Any, - variables: dict[str, Any], - objectives: tuple[OptimizationObjective, ...], - features: tuple[OptimizationFeature, ...], -) -> dict[str, Any]: - terms = {} - feature_values: dict[tuple[str, str], int] = {} - for feature in features: - if feature.argument not in variables: - raise ValueError(f"feature references unknown argument: {feature.argument!r}") - feature_values[(feature.argument, feature.name)] = feature_values.get((feature.argument, feature.name), 0) + feature.value - - for objective in objectives: - terms[objective.name] = z3.Sum( - [ - z3.If( - variables[argument], - objective.weight * feature_values.get((argument, objective.name), 0), - 0, - ) - for argument in sorted(variables) - ] - ) - return terms - - -def _model_int(value: Any) -> int: - if hasattr(value, "as_long"): - return int(value.as_long()) - return int(str(value)) - - -def _z3_safe_name(argument: str) -> str: - return "".join(character if character.isalnum() else "_" for character in argument) - - -def _import_z3() -> Any | None: - try: - import z3 - except ImportError: - return None - return z3 +"""OMT-backed optimization semantics for abstract argumentation. + +This module turns Dung-style argumentation constraints into a Z3 Optimize +problem. The semantics constraints cite Dung 1995, p.326, where +conflict-free, acceptability, and admissibility are defined. The lexicographic +objective behaviour cites Bjorner and Phan 2014, p.7, and Sebastiani and +Trentin 2015, p.450, which describe lexicographic multi-objective OMT. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +from argumentation.core.dung import ArgumentationFramework + + +OptimizationDirection = Literal["maximize", "minimize"] +OptimizationSemantics = Literal["conflict_free", "admissible"] +OptimizationStatus = Literal["optimal", "unsat", "unknown", "unavailable"] + + +@dataclass(frozen=True) +class OptimizationFeature: + """Integer-valued argument feature used by an optimization objective.""" + + argument: str + name: str + value: int + + +@dataclass(frozen=True) +class OptimizationObjective: + """One lexicographic objective over accepted argument features.""" + + name: str + direction: OptimizationDirection + priority: int = 0 + weight: int = 1 + + def __post_init__(self) -> None: + if self.direction not in {"maximize", "minimize"}: + raise ValueError(f"unknown objective direction: {self.direction}") + if self.weight <= 0: + raise ValueError("objective weight must be positive") + + +@dataclass(frozen=True) +class OptimizationPolicy: + """Optimization policy for selecting one candidate argument. + + ``semantics`` currently supports the Dung constraints needed by the chess + sidecar: conflict-free sets and admissible sets. See Dung 1995, p.326. + """ + + semantics: OptimizationSemantics = "conflict_free" + objectives: tuple[OptimizationObjective, ...] = () + candidates: frozenset[str] = frozenset() + required: frozenset[str] = frozenset() + forbidden: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + if self.semantics not in {"conflict_free", "admissible"}: + raise ValueError(f"unknown optimization semantics: {self.semantics}") + object.__setattr__(self, "objectives", tuple(self.objectives)) + object.__setattr__(self, "candidates", frozenset(self.candidates)) + object.__setattr__(self, "required", frozenset(self.required)) + object.__setattr__(self, "forbidden", frozenset(self.forbidden)) + + +@dataclass(frozen=True) +class OptimizationResult: + """Result of optimizing an argumentation framework.""" + + status: OptimizationStatus + selected_arguments: frozenset[str] = frozenset() + selected_candidate: str | None = None + objective_values: dict[str, int] = field(default_factory=dict) + backend: str = "z3" + trace: dict[str, Any] = field(default_factory=dict) + + +def optimize_framework( + framework: ArgumentationFramework, + policy: OptimizationPolicy, + features: tuple[OptimizationFeature, ...] | list[OptimizationFeature], +) -> OptimizationResult: + """Return an optimal accepted candidate under ``policy``. + + The selected set is constrained by Dung conflict-freeness or admissibility. + Objectives are applied lexicographically over accepted-argument feature + sums, following the OMT combination pattern in Bjorner-Phan 2014 and + Sebastiani-Trentin 2015. + """ + + _validate_policy(framework, policy) + z3 = _import_z3() + if z3 is None: + return OptimizationResult( + status="unavailable", + trace={"reason": "z3-solver is not importable"}, + ) + + arguments = tuple(sorted(framework.arguments)) + candidates = tuple(sorted(policy.candidates)) + variables = { + argument: z3.Bool(f"in_{_z3_safe_name(argument)}") + for argument in arguments + } + optimizer = z3.Optimize() + optimizer.set(priority="lex") + + _add_candidate_constraints(z3, optimizer, variables, candidates) + _add_required_forbidden_constraints(optimizer, variables, policy) + _add_conflict_free_constraints(z3, optimizer, variables, framework) + if policy.semantics == "admissible": + _add_admissibility_constraints(z3, optimizer, variables, framework) + + objective_terms = _objective_terms(z3, variables, policy.objectives, tuple(features)) + for objective in sorted(policy.objectives, key=lambda item: (item.priority, item.name)): + term = objective_terms[objective.name] + if objective.direction == "maximize": + optimizer.maximize(term) + else: + optimizer.minimize(term) + tie_break = z3.Sum( + [ + z3.If(variables[candidate], rank, 0) + for rank, candidate in enumerate(candidates) + ] + ) + optimizer.minimize(tie_break) + + check = optimizer.check() + if check == z3.unsat: + return OptimizationResult(status="unsat") + if check != z3.sat: + return OptimizationResult(status="unknown", trace={"z3_status": str(check)}) + + model = optimizer.model() + selected_arguments = frozenset( + argument + for argument in arguments + if bool(model.eval(variables[argument], model_completion=True)) + ) + selected_candidate = next( + (candidate for candidate in candidates if candidate in selected_arguments), + None, + ) + objective_values = { + objective.name: _model_int(model.eval(objective_terms[objective.name], model_completion=True)) + for objective in policy.objectives + } + objective_values["tie_break"] = _model_int(model.eval(tie_break, model_completion=True)) + return OptimizationResult( + status="optimal", + selected_arguments=selected_arguments, + selected_candidate=selected_candidate, + objective_values=objective_values, + trace={ + "semantics": policy.semantics, + "objectives": [ + { + "name": objective.name, + "direction": objective.direction, + "priority": objective.priority, + "weight": objective.weight, + } + for objective in policy.objectives + ], + }, + ) + + +def _validate_policy(framework: ArgumentationFramework, policy: OptimizationPolicy) -> None: + unknown = (policy.candidates | policy.required | policy.forbidden) - framework.arguments + if unknown: + raise ValueError(f"policy references unknown arguments: {sorted(unknown)!r}") + if not policy.candidates: + raise ValueError("optimization policy requires at least one candidate") + overlap = policy.required & policy.forbidden + if overlap: + raise ValueError(f"arguments cannot be both required and forbidden: {sorted(overlap)!r}") + + +def _add_candidate_constraints( + z3: Any, + optimizer: Any, + variables: dict[str, Any], + candidates: tuple[str, ...], +) -> None: + optimizer.add( + z3.Sum([z3.If(variables[candidate], 1, 0) for candidate in candidates]) == 1 + ) + + +def _add_required_forbidden_constraints( + optimizer: Any, + variables: dict[str, Any], + policy: OptimizationPolicy, +) -> None: + for argument in sorted(policy.required): + optimizer.add(variables[argument]) + for argument in sorted(policy.forbidden): + optimizer.add(~variables[argument]) + + +def _add_conflict_free_constraints( + z3: Any, + optimizer: Any, + variables: dict[str, Any], + framework: ArgumentationFramework, +) -> None: + # Dung 1995, p.326, Definition 5: a conflict-free set contains no attacker + # and target pair from the attack relation. + for attacker, target in sorted(framework.defeats): + optimizer.add(z3.Not(z3.And(variables[attacker], variables[target]))) + + +def _add_admissibility_constraints( + z3: Any, + optimizer: Any, + variables: dict[str, Any], + framework: ArgumentationFramework, +) -> None: + # Dung 1995, p.326, Definition 6: each selected argument must be acceptable + # with respect to the selected set, i.e. every attacker is counter-attacked + # by some selected argument. + attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + defenders: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in framework.defeats: + attackers[target].add(attacker) + defenders[target].add(attacker) + + for argument in sorted(framework.arguments): + for attacker in sorted(attackers[argument]): + selected_defenders = [ + variables[defender] + for defender in sorted(defenders[attacker]) + ] + defended = z3.Or(selected_defenders) if selected_defenders else z3.BoolVal(False) + optimizer.add(z3.Implies(variables[argument], defended)) + + +def _objective_terms( + z3: Any, + variables: dict[str, Any], + objectives: tuple[OptimizationObjective, ...], + features: tuple[OptimizationFeature, ...], +) -> dict[str, Any]: + terms = {} + feature_values: dict[tuple[str, str], int] = {} + for feature in features: + if feature.argument not in variables: + raise ValueError(f"feature references unknown argument: {feature.argument!r}") + feature_values[(feature.argument, feature.name)] = feature_values.get((feature.argument, feature.name), 0) + feature.value + + for objective in objectives: + terms[objective.name] = z3.Sum( + [ + z3.If( + variables[argument], + objective.weight * feature_values.get((argument, objective.name), 0), + 0, + ) + for argument in sorted(variables) + ] + ) + return terms + + +def _model_int(value: Any) -> int: + if hasattr(value, "as_long"): + return int(value.as_long()) + return int(str(value)) + + +def _z3_safe_name(argument: str) -> str: + return "".join(character if character.isalnum() else "_" for character in argument) + + +def _import_z3() -> Any | None: + try: + import z3 + except ImportError: + return None + return z3 diff --git a/src/argumentation/encodings/aba_com_incremental.lp b/src/argumentation/encodings/aba_com_incremental.lp index e825d90..6ce7d24 100644 --- a/src/argumentation/encodings/aba_com_incremental.lp +++ b/src/argumentation/encodings/aba_com_incremental.lp @@ -3,7 +3,7 @@ % (arXiv:2108.04192), Listing 1 ("Module pi_com"). Used by the incremental % CEGAR loop of Algorithm 1 (skeptical acceptance under preferred). Expects the % ABA(F) facts assumption/1, head/2, body/2, contrary/2 (a subset of what -% argumentation.aba_asp.encode_aba_theory emits). +% argumentation.structured.aba.aba_asp.encode_aba_theory emits). % % An answer set I of ABA(F) cup pi_com has {a : in(a) in I} a complete % assumption set of F, supported(x) in I iff x is forward-derivable from it, and diff --git a/src/argumentation/frameworks/__init__.py b/src/argumentation/frameworks/__init__.py new file mode 100644 index 0000000..bf1017b --- /dev/null +++ b/src/argumentation/frameworks/__init__.py @@ -0,0 +1 @@ +"""Frameworks layer: extended abstract argumentation frameworks.""" diff --git a/src/argumentation/adf.py b/src/argumentation/frameworks/adf.py similarity index 99% rename from src/argumentation/adf.py rename to src/argumentation/frameworks/adf.py index 465767b..75eb779 100644 --- a/src/argumentation/adf.py +++ b/src/argumentation/frameworks/adf.py @@ -14,7 +14,7 @@ from itertools import product from typing import Any, Mapping, TypeAlias -from argumentation.dung import ArgumentationFramework +from argumentation.core.dung import ArgumentationFramework class ThreeValued(StrEnum): diff --git a/src/argumentation/caf.py b/src/argumentation/frameworks/caf.py similarity index 96% rename from src/argumentation/caf.py rename to src/argumentation/frameworks/caf.py index a91f859..d2aac48 100644 --- a/src/argumentation/caf.py +++ b/src/argumentation/frameworks/caf.py @@ -1,268 +1,268 @@ -"""Claim-augmented argumentation frameworks. - -CAFs attach claim identifiers to Dung arguments. Inherited semantics computes -ordinary Dung extensions first and projects them to claim sets. Claim-level -semantics applies the claim-centric maximization and range definitions from the -CAF papers. - -References: - Dvorak, Gressler, Rapberger, and Woltran (2023). The complexity landscape - of claim-augmented argumentation frameworks. - Dvorak, Rapberger, and Woltran (2020). Argumentation semantics under a - claim-centric view. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import combinations -from typing import Iterable, Literal, Mapping - -from argumentation.dung import ( - ArgumentationFramework, - admissible, - cf2_extensions, - complete_extensions, - conflict_free, - grounded_extension, - naive_extensions, - preferred_extensions, - range_of, - semi_stable_extensions, - stable_extensions, - stage_extensions, -) - - -CAFView = Literal["inherited", "claim_level"] - - -@dataclass(frozen=True) -class ClaimAugmentedAF: - framework: ArgumentationFramework - claims: Mapping[str, str] - - def __post_init__(self) -> None: - claim_keys = set(self.claims) - arguments = set(self.framework.arguments) - missing = sorted(arguments - claim_keys) - extra = sorted(claim_keys - arguments) - if missing or extra: - raise ValueError( - "claims must contain exactly the framework arguments: " - f"missing={missing!r}, extra={extra!r}" - ) - object.__setattr__( - self, - "claims", - {argument: str(claim) for argument, claim in self.claims.items()}, - ) - - -def inherited_extensions( - caf: ClaimAugmentedAF, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - """Project Dung extensions to claim sets.""" - return _deduplicate_claim_sets( - _project(caf, extension) - for extension in _argument_extensions(caf.framework, semantics) - ) - - -def claim_level_extensions( - caf: ClaimAugmentedAF, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - """Return KR 2020 claim-level CAF semantics. - - The stable branch implements cl-stable; use ``stable-admissible`` for the - admissible cl-stable variant. - """ - if semantics == "preferred": - return _maximal_claim_sets( - _project(caf, candidate) - for candidate in _argument_subsets(caf.framework.arguments) - if admissible(candidate, caf.framework.arguments, caf.framework.defeats) - ) - if semantics == "naive": - return _maximal_claim_sets( - _project(caf, candidate) - for candidate in _argument_subsets(caf.framework.arguments) - if conflict_free(candidate, caf.framework.defeats) - ) - if semantics == "stable": - all_claims = _all_claims(caf) - return _deduplicate_claim_sets( - _project(caf, candidate) - for candidate in _argument_subsets(caf.framework.arguments) - if conflict_free(candidate, caf.framework.defeats) - and claim_range(caf, candidate) == all_claims - ) - if semantics == "stable-admissible": - all_claims = _all_claims(caf) - return _deduplicate_claim_sets( - _project(caf, candidate) - for candidate in _argument_subsets(caf.framework.arguments) - if admissible(candidate, caf.framework.arguments, caf.framework.defeats) - and claim_range(caf, candidate) == all_claims - ) - if semantics == "semi-stable": - return _claim_range_maximal( - caf, - ( - candidate - for candidate in _argument_subsets(caf.framework.arguments) - if admissible(candidate, caf.framework.arguments, caf.framework.defeats) - ), - ) - if semantics == "stage": - return _claim_range_maximal( - caf, - ( - candidate - for candidate in _argument_subsets(caf.framework.arguments) - if conflict_free(candidate, caf.framework.defeats) - ), - ) - raise ValueError(f"unsupported CAF claim-level semantics: {semantics}") - - -def concurrence_holds(caf: ClaimAugmentedAF, *, semantics: str) -> bool: - """Return whether inherited and claim-level views agree.""" - return set(inherited_extensions(caf, semantics=semantics)) == set( - claim_level_extensions(caf, semantics=semantics) - ) - - -def extensions( - caf: ClaimAugmentedAF, - *, - semantics: str, - view: CAFView = "inherited", -) -> tuple[frozenset[str], ...]: - """Dispatch CAF extensions by view.""" - if view == "inherited": - return inherited_extensions(caf, semantics=semantics) - if view == "claim_level": - return claim_level_extensions(caf, semantics=semantics) - raise ValueError(f"unsupported CAF view: {view}") - - -def _argument_extensions( - framework: ArgumentationFramework, - semantics: str, -) -> tuple[frozenset[str], ...]: - if semantics == "grounded": - return (grounded_extension(framework),) - if semantics == "complete": - return tuple(complete_extensions(framework)) - if semantics == "preferred": - return tuple(preferred_extensions(framework)) - if semantics == "stable": - return tuple(stable_extensions(framework)) - if semantics == "semi-stable": - return tuple(semi_stable_extensions(framework)) - if semantics == "stage": - return tuple(stage_extensions(framework)) - if semantics == "naive": - return tuple(naive_extensions(framework)) - if semantics == "cf2": - return tuple(cf2_extensions(framework)) - raise ValueError(f"unsupported CAF semantics: {semantics}") - - -def _project(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: - return frozenset(caf.claims[argument] for argument in extension) - - -def is_well_formed(caf: ClaimAugmentedAF) -> bool: - """Return whether same-claim arguments have identical attack targets.""" - outgoing = { - argument: frozenset( - target - for attacker, target in caf.framework.defeats - if attacker == argument - ) - for argument in caf.framework.arguments - } - for left in caf.framework.arguments: - for right in caf.framework.arguments: - if caf.claims[left] == caf.claims[right] and outgoing[left] != outgoing[right]: - return False - return True - - -def defeated_claims(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: - """Return the claims defeated by ``extension`` in the CAF sense.""" - unknown = sorted(extension - caf.framework.arguments) - if unknown: - raise ValueError(f"extension contains unknown arguments: {unknown!r}") - attacked_arguments = range_of(extension, caf.framework.defeats) - extension - defeated: set[str] = set() - for claim in _all_claims(caf): - arguments_with_claim = { - argument - for argument, argument_claim in caf.claims.items() - if argument_claim == claim - } - if arguments_with_claim and arguments_with_claim <= attacked_arguments: - defeated.add(claim) - return frozenset(defeated) - - -def claim_range(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: - """Return the CAF claim range ``claim(extension) union defeated_claims``.""" - return _project(caf, extension) | defeated_claims(caf, extension) - - -def is_i_maximal(claim_sets: Iterable[frozenset[str]]) -> bool: - """Return whether no claim set is a proper subset of another.""" - values = list(_deduplicate_claim_sets(claim_sets)) - return not any(left < right for left in values for right in values) - - -def _all_claims(caf: ClaimAugmentedAF) -> frozenset[str]: - return frozenset(caf.claims.values()) - - -def _argument_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = sorted(arguments) - subsets: list[frozenset[str]] = [] - for size in range(len(ordered) + 1): - for subset in combinations(ordered, size): - subsets.append(frozenset(subset)) - return subsets - - -def _maximal_claim_sets(claim_sets: Iterable[frozenset[str]]) -> tuple[frozenset[str], ...]: - projected = list(_deduplicate_claim_sets(claim_sets)) - return tuple( - claim_set - for claim_set in projected - if not any(claim_set < other for other in projected) - ) - - -def _claim_range_maximal( - caf: ClaimAugmentedAF, - candidates: Iterable[frozenset[str]], -) -> tuple[frozenset[str], ...]: - pairs = [ - (_project(caf, candidate), claim_range(caf, candidate)) - for candidate in candidates - ] - return _deduplicate_claim_sets( - claim_set - for claim_set, claim_range in pairs - if not any(claim_range < other_range for _, other_range in pairs) - ) - - -def _deduplicate_claim_sets( - claim_sets: Iterable[frozenset[str]], -) -> tuple[frozenset[str], ...]: - unique = set(claim_sets) - return tuple(sorted(unique, key=lambda item: (len(item), tuple(sorted(item))))) +"""Claim-augmented argumentation frameworks. + +CAFs attach claim identifiers to Dung arguments. Inherited semantics computes +ordinary Dung extensions first and projects them to claim sets. Claim-level +semantics applies the claim-centric maximization and range definitions from the +CAF papers. + +References: + Dvorak, Gressler, Rapberger, and Woltran (2023). The complexity landscape + of claim-augmented argumentation frameworks. + Dvorak, Rapberger, and Woltran (2020). Argumentation semantics under a + claim-centric view. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations +from typing import Iterable, Literal, Mapping + +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + cf2_extensions, + complete_extensions, + conflict_free, + grounded_extension, + naive_extensions, + preferred_extensions, + range_of, + semi_stable_extensions, + stable_extensions, + stage_extensions, +) + + +CAFView = Literal["inherited", "claim_level"] + + +@dataclass(frozen=True) +class ClaimAugmentedAF: + framework: ArgumentationFramework + claims: Mapping[str, str] + + def __post_init__(self) -> None: + claim_keys = set(self.claims) + arguments = set(self.framework.arguments) + missing = sorted(arguments - claim_keys) + extra = sorted(claim_keys - arguments) + if missing or extra: + raise ValueError( + "claims must contain exactly the framework arguments: " + f"missing={missing!r}, extra={extra!r}" + ) + object.__setattr__( + self, + "claims", + {argument: str(claim) for argument, claim in self.claims.items()}, + ) + + +def inherited_extensions( + caf: ClaimAugmentedAF, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + """Project Dung extensions to claim sets.""" + return _deduplicate_claim_sets( + _project(caf, extension) + for extension in _argument_extensions(caf.framework, semantics) + ) + + +def claim_level_extensions( + caf: ClaimAugmentedAF, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + """Return KR 2020 claim-level CAF semantics. + + The stable branch implements cl-stable; use ``stable-admissible`` for the + admissible cl-stable variant. + """ + if semantics == "preferred": + return _maximal_claim_sets( + _project(caf, candidate) + for candidate in _argument_subsets(caf.framework.arguments) + if admissible(candidate, caf.framework.arguments, caf.framework.defeats) + ) + if semantics == "naive": + return _maximal_claim_sets( + _project(caf, candidate) + for candidate in _argument_subsets(caf.framework.arguments) + if conflict_free(candidate, caf.framework.defeats) + ) + if semantics == "stable": + all_claims = _all_claims(caf) + return _deduplicate_claim_sets( + _project(caf, candidate) + for candidate in _argument_subsets(caf.framework.arguments) + if conflict_free(candidate, caf.framework.defeats) + and claim_range(caf, candidate) == all_claims + ) + if semantics == "stable-admissible": + all_claims = _all_claims(caf) + return _deduplicate_claim_sets( + _project(caf, candidate) + for candidate in _argument_subsets(caf.framework.arguments) + if admissible(candidate, caf.framework.arguments, caf.framework.defeats) + and claim_range(caf, candidate) == all_claims + ) + if semantics == "semi-stable": + return _claim_range_maximal( + caf, + ( + candidate + for candidate in _argument_subsets(caf.framework.arguments) + if admissible(candidate, caf.framework.arguments, caf.framework.defeats) + ), + ) + if semantics == "stage": + return _claim_range_maximal( + caf, + ( + candidate + for candidate in _argument_subsets(caf.framework.arguments) + if conflict_free(candidate, caf.framework.defeats) + ), + ) + raise ValueError(f"unsupported CAF claim-level semantics: {semantics}") + + +def concurrence_holds(caf: ClaimAugmentedAF, *, semantics: str) -> bool: + """Return whether inherited and claim-level views agree.""" + return set(inherited_extensions(caf, semantics=semantics)) == set( + claim_level_extensions(caf, semantics=semantics) + ) + + +def extensions( + caf: ClaimAugmentedAF, + *, + semantics: str, + view: CAFView = "inherited", +) -> tuple[frozenset[str], ...]: + """Dispatch CAF extensions by view.""" + if view == "inherited": + return inherited_extensions(caf, semantics=semantics) + if view == "claim_level": + return claim_level_extensions(caf, semantics=semantics) + raise ValueError(f"unsupported CAF view: {view}") + + +def _argument_extensions( + framework: ArgumentationFramework, + semantics: str, +) -> tuple[frozenset[str], ...]: + if semantics == "grounded": + return (grounded_extension(framework),) + if semantics == "complete": + return tuple(complete_extensions(framework)) + if semantics == "preferred": + return tuple(preferred_extensions(framework)) + if semantics == "stable": + return tuple(stable_extensions(framework)) + if semantics == "semi-stable": + return tuple(semi_stable_extensions(framework)) + if semantics == "stage": + return tuple(stage_extensions(framework)) + if semantics == "naive": + return tuple(naive_extensions(framework)) + if semantics == "cf2": + return tuple(cf2_extensions(framework)) + raise ValueError(f"unsupported CAF semantics: {semantics}") + + +def _project(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: + return frozenset(caf.claims[argument] for argument in extension) + + +def is_well_formed(caf: ClaimAugmentedAF) -> bool: + """Return whether same-claim arguments have identical attack targets.""" + outgoing = { + argument: frozenset( + target + for attacker, target in caf.framework.defeats + if attacker == argument + ) + for argument in caf.framework.arguments + } + for left in caf.framework.arguments: + for right in caf.framework.arguments: + if caf.claims[left] == caf.claims[right] and outgoing[left] != outgoing[right]: + return False + return True + + +def defeated_claims(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: + """Return the claims defeated by ``extension`` in the CAF sense.""" + unknown = sorted(extension - caf.framework.arguments) + if unknown: + raise ValueError(f"extension contains unknown arguments: {unknown!r}") + attacked_arguments = range_of(extension, caf.framework.defeats) - extension + defeated: set[str] = set() + for claim in _all_claims(caf): + arguments_with_claim = { + argument + for argument, argument_claim in caf.claims.items() + if argument_claim == claim + } + if arguments_with_claim and arguments_with_claim <= attacked_arguments: + defeated.add(claim) + return frozenset(defeated) + + +def claim_range(caf: ClaimAugmentedAF, extension: frozenset[str]) -> frozenset[str]: + """Return the CAF claim range ``claim(extension) union defeated_claims``.""" + return _project(caf, extension) | defeated_claims(caf, extension) + + +def is_i_maximal(claim_sets: Iterable[frozenset[str]]) -> bool: + """Return whether no claim set is a proper subset of another.""" + values = list(_deduplicate_claim_sets(claim_sets)) + return not any(left < right for left in values for right in values) + + +def _all_claims(caf: ClaimAugmentedAF) -> frozenset[str]: + return frozenset(caf.claims.values()) + + +def _argument_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = sorted(arguments) + subsets: list[frozenset[str]] = [] + for size in range(len(ordered) + 1): + for subset in combinations(ordered, size): + subsets.append(frozenset(subset)) + return subsets + + +def _maximal_claim_sets(claim_sets: Iterable[frozenset[str]]) -> tuple[frozenset[str], ...]: + projected = list(_deduplicate_claim_sets(claim_sets)) + return tuple( + claim_set + for claim_set in projected + if not any(claim_set < other for other in projected) + ) + + +def _claim_range_maximal( + caf: ClaimAugmentedAF, + candidates: Iterable[frozenset[str]], +) -> tuple[frozenset[str], ...]: + pairs = [ + (_project(caf, candidate), claim_range(caf, candidate)) + for candidate in candidates + ] + return _deduplicate_claim_sets( + claim_set + for claim_set, claim_range in pairs + if not any(claim_range < other_range for _, other_range in pairs) + ) + + +def _deduplicate_claim_sets( + claim_sets: Iterable[frozenset[str]], +) -> tuple[frozenset[str], ...]: + unique = set(claim_sets) + return tuple(sorted(unique, key=lambda item: (len(item), tuple(sorted(item))))) diff --git a/src/argumentation/partial_af.py b/src/argumentation/frameworks/partial_af.py similarity index 96% rename from src/argumentation/partial_af.py rename to src/argumentation/frameworks/partial_af.py index 30bb2b4..9c14bc9 100644 --- a/src/argumentation/partial_af.py +++ b/src/argumentation/frameworks/partial_af.py @@ -1,424 +1,424 @@ -"""Partial argumentation frameworks and completion-based queries.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from itertools import combinations, product -from typing import TypeAlias - -from argumentation.dung import ( - ArgumentationFramework, - grounded_extension, - preferred_extensions, - stable_extensions, -) - -AttackPair = tuple[str, str] - - -class PairState(StrEnum): - ATTACK = "attack" - IGNORANCE = "ignorance" - NON_ATTACK = "non_attack" - - -def _normalize_pairs(pairs: frozenset[AttackPair] | set[AttackPair]) -> frozenset[AttackPair]: - normalized: set[AttackPair] = set() - for attacker, target in pairs: - normalized.add((attacker, target)) - return frozenset(normalized) - - -@dataclass(frozen=True) -class PartialArgumentationFramework: - """Partial AF over ordered pairs with an explicit three-way partition.""" - - arguments: frozenset[str] - attacks: frozenset[AttackPair] - ignorance: frozenset[AttackPair] - non_attacks: frozenset[AttackPair] - - def __post_init__(self) -> None: - arguments = frozenset(self.arguments) - attacks = _normalize_pairs(self.attacks) - ignorance = _normalize_pairs(self.ignorance) - non_attacks = _normalize_pairs(self.non_attacks) - ordered_pairs = frozenset(product(arguments, arguments)) - - overlap = ( - (attacks & ignorance) - | (attacks & non_attacks) - | (ignorance & non_attacks) - ) - if overlap: - raise ValueError( - "attacks, ignorance, and non_attacks must be pairwise disjoint" - ) - - union = attacks | ignorance | non_attacks - if union != ordered_pairs: - missing = ordered_pairs - union - extra = union - ordered_pairs - details: list[str] = [] - if missing: - details.append(f"missing={sorted(missing)!r}") - if extra: - details.append(f"extra={sorted(extra)!r}") - suffix = f": {', '.join(details)}" if details else "" - raise ValueError( - "attacks, ignorance, and non_attacks must partition A x A" - f"{suffix}" - ) - - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "attacks", attacks) - object.__setattr__(self, "ignorance", ignorance) - object.__setattr__(self, "non_attacks", non_attacks) - - @property - def ordered_pairs(self) -> frozenset[AttackPair]: - return frozenset(product(self.arguments, self.arguments)) - - def state_of(self, pair: AttackPair) -> PairState: - if pair in self.attacks: - return PairState.ATTACK - if pair in self.ignorance: - return PairState.IGNORANCE - if pair in self.non_attacks: - return PairState.NON_ATTACK - raise ValueError(f"Pair {pair!r} is not in {sorted(self.ordered_pairs)!r}") - - def completions(self) -> list[ArgumentationFramework]: - return enumerate_completions(self) - - -@dataclass(frozen=True) -class EnumerationExceeded: - """Anytime result for exact AF enumerators stopped by a candidate ceiling.""" - - partial_count: int - max_candidates: int - remainder_provenance: str = "vacuous" - - -FrameworkLike: TypeAlias = PartialArgumentationFramework | ArgumentationFramework - - -def enumerate_completions( - framework: PartialArgumentationFramework, -) -> list[ArgumentationFramework]: - """Enumerate every Dung AF obtained by resolving ignorance exactly.""" - - ignorance_pairs = sorted(framework.ignorance) - completions: list[ArgumentationFramework] = [] - for size in range(len(ignorance_pairs) + 1): - for selected in combinations(ignorance_pairs, size): - defeats = frozenset(framework.attacks | frozenset(selected)) - completions.append( - ArgumentationFramework( - arguments=framework.arguments, - defeats=defeats, - ) - ) - return completions - - -def _coerce_partial_framework(framework: FrameworkLike) -> PartialArgumentationFramework: - if isinstance(framework, PartialArgumentationFramework): - return framework - if isinstance(framework, ArgumentationFramework): - arguments = frozenset(framework.arguments) - attacks = frozenset(framework.defeats) - ordered_pairs = frozenset(product(arguments, arguments)) - return PartialArgumentationFramework( - arguments=arguments, - attacks=attacks, - ignorance=frozenset(), - non_attacks=ordered_pairs - attacks, - ) - raise TypeError(f"Unsupported framework type: {type(framework)!r}") - - -def merge_framework_edit_distance( - left: FrameworkLike, - right: FrameworkLike, -) -> int: - """Hamming distance over pair labels on a shared argument universe.""" - - left_framework = _coerce_partial_framework(left) - right_framework = _coerce_partial_framework(right) - if left_framework.arguments != right_framework.arguments: - raise ValueError("merge_framework_edit_distance requires identical arguments") - - return sum( - 1 - for pair in left_framework.ordered_pairs - if left_framework.state_of(pair) != right_framework.state_of(pair) - ) - - -def _extensions_for_completion( - completion: ArgumentationFramework, - *, - semantics: str, -) -> list[frozenset[str]]: - if semantics == "grounded": - return [grounded_extension(completion)] - if semantics == "preferred": - return [frozenset(extension) for extension in preferred_extensions(completion)] - if semantics == "stable": - return [frozenset(extension) for extension in stable_extensions(completion)] - raise ValueError(f"Unknown semantics: {semantics}") - - -def skeptically_accepted_arguments( - framework: PartialArgumentationFramework, - *, - semantics: str = "grounded", -) -> frozenset[str]: - """Arguments accepted in every extension of every completion.""" - extensions = [ - extension - for completion in enumerate_completions(framework) - for extension in _extensions_for_completion(completion, semantics=semantics) - ] - if not extensions: - return frozenset() - skeptical = set(framework.arguments) - for extension in extensions: - skeptical.intersection_update(extension) - return frozenset(skeptical) - - -def credulously_accepted_arguments( - framework: PartialArgumentationFramework, - *, - semantics: str = "grounded", -) -> frozenset[str]: - """Arguments accepted in some extension of some completion.""" - credulous: set[str] = set() - for completion in enumerate_completions(framework): - for extension in _extensions_for_completion(completion, semantics=semantics): - credulous.update(extension) - return frozenset(credulous) - - -def _attack_relation(framework: ArgumentationFramework) -> frozenset[AttackPair]: - return framework.attacks if framework.attacks is not None else framework.defeats - - -def consensual_expand( - framework: ArgumentationFramework, - universe: frozenset[str], -) -> PartialArgumentationFramework: - """Expand an AF to a shared universe using ignorance outside source scope.""" - source_arguments = frozenset(framework.arguments) - attack_relation = _attack_relation(framework) - attacks: set[AttackPair] = set() - ignorance: set[AttackPair] = set() - non_attacks: set[AttackPair] = set() - - for pair in product(universe, universe): - attacker, target = pair - if attacker in source_arguments and target in source_arguments: - if pair in attack_relation: - attacks.add(pair) - else: - non_attacks.add(pair) - else: - ignorance.add(pair) - - return PartialArgumentationFramework( - arguments=universe, - attacks=frozenset(attacks), - ignorance=frozenset(ignorance), - non_attacks=frozenset(non_attacks), - ) - - -def _candidate_frameworks( - arguments: frozenset[str], - *, - max_candidates: int | None = None, -) -> list[ArgumentationFramework] | EnumerationExceeded: - ordered_pairs = sorted(product(arguments, arguments)) - candidates: list[ArgumentationFramework] = [] - for size in range(len(ordered_pairs) + 1): - for selected in combinations(ordered_pairs, size): - if max_candidates is not None and len(candidates) >= max_candidates: - return EnumerationExceeded( - partial_count=len(candidates), - max_candidates=max_candidates, - ) - attacks = frozenset(selected) - candidates.append( - ArgumentationFramework( - arguments=arguments, - defeats=attacks, - attacks=attacks, - ) - ) - return candidates - - -def _expanded_profile( - profile: dict[str, ArgumentationFramework], - universe: frozenset[str], -) -> dict[str, PartialArgumentationFramework]: - return { - source: consensual_expand(framework, universe) - for source, framework in profile.items() - } - - -def _framework_key(framework: ArgumentationFramework) -> tuple[tuple[str, str], ...]: - return tuple(sorted(_attack_relation(framework))) - - -def _shared_universe(profile: dict[str, ArgumentationFramework]) -> frozenset[str]: - if not profile: - raise ValueError("merge profile must not be empty") - return frozenset().union(*(framework.arguments for framework in profile.values())) - - -def _strict_bipartition_sum_merge( - universe: frozenset[str], - expanded: dict[str, PartialArgumentationFramework], -) -> list[ArgumentationFramework] | None: - """Return the unique pairwise Sum median for a strict bipartition profile. - - Coste-Marquis et al. 2007 define AF merge distances over attack statuses. - For complete shared-universe profiles with no ignorance and a strict - attack/non-attack majority on every ordered pair, the Sum objective - decomposes per pair, so the unique winner is obtained without enumerating - the 2^(|A|^2) candidate AF space. - """ - - if any(framework.ignorance for framework in expanded.values()): - return None - - attacks: set[AttackPair] = set() - for pair in product(universe, universe): - attack_votes = sum(1 for framework in expanded.values() if pair in framework.attacks) - non_attack_votes = sum( - 1 for framework in expanded.values() if pair in framework.non_attacks - ) - if attack_votes == non_attack_votes: - return None - if attack_votes > non_attack_votes: - attacks.add(pair) - - attack_relation = frozenset(attacks) - return [ - ArgumentationFramework( - arguments=universe, - defeats=attack_relation, - attacks=attack_relation, - ) - ] - - -def sum_merge_frameworks( - profile: dict[str, ArgumentationFramework], - *, - max_candidates: int | None = None, -) -> list[ArgumentationFramework] | EnumerationExceeded: - """Return exact Sum-minimizing AFs over the shared argument universe.""" - universe = _shared_universe(profile) - expanded = _expanded_profile(profile, universe) - strict_bipartition_winners = _strict_bipartition_sum_merge(universe, expanded) - if strict_bipartition_winners is not None: - return strict_bipartition_winners - - candidates = _candidate_frameworks(universe, max_candidates=max_candidates) - if isinstance(candidates, EnumerationExceeded): - return candidates - - best_score: int | None = None - winners: list[ArgumentationFramework] = [] - for candidate in candidates: - score = sum( - merge_framework_edit_distance(candidate, source_framework) - for source_framework in expanded.values() - ) - if best_score is None or score < best_score: - best_score = score - winners = [candidate] - elif score == best_score: - winners.append(candidate) - return sorted(winners, key=_framework_key) - - -def max_merge_frameworks( - profile: dict[str, ArgumentationFramework], - *, - max_candidates: int | None = None, -) -> list[ArgumentationFramework] | EnumerationExceeded: - """Return exact Max-minimizing AFs over the shared argument universe.""" - universe = _shared_universe(profile) - expanded = _expanded_profile(profile, universe) - candidates = _candidate_frameworks(universe, max_candidates=max_candidates) - if isinstance(candidates, EnumerationExceeded): - return candidates - - best_score: int | None = None - winners: list[ArgumentationFramework] = [] - for candidate in candidates: - score = max( - merge_framework_edit_distance(candidate, source_framework) - for source_framework in expanded.values() - ) - if best_score is None or score < best_score: - best_score = score - winners = [candidate] - elif score == best_score: - winners.append(candidate) - return sorted(winners, key=_framework_key) - - -def leximax_merge_frameworks( - profile: dict[str, ArgumentationFramework], - *, - max_candidates: int | None = None, -) -> list[ArgumentationFramework] | EnumerationExceeded: - """Return exact Leximax-minimizing AFs over the shared argument universe.""" - universe = _shared_universe(profile) - expanded = _expanded_profile(profile, universe) - max_winners = max_merge_frameworks(profile, max_candidates=max_candidates) - if isinstance(max_winners, EnumerationExceeded): - return max_winners - - best_vector: tuple[int, ...] | None = None - winners: list[ArgumentationFramework] = [] - for candidate in max_winners: - vector = tuple( - sorted( - ( - merge_framework_edit_distance(candidate, source_framework) - for source_framework in expanded.values() - ), - reverse=True, - ) - ) - if best_vector is None or vector < best_vector: - best_vector = vector - winners = [candidate] - elif vector == best_vector: - winners.append(candidate) - return sorted(winners, key=_framework_key) - - -__all__ = [ - "EnumerationExceeded", - "PairState", - "PartialArgumentationFramework", - "enumerate_completions", - "merge_framework_edit_distance", - "skeptically_accepted_arguments", - "credulously_accepted_arguments", - "consensual_expand", - "sum_merge_frameworks", - "max_merge_frameworks", - "leximax_merge_frameworks", -] +"""Partial argumentation frameworks and completion-based queries.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from itertools import combinations, product +from typing import TypeAlias + +from argumentation.core.dung import ( + ArgumentationFramework, + grounded_extension, + preferred_extensions, + stable_extensions, +) + +AttackPair = tuple[str, str] + + +class PairState(StrEnum): + ATTACK = "attack" + IGNORANCE = "ignorance" + NON_ATTACK = "non_attack" + + +def _normalize_pairs(pairs: frozenset[AttackPair] | set[AttackPair]) -> frozenset[AttackPair]: + normalized: set[AttackPair] = set() + for attacker, target in pairs: + normalized.add((attacker, target)) + return frozenset(normalized) + + +@dataclass(frozen=True) +class PartialArgumentationFramework: + """Partial AF over ordered pairs with an explicit three-way partition.""" + + arguments: frozenset[str] + attacks: frozenset[AttackPair] + ignorance: frozenset[AttackPair] + non_attacks: frozenset[AttackPair] + + def __post_init__(self) -> None: + arguments = frozenset(self.arguments) + attacks = _normalize_pairs(self.attacks) + ignorance = _normalize_pairs(self.ignorance) + non_attacks = _normalize_pairs(self.non_attacks) + ordered_pairs = frozenset(product(arguments, arguments)) + + overlap = ( + (attacks & ignorance) + | (attacks & non_attacks) + | (ignorance & non_attacks) + ) + if overlap: + raise ValueError( + "attacks, ignorance, and non_attacks must be pairwise disjoint" + ) + + union = attacks | ignorance | non_attacks + if union != ordered_pairs: + missing = ordered_pairs - union + extra = union - ordered_pairs + details: list[str] = [] + if missing: + details.append(f"missing={sorted(missing)!r}") + if extra: + details.append(f"extra={sorted(extra)!r}") + suffix = f": {', '.join(details)}" if details else "" + raise ValueError( + "attacks, ignorance, and non_attacks must partition A x A" + f"{suffix}" + ) + + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "attacks", attacks) + object.__setattr__(self, "ignorance", ignorance) + object.__setattr__(self, "non_attacks", non_attacks) + + @property + def ordered_pairs(self) -> frozenset[AttackPair]: + return frozenset(product(self.arguments, self.arguments)) + + def state_of(self, pair: AttackPair) -> PairState: + if pair in self.attacks: + return PairState.ATTACK + if pair in self.ignorance: + return PairState.IGNORANCE + if pair in self.non_attacks: + return PairState.NON_ATTACK + raise ValueError(f"Pair {pair!r} is not in {sorted(self.ordered_pairs)!r}") + + def completions(self) -> list[ArgumentationFramework]: + return enumerate_completions(self) + + +@dataclass(frozen=True) +class EnumerationExceeded: + """Anytime result for exact AF enumerators stopped by a candidate ceiling.""" + + partial_count: int + max_candidates: int + remainder_provenance: str = "vacuous" + + +FrameworkLike: TypeAlias = PartialArgumentationFramework | ArgumentationFramework + + +def enumerate_completions( + framework: PartialArgumentationFramework, +) -> list[ArgumentationFramework]: + """Enumerate every Dung AF obtained by resolving ignorance exactly.""" + + ignorance_pairs = sorted(framework.ignorance) + completions: list[ArgumentationFramework] = [] + for size in range(len(ignorance_pairs) + 1): + for selected in combinations(ignorance_pairs, size): + defeats = frozenset(framework.attacks | frozenset(selected)) + completions.append( + ArgumentationFramework( + arguments=framework.arguments, + defeats=defeats, + ) + ) + return completions + + +def _coerce_partial_framework(framework: FrameworkLike) -> PartialArgumentationFramework: + if isinstance(framework, PartialArgumentationFramework): + return framework + if isinstance(framework, ArgumentationFramework): + arguments = frozenset(framework.arguments) + attacks = frozenset(framework.defeats) + ordered_pairs = frozenset(product(arguments, arguments)) + return PartialArgumentationFramework( + arguments=arguments, + attacks=attacks, + ignorance=frozenset(), + non_attacks=ordered_pairs - attacks, + ) + raise TypeError(f"Unsupported framework type: {type(framework)!r}") + + +def merge_framework_edit_distance( + left: FrameworkLike, + right: FrameworkLike, +) -> int: + """Hamming distance over pair labels on a shared argument universe.""" + + left_framework = _coerce_partial_framework(left) + right_framework = _coerce_partial_framework(right) + if left_framework.arguments != right_framework.arguments: + raise ValueError("merge_framework_edit_distance requires identical arguments") + + return sum( + 1 + for pair in left_framework.ordered_pairs + if left_framework.state_of(pair) != right_framework.state_of(pair) + ) + + +def _extensions_for_completion( + completion: ArgumentationFramework, + *, + semantics: str, +) -> list[frozenset[str]]: + if semantics == "grounded": + return [grounded_extension(completion)] + if semantics == "preferred": + return [frozenset(extension) for extension in preferred_extensions(completion)] + if semantics == "stable": + return [frozenset(extension) for extension in stable_extensions(completion)] + raise ValueError(f"Unknown semantics: {semantics}") + + +def skeptically_accepted_arguments( + framework: PartialArgumentationFramework, + *, + semantics: str = "grounded", +) -> frozenset[str]: + """Arguments accepted in every extension of every completion.""" + extensions = [ + extension + for completion in enumerate_completions(framework) + for extension in _extensions_for_completion(completion, semantics=semantics) + ] + if not extensions: + return frozenset() + skeptical = set(framework.arguments) + for extension in extensions: + skeptical.intersection_update(extension) + return frozenset(skeptical) + + +def credulously_accepted_arguments( + framework: PartialArgumentationFramework, + *, + semantics: str = "grounded", +) -> frozenset[str]: + """Arguments accepted in some extension of some completion.""" + credulous: set[str] = set() + for completion in enumerate_completions(framework): + for extension in _extensions_for_completion(completion, semantics=semantics): + credulous.update(extension) + return frozenset(credulous) + + +def _attack_relation(framework: ArgumentationFramework) -> frozenset[AttackPair]: + return framework.attacks if framework.attacks is not None else framework.defeats + + +def consensual_expand( + framework: ArgumentationFramework, + universe: frozenset[str], +) -> PartialArgumentationFramework: + """Expand an AF to a shared universe using ignorance outside source scope.""" + source_arguments = frozenset(framework.arguments) + attack_relation = _attack_relation(framework) + attacks: set[AttackPair] = set() + ignorance: set[AttackPair] = set() + non_attacks: set[AttackPair] = set() + + for pair in product(universe, universe): + attacker, target = pair + if attacker in source_arguments and target in source_arguments: + if pair in attack_relation: + attacks.add(pair) + else: + non_attacks.add(pair) + else: + ignorance.add(pair) + + return PartialArgumentationFramework( + arguments=universe, + attacks=frozenset(attacks), + ignorance=frozenset(ignorance), + non_attacks=frozenset(non_attacks), + ) + + +def _candidate_frameworks( + arguments: frozenset[str], + *, + max_candidates: int | None = None, +) -> list[ArgumentationFramework] | EnumerationExceeded: + ordered_pairs = sorted(product(arguments, arguments)) + candidates: list[ArgumentationFramework] = [] + for size in range(len(ordered_pairs) + 1): + for selected in combinations(ordered_pairs, size): + if max_candidates is not None and len(candidates) >= max_candidates: + return EnumerationExceeded( + partial_count=len(candidates), + max_candidates=max_candidates, + ) + attacks = frozenset(selected) + candidates.append( + ArgumentationFramework( + arguments=arguments, + defeats=attacks, + attacks=attacks, + ) + ) + return candidates + + +def _expanded_profile( + profile: dict[str, ArgumentationFramework], + universe: frozenset[str], +) -> dict[str, PartialArgumentationFramework]: + return { + source: consensual_expand(framework, universe) + for source, framework in profile.items() + } + + +def _framework_key(framework: ArgumentationFramework) -> tuple[tuple[str, str], ...]: + return tuple(sorted(_attack_relation(framework))) + + +def _shared_universe(profile: dict[str, ArgumentationFramework]) -> frozenset[str]: + if not profile: + raise ValueError("merge profile must not be empty") + return frozenset().union(*(framework.arguments for framework in profile.values())) + + +def _strict_bipartition_sum_merge( + universe: frozenset[str], + expanded: dict[str, PartialArgumentationFramework], +) -> list[ArgumentationFramework] | None: + """Return the unique pairwise Sum median for a strict bipartition profile. + + Coste-Marquis et al. 2007 define AF merge distances over attack statuses. + For complete shared-universe profiles with no ignorance and a strict + attack/non-attack majority on every ordered pair, the Sum objective + decomposes per pair, so the unique winner is obtained without enumerating + the 2^(|A|^2) candidate AF space. + """ + + if any(framework.ignorance for framework in expanded.values()): + return None + + attacks: set[AttackPair] = set() + for pair in product(universe, universe): + attack_votes = sum(1 for framework in expanded.values() if pair in framework.attacks) + non_attack_votes = sum( + 1 for framework in expanded.values() if pair in framework.non_attacks + ) + if attack_votes == non_attack_votes: + return None + if attack_votes > non_attack_votes: + attacks.add(pair) + + attack_relation = frozenset(attacks) + return [ + ArgumentationFramework( + arguments=universe, + defeats=attack_relation, + attacks=attack_relation, + ) + ] + + +def sum_merge_frameworks( + profile: dict[str, ArgumentationFramework], + *, + max_candidates: int | None = None, +) -> list[ArgumentationFramework] | EnumerationExceeded: + """Return exact Sum-minimizing AFs over the shared argument universe.""" + universe = _shared_universe(profile) + expanded = _expanded_profile(profile, universe) + strict_bipartition_winners = _strict_bipartition_sum_merge(universe, expanded) + if strict_bipartition_winners is not None: + return strict_bipartition_winners + + candidates = _candidate_frameworks(universe, max_candidates=max_candidates) + if isinstance(candidates, EnumerationExceeded): + return candidates + + best_score: int | None = None + winners: list[ArgumentationFramework] = [] + for candidate in candidates: + score = sum( + merge_framework_edit_distance(candidate, source_framework) + for source_framework in expanded.values() + ) + if best_score is None or score < best_score: + best_score = score + winners = [candidate] + elif score == best_score: + winners.append(candidate) + return sorted(winners, key=_framework_key) + + +def max_merge_frameworks( + profile: dict[str, ArgumentationFramework], + *, + max_candidates: int | None = None, +) -> list[ArgumentationFramework] | EnumerationExceeded: + """Return exact Max-minimizing AFs over the shared argument universe.""" + universe = _shared_universe(profile) + expanded = _expanded_profile(profile, universe) + candidates = _candidate_frameworks(universe, max_candidates=max_candidates) + if isinstance(candidates, EnumerationExceeded): + return candidates + + best_score: int | None = None + winners: list[ArgumentationFramework] = [] + for candidate in candidates: + score = max( + merge_framework_edit_distance(candidate, source_framework) + for source_framework in expanded.values() + ) + if best_score is None or score < best_score: + best_score = score + winners = [candidate] + elif score == best_score: + winners.append(candidate) + return sorted(winners, key=_framework_key) + + +def leximax_merge_frameworks( + profile: dict[str, ArgumentationFramework], + *, + max_candidates: int | None = None, +) -> list[ArgumentationFramework] | EnumerationExceeded: + """Return exact Leximax-minimizing AFs over the shared argument universe.""" + universe = _shared_universe(profile) + expanded = _expanded_profile(profile, universe) + max_winners = max_merge_frameworks(profile, max_candidates=max_candidates) + if isinstance(max_winners, EnumerationExceeded): + return max_winners + + best_vector: tuple[int, ...] | None = None + winners: list[ArgumentationFramework] = [] + for candidate in max_winners: + vector = tuple( + sorted( + ( + merge_framework_edit_distance(candidate, source_framework) + for source_framework in expanded.values() + ), + reverse=True, + ) + ) + if best_vector is None or vector < best_vector: + best_vector = vector + winners = [candidate] + elif vector == best_vector: + winners.append(candidate) + return sorted(winners, key=_framework_key) + + +__all__ = [ + "EnumerationExceeded", + "PairState", + "PartialArgumentationFramework", + "enumerate_completions", + "merge_framework_edit_distance", + "skeptically_accepted_arguments", + "credulously_accepted_arguments", + "consensual_expand", + "sum_merge_frameworks", + "max_merge_frameworks", + "leximax_merge_frameworks", +] diff --git a/src/argumentation/practical_reasoning.py b/src/argumentation/frameworks/practical_reasoning.py similarity index 100% rename from src/argumentation/practical_reasoning.py rename to src/argumentation/frameworks/practical_reasoning.py diff --git a/src/argumentation/setaf.py b/src/argumentation/frameworks/setaf.py similarity index 100% rename from src/argumentation/setaf.py rename to src/argumentation/frameworks/setaf.py diff --git a/src/argumentation/setaf_io.py b/src/argumentation/frameworks/setaf_io.py similarity index 98% rename from src/argumentation/setaf_io.py rename to src/argumentation/frameworks/setaf_io.py index 8fdb2f9..ce1c9ee 100644 --- a/src/argumentation/setaf_io.py +++ b/src/argumentation/frameworks/setaf_io.py @@ -9,7 +9,7 @@ import re -from argumentation.setaf import SETAF +from argumentation.frameworks.setaf import SETAF _ASPARTIX_FACT_RE = re.compile( diff --git a/src/argumentation/vaf.py b/src/argumentation/frameworks/vaf.py similarity index 96% rename from src/argumentation/vaf.py rename to src/argumentation/frameworks/vaf.py index 37698b6..fbd5003 100644 --- a/src/argumentation/vaf.py +++ b/src/argumentation/frameworks/vaf.py @@ -1,190 +1,190 @@ -"""Value-based argumentation frameworks. - -Bench-Capon 2003 extends Dung AFs with values and audience-specific value -orders. An attack succeeds for an audience exactly when the attacked argument's -value is not strictly preferred to the attacker's value. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import permutations -from typing import Mapping, Sequence - -from argumentation.dung import ArgumentationFramework, preferred_extensions - - -Audience = tuple[str, ...] - - -@dataclass(frozen=True) -class ValueBasedArgumentationFramework: - """A finite value-based argumentation framework. - - Bench-Capon 2003 p. 435, Definition 5.1: ``VAF=(AR, attacks, V, val, P)``. - If ``audiences`` is omitted, all total value orderings are considered for - objective and subjective acceptance. ``audience`` is the active ordering - used by ``successful_attacks``. - """ - - arguments: frozenset[str] - attacks: frozenset[tuple[str, str]] - values: frozenset[str] - valuation: Mapping[str, str] - audience: Audience | None = None - audiences: tuple[Audience, ...] | None = None - - def __post_init__(self) -> None: - arguments = frozenset(self.arguments) - values = frozenset(self.values) - attacks = frozenset((attacker, target) for attacker, target in self.attacks) - valuation = dict(self.valuation) - - if not values: - raise ValueError("values must be non-empty") - - unknown_attack_arguments = sorted( - (attacker, target) - for attacker, target in attacks - if attacker not in arguments or target not in arguments - ) - if unknown_attack_arguments: - raise ValueError( - "attacks must only contain framework arguments: " - f"{unknown_attack_arguments!r}" - ) - - missing_valuations = sorted(arguments - valuation.keys()) - extra_valuations = sorted(set(valuation) - arguments) - if missing_valuations or extra_valuations: - raise ValueError( - "valuation must map exactly the framework arguments: " - f"missing={missing_valuations!r}, extra={extra_valuations!r}" - ) - - unknown_values = sorted(value for value in valuation.values() if value not in values) - if unknown_values: - raise ValueError(f"valuation uses unknown values: {unknown_values!r}") - - normalized_audiences = ( - tuple(self._validate_audience(audience) for audience in self.audiences) - if self.audiences is not None - else None - ) - active_audience = self._validate_audience(self.audience) if self.audience else None - - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "attacks", attacks) - object.__setattr__(self, "values", values) - object.__setattr__(self, "valuation", valuation) - object.__setattr__(self, "audience", active_audience) - object.__setattr__(self, "audiences", normalized_audiences) - - def with_audience(self, audience: Sequence[str]) -> ValueBasedArgumentationFramework: - """Return the same VAF with a different active audience ordering.""" - - return ValueBasedArgumentationFramework( - arguments=self.arguments, - attacks=self.attacks, - values=self.values, - valuation=self.valuation, - audience=tuple(audience), - audiences=self.audiences, - ) - - def value_preferred(self, left: str, right: str, audience: Sequence[str] | None = None) -> bool: - """Return whether ``left`` is strictly preferred to ``right``.""" - - ordering = self._active_or_supplied_audience(audience) - ranking = {value: index for index, value in enumerate(ordering)} - return ranking[left] < ranking[right] - - def successful_attacks( - self, - audience: Sequence[str] | None = None, - ) -> frozenset[tuple[str, str]]: - """Return attacks that defeat under Bench-Capon's audience condition. - - Bench-Capon 2003 p. 436, Definition 5.3: ``A`` defeats ``B`` iff - ``A`` attacks ``B`` and ``val(B)`` is not preferred to ``val(A)``. - """ - - ordering = self._active_or_supplied_audience(audience) - ranking = {value: index for index, value in enumerate(ordering)} - defeats: set[tuple[str, str]] = set() - for attacker, target in self.attacks: - attacker_value = self.valuation[attacker] - target_value = self.valuation[target] - if ranking[target_value] >= ranking[attacker_value]: - defeats.add((attacker, target)) - return frozenset(defeats) - - def induced_framework(self, audience: Sequence[str] | None = None) -> ArgumentationFramework: - """Return the Dung AF induced by removing failing attacks.""" - - defeats = self.successful_attacks(audience) - return ArgumentationFramework( - arguments=self.arguments, - defeats=defeats, - ) - - def preferred_extensions_for_audience( - self, - audience: Sequence[str], - ) -> list[frozenset[str]]: - """Return preferred extensions for the audience-specific VAF.""" - - return preferred_extensions(self.induced_framework(audience)) - - def possible_audiences(self) -> tuple[Audience, ...]: - """Return explicit audiences or all total orders over the value set.""" - - if self.audiences is not None: - return self.audiences - return tuple(tuple(ordering) for ordering in permutations(sorted(self.values))) - - def objectively_acceptable(self) -> frozenset[str]: - """Arguments in every preferred extension for every audience. - - Bench-Capon 2003 p. 437, Definition 6.1. - """ - - objective = set(self.arguments) - for audience in self.possible_audiences(): - extensions = self.preferred_extensions_for_audience(audience) - if not extensions: - objective.clear() - break - accepted_by_audience = set.intersection(*(set(extension) for extension in extensions)) - objective &= accepted_by_audience - return frozenset(objective) - - def subjectively_acceptable(self) -> frozenset[str]: - """Arguments in at least one preferred extension for some audience. - - Bench-Capon 2003 p. 437, Definition 6.2. - """ - - subjective: set[str] = set() - for audience in self.possible_audiences(): - for extension in self.preferred_extensions_for_audience(audience): - subjective.update(extension) - return frozenset(subjective) - - def indefensible(self) -> frozenset[str]: - """Return arguments that are not subjectively acceptable.""" - - return self.arguments - self.subjectively_acceptable() - - def _active_or_supplied_audience(self, audience: Sequence[str] | None) -> Audience: - if audience is not None: - return self._validate_audience(audience) - if self.audience is None: - raise ValueError("an audience is required for audience-specific defeat") - return self.audience - - def _validate_audience(self, audience: Sequence[str]) -> Audience: - ordering = tuple(audience) - if frozenset(ordering) != self.values or len(ordering) != len(self.values): - raise ValueError("audience must be a total ordering of the VAF values") - return ordering +"""Value-based argumentation frameworks. + +Bench-Capon 2003 extends Dung AFs with values and audience-specific value +orders. An attack succeeds for an audience exactly when the attacked argument's +value is not strictly preferred to the attacker's value. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import permutations +from typing import Mapping, Sequence + +from argumentation.core.dung import ArgumentationFramework, preferred_extensions + + +Audience = tuple[str, ...] + + +@dataclass(frozen=True) +class ValueBasedArgumentationFramework: + """A finite value-based argumentation framework. + + Bench-Capon 2003 p. 435, Definition 5.1: ``VAF=(AR, attacks, V, val, P)``. + If ``audiences`` is omitted, all total value orderings are considered for + objective and subjective acceptance. ``audience`` is the active ordering + used by ``successful_attacks``. + """ + + arguments: frozenset[str] + attacks: frozenset[tuple[str, str]] + values: frozenset[str] + valuation: Mapping[str, str] + audience: Audience | None = None + audiences: tuple[Audience, ...] | None = None + + def __post_init__(self) -> None: + arguments = frozenset(self.arguments) + values = frozenset(self.values) + attacks = frozenset((attacker, target) for attacker, target in self.attacks) + valuation = dict(self.valuation) + + if not values: + raise ValueError("values must be non-empty") + + unknown_attack_arguments = sorted( + (attacker, target) + for attacker, target in attacks + if attacker not in arguments or target not in arguments + ) + if unknown_attack_arguments: + raise ValueError( + "attacks must only contain framework arguments: " + f"{unknown_attack_arguments!r}" + ) + + missing_valuations = sorted(arguments - valuation.keys()) + extra_valuations = sorted(set(valuation) - arguments) + if missing_valuations or extra_valuations: + raise ValueError( + "valuation must map exactly the framework arguments: " + f"missing={missing_valuations!r}, extra={extra_valuations!r}" + ) + + unknown_values = sorted(value for value in valuation.values() if value not in values) + if unknown_values: + raise ValueError(f"valuation uses unknown values: {unknown_values!r}") + + normalized_audiences = ( + tuple(self._validate_audience(audience) for audience in self.audiences) + if self.audiences is not None + else None + ) + active_audience = self._validate_audience(self.audience) if self.audience else None + + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "attacks", attacks) + object.__setattr__(self, "values", values) + object.__setattr__(self, "valuation", valuation) + object.__setattr__(self, "audience", active_audience) + object.__setattr__(self, "audiences", normalized_audiences) + + def with_audience(self, audience: Sequence[str]) -> ValueBasedArgumentationFramework: + """Return the same VAF with a different active audience ordering.""" + + return ValueBasedArgumentationFramework( + arguments=self.arguments, + attacks=self.attacks, + values=self.values, + valuation=self.valuation, + audience=tuple(audience), + audiences=self.audiences, + ) + + def value_preferred(self, left: str, right: str, audience: Sequence[str] | None = None) -> bool: + """Return whether ``left`` is strictly preferred to ``right``.""" + + ordering = self._active_or_supplied_audience(audience) + ranking = {value: index for index, value in enumerate(ordering)} + return ranking[left] < ranking[right] + + def successful_attacks( + self, + audience: Sequence[str] | None = None, + ) -> frozenset[tuple[str, str]]: + """Return attacks that defeat under Bench-Capon's audience condition. + + Bench-Capon 2003 p. 436, Definition 5.3: ``A`` defeats ``B`` iff + ``A`` attacks ``B`` and ``val(B)`` is not preferred to ``val(A)``. + """ + + ordering = self._active_or_supplied_audience(audience) + ranking = {value: index for index, value in enumerate(ordering)} + defeats: set[tuple[str, str]] = set() + for attacker, target in self.attacks: + attacker_value = self.valuation[attacker] + target_value = self.valuation[target] + if ranking[target_value] >= ranking[attacker_value]: + defeats.add((attacker, target)) + return frozenset(defeats) + + def induced_framework(self, audience: Sequence[str] | None = None) -> ArgumentationFramework: + """Return the Dung AF induced by removing failing attacks.""" + + defeats = self.successful_attacks(audience) + return ArgumentationFramework( + arguments=self.arguments, + defeats=defeats, + ) + + def preferred_extensions_for_audience( + self, + audience: Sequence[str], + ) -> list[frozenset[str]]: + """Return preferred extensions for the audience-specific VAF.""" + + return preferred_extensions(self.induced_framework(audience)) + + def possible_audiences(self) -> tuple[Audience, ...]: + """Return explicit audiences or all total orders over the value set.""" + + if self.audiences is not None: + return self.audiences + return tuple(tuple(ordering) for ordering in permutations(sorted(self.values))) + + def objectively_acceptable(self) -> frozenset[str]: + """Arguments in every preferred extension for every audience. + + Bench-Capon 2003 p. 437, Definition 6.1. + """ + + objective = set(self.arguments) + for audience in self.possible_audiences(): + extensions = self.preferred_extensions_for_audience(audience) + if not extensions: + objective.clear() + break + accepted_by_audience = set.intersection(*(set(extension) for extension in extensions)) + objective &= accepted_by_audience + return frozenset(objective) + + def subjectively_acceptable(self) -> frozenset[str]: + """Arguments in at least one preferred extension for some audience. + + Bench-Capon 2003 p. 437, Definition 6.2. + """ + + subjective: set[str] = set() + for audience in self.possible_audiences(): + for extension in self.preferred_extensions_for_audience(audience): + subjective.update(extension) + return frozenset(subjective) + + def indefensible(self) -> frozenset[str]: + """Return arguments that are not subjectively acceptable.""" + + return self.arguments - self.subjectively_acceptable() + + def _active_or_supplied_audience(self, audience: Sequence[str] | None) -> Audience: + if audience is not None: + return self._validate_audience(audience) + if self.audience is None: + raise ValueError("an audience is required for audience-specific defeat") + return self.audience + + def _validate_audience(self, audience: Sequence[str]) -> Audience: + ordering = tuple(audience) + if frozenset(ordering) != self.values or len(ordering) != len(self.values): + raise ValueError("audience must be a total ordering of the VAF values") + return ordering diff --git a/src/argumentation/vaf_completion.py b/src/argumentation/frameworks/vaf_completion.py similarity index 99% rename from src/argumentation/vaf_completion.py rename to src/argumentation/frameworks/vaf_completion.py index 976a077..0d1582f 100644 --- a/src/argumentation/vaf_completion.py +++ b/src/argumentation/frameworks/vaf_completion.py @@ -7,7 +7,7 @@ from itertools import permutations from typing import Sequence -from argumentation.vaf import Audience, ValueBasedArgumentationFramework +from argumentation.frameworks.vaf import Audience, ValueBasedArgumentationFramework FACT_VALUE = "fact" diff --git a/src/argumentation/gradual/__init__.py b/src/argumentation/gradual/__init__.py new file mode 100644 index 0000000..a43e569 --- /dev/null +++ b/src/argumentation/gradual/__init__.py @@ -0,0 +1 @@ +"""Gradual and quantitative bipolar argumentation (QBAF) semantics.""" diff --git a/src/argumentation/dfquad.py b/src/argumentation/gradual/dfquad.py similarity index 98% rename from src/argumentation/dfquad.py rename to src/argumentation/gradual/dfquad.py index 84bfcf5..113af7b 100644 --- a/src/argumentation/dfquad.py +++ b/src/argumentation/gradual/dfquad.py @@ -6,7 +6,7 @@ from collections import deque from collections.abc import Mapping -from argumentation.gradual import GradualStrengthResult, WeightedBipolarGraph +from argumentation.gradual.gradual import GradualStrengthResult, WeightedBipolarGraph def dfquad_aggregate(base_score: float, combined_influence: float) -> float: diff --git a/src/argumentation/equational.py b/src/argumentation/gradual/equational.py similarity index 97% rename from src/argumentation/equational.py rename to src/argumentation/gradual/equational.py index 711e155..307d89d 100644 --- a/src/argumentation/equational.py +++ b/src/argumentation/gradual/equational.py @@ -5,7 +5,7 @@ from collections.abc import Iterable from typing import Literal -from argumentation.gradual import GradualStrengthResult, WeightedBipolarGraph +from argumentation.gradual.gradual import GradualStrengthResult, WeightedBipolarGraph EquationScheme = Literal["inverse", "max", "min"] diff --git a/src/argumentation/gradual.py b/src/argumentation/gradual/gradual.py similarity index 100% rename from src/argumentation/gradual.py rename to src/argumentation/gradual/gradual.py diff --git a/src/argumentation/gradual_principles.py b/src/argumentation/gradual/gradual_principles.py similarity index 98% rename from src/argumentation/gradual_principles.py rename to src/argumentation/gradual/gradual_principles.py index 3f12359..ab95ffd 100644 --- a/src/argumentation/gradual_principles.py +++ b/src/argumentation/gradual/gradual_principles.py @@ -5,7 +5,7 @@ from collections.abc import Callable from enum import Enum -from argumentation.gradual import WeightedBipolarGraph +from argumentation.gradual.gradual import WeightedBipolarGraph StrengthFunction = Callable[[WeightedBipolarGraph], dict[str, float]] diff --git a/src/argumentation/llm_surface.py b/src/argumentation/gradual/llm_surface.py similarity index 99% rename from src/argumentation/llm_surface.py rename to src/argumentation/gradual/llm_surface.py index 83fcf19..d9ed574 100644 --- a/src/argumentation/llm_surface.py +++ b/src/argumentation/gradual/llm_surface.py @@ -20,7 +20,7 @@ from dataclasses import dataclass from typing import Literal, Mapping -from argumentation.gradual import ( +from argumentation.gradual.gradual import ( ShapleyAttackImpactResult, WeightedBipolarGraph, quadratic_energy_strengths, diff --git a/src/argumentation/sensitivity.py b/src/argumentation/gradual/sensitivity.py similarity index 91% rename from src/argumentation/sensitivity.py rename to src/argumentation/gradual/sensitivity.py index 893e4a4..90a9f50 100644 --- a/src/argumentation/sensitivity.py +++ b/src/argumentation/gradual/sensitivity.py @@ -1,119 +1,119 @@ -"""Sensitivity and importance analysis of argumentation-framework outputs. - -These functions measure how much a framework's accepted set or gradual -strengths *change* when one element is removed. They quantify the local -importance of an argument or an attack to the framework's verdict. -""" - -from __future__ import annotations - -from argumentation.dung import ArgumentationFramework, grounded_extension -from argumentation.dfquad import dfquad_strengths -from argumentation.gradual import WeightedBipolarGraph - - -def score_conflict( - framework: ArgumentationFramework, - claim_a_id: str, - claim_b_id: str, - *, - semantics: str = "grounded", -) -> float: - """Score how much two arguments swing the accepted extension. - - For each of ``claim_a_id`` and ``claim_b_id``, the argument (and every - defeat touching it) is removed and the grounded extension is recomputed. - The symmetric difference between the original and the reduced extension - measures how many acceptance verdicts that removal flips. The returned - value is the larger of the two normalized swing counts, clamped to - ``[0, 1]``. - - A value of ``0.0`` means removing either argument leaves every other - argument's acceptance unchanged; a value near ``1.0`` means one of them - is pivotal for almost the whole framework. - - Only ``semantics="grounded"`` is supported; any other value raises - ``ValueError``. - """ - if semantics != "grounded": - raise ValueError(f"Unsupported semantics: {semantics!r}") - if not framework.arguments: - return 0.0 - - total = len(framework.arguments) - current = grounded_extension(framework) - - def _remove(arg_id: str) -> frozenset[str]: - reduced = ArgumentationFramework( - arguments=frozenset( - argument for argument in framework.arguments if argument != arg_id - ), - defeats=frozenset( - (attacker, target) - for attacker, target in framework.defeats - if attacker != arg_id and target != arg_id - ), - ) - return grounded_extension(reduced) - - ext_remove_a = _remove(claim_a_id) - ext_remove_b = _remove(claim_b_id) - dist_a = len(current.symmetric_difference(ext_remove_a)) - dist_b = len(current.symmetric_difference(ext_remove_b)) - return min(1.0, max(dist_a, dist_b) / total) - - -def attack_removal_sensitivity( - framework: ArgumentationFramework, - supports: dict[tuple[str, str], float], - base_scores: dict[str, float], - attack: tuple[str, str], -) -> float: - """Measure the DF-QuAD strength swing of an attack's target when removed. - - The DF-QuAD strengths of every argument are computed once with ``attack`` - present and once with ``attack`` removed. The returned number is the - strength delta of the *attacked* argument (``attack[1]``): - - strength(target) without the attack - strength(target) with it. - - Because the attack suppresses its target, removing it normally *raises* - the target's strength, so the result is typically non-negative; it is the - amount of strength the target loses purely because this attack exists. - If ``attack`` is not a defeat of ``framework``, the result is ``0.0``. - - ``supports`` maps support edges to their weights and ``base_scores`` gives - each argument's base score; both are passed straight through to - :func:`argumentation.dfquad.dfquad_strengths`. - """ - if attack not in framework.defeats: - return 0.0 - - graph = WeightedBipolarGraph( - arguments=framework.arguments, - initial_weights=base_scores, - attacks=framework.defeats, - supports=frozenset(supports), - ) - strengths_full = dfquad_strengths( - graph, - base_scores=base_scores, - support_weights=supports, - ).strengths - - reduced_framework = ArgumentationFramework( - arguments=framework.arguments, - defeats=frozenset(defeat for defeat in framework.defeats if defeat != attack), - ) - reduced_graph = WeightedBipolarGraph( - arguments=reduced_framework.arguments, - initial_weights=base_scores, - attacks=reduced_framework.defeats, - supports=frozenset(supports), - ) - strengths_reduced = dfquad_strengths( - reduced_graph, - base_scores=base_scores, - support_weights=supports, - ).strengths - return strengths_reduced[attack[1]] - strengths_full[attack[1]] +"""Sensitivity and importance analysis of argumentation-framework outputs. + +These functions measure how much a framework's accepted set or gradual +strengths *change* when one element is removed. They quantify the local +importance of an argument or an attack to the framework's verdict. +""" + +from __future__ import annotations + +from argumentation.core.dung import ArgumentationFramework, grounded_extension +from argumentation.gradual.dfquad import dfquad_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph + + +def score_conflict( + framework: ArgumentationFramework, + claim_a_id: str, + claim_b_id: str, + *, + semantics: str = "grounded", +) -> float: + """Score how much two arguments swing the accepted extension. + + For each of ``claim_a_id`` and ``claim_b_id``, the argument (and every + defeat touching it) is removed and the grounded extension is recomputed. + The symmetric difference between the original and the reduced extension + measures how many acceptance verdicts that removal flips. The returned + value is the larger of the two normalized swing counts, clamped to + ``[0, 1]``. + + A value of ``0.0`` means removing either argument leaves every other + argument's acceptance unchanged; a value near ``1.0`` means one of them + is pivotal for almost the whole framework. + + Only ``semantics="grounded"`` is supported; any other value raises + ``ValueError``. + """ + if semantics != "grounded": + raise ValueError(f"Unsupported semantics: {semantics!r}") + if not framework.arguments: + return 0.0 + + total = len(framework.arguments) + current = grounded_extension(framework) + + def _remove(arg_id: str) -> frozenset[str]: + reduced = ArgumentationFramework( + arguments=frozenset( + argument for argument in framework.arguments if argument != arg_id + ), + defeats=frozenset( + (attacker, target) + for attacker, target in framework.defeats + if attacker != arg_id and target != arg_id + ), + ) + return grounded_extension(reduced) + + ext_remove_a = _remove(claim_a_id) + ext_remove_b = _remove(claim_b_id) + dist_a = len(current.symmetric_difference(ext_remove_a)) + dist_b = len(current.symmetric_difference(ext_remove_b)) + return min(1.0, max(dist_a, dist_b) / total) + + +def attack_removal_sensitivity( + framework: ArgumentationFramework, + supports: dict[tuple[str, str], float], + base_scores: dict[str, float], + attack: tuple[str, str], +) -> float: + """Measure the DF-QuAD strength swing of an attack's target when removed. + + The DF-QuAD strengths of every argument are computed once with ``attack`` + present and once with ``attack`` removed. The returned number is the + strength delta of the *attacked* argument (``attack[1]``): + + strength(target) without the attack - strength(target) with it. + + Because the attack suppresses its target, removing it normally *raises* + the target's strength, so the result is typically non-negative; it is the + amount of strength the target loses purely because this attack exists. + If ``attack`` is not a defeat of ``framework``, the result is ``0.0``. + + ``supports`` maps support edges to their weights and ``base_scores`` gives + each argument's base score; both are passed straight through to + :func:`argumentation.gradual.dfquad.dfquad_strengths`. + """ + if attack not in framework.defeats: + return 0.0 + + graph = WeightedBipolarGraph( + arguments=framework.arguments, + initial_weights=base_scores, + attacks=framework.defeats, + supports=frozenset(supports), + ) + strengths_full = dfquad_strengths( + graph, + base_scores=base_scores, + support_weights=supports, + ).strengths + + reduced_framework = ArgumentationFramework( + arguments=framework.arguments, + defeats=frozenset(defeat for defeat in framework.defeats if defeat != attack), + ) + reduced_graph = WeightedBipolarGraph( + arguments=reduced_framework.arguments, + initial_weights=base_scores, + attacks=reduced_framework.defeats, + supports=frozenset(supports), + ) + strengths_reduced = dfquad_strengths( + reduced_graph, + base_scores=base_scores, + support_weights=supports, + ).strengths + return strengths_reduced[attack[1]] - strengths_full[attack[1]] diff --git a/src/argumentation/interop/__init__.py b/src/argumentation/interop/__init__.py new file mode 100644 index 0000000..266023d --- /dev/null +++ b/src/argumentation/interop/__init__.py @@ -0,0 +1 @@ +"""Interop layer: external format parsing and serialisation.""" diff --git a/src/argumentation/iccma.py b/src/argumentation/interop/iccma.py similarity index 95% rename from src/argumentation/iccma.py rename to src/argumentation/interop/iccma.py index 7129619..fa662a6 100644 --- a/src/argumentation/iccma.py +++ b/src/argumentation/interop/iccma.py @@ -1,379 +1,379 @@ -"""ICCMA-style argumentation framework I/O.""" - -from __future__ import annotations - -import re - -from argumentation.adf import ( - AcceptanceCondition, - AbstractDialecticalFramework, - parse_iccma_formula, - write_iccma_formula, -) -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.dung import ArgumentationFramework - -APX_ARG_RE = re.compile(r"arg\(([^)]+)\)\.") -APX_ATT_RE = re.compile(r"att\(([^,]+),([^)]+)\)\.") - - -def parse_af(text: str) -> ArgumentationFramework: - """Parse the ICCMA ``p af n`` numeric AF format.""" - argument_count: int | None = None - attacks: set[tuple[str, str]] = set() - - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts[:2] == ["p", "af"]: - if argument_count is not None: - raise ValueError("multiple p af header lines") - if len(parts) != 3 or not parts[2].isdigit(): - raise ValueError("p af header must be: p af ") - argument_count = int(parts[2]) - continue - if argument_count is None: - raise ValueError("ICCMA AF input must start with a p af header") - if len(parts) != 2 or not all(part.isdigit() for part in parts): - raise ValueError(f"attack line {line_number} must contain two numeric ids") - attacker, target = parts - _validate_attack_id(attacker, argument_count, line_number) - _validate_attack_id(target, argument_count, line_number) - attacks.add((attacker, target)) - - if argument_count is None: - raise ValueError("ICCMA AF input must include a p af header") - - arguments = frozenset(str(index) for index in range(1, argument_count + 1)) - return ArgumentationFramework(arguments=arguments, defeats=frozenset(attacks)) - - -def write_af(framework: ArgumentationFramework) -> str: - """Write a framework in deterministic ICCMA ``p af n`` format.""" - argument_ids = _numeric_argument_ids(framework) - expected = list(range(1, len(argument_ids) + 1)) - if argument_ids != expected: - raise ValueError("ICCMA AF arguments must be numeric ids 1..n") - - lines = [f"p af {len(argument_ids)}"] - for attacker, target in sorted( - framework.defeats, - key=lambda attack: (int(attack[0]), int(attack[1])), - ): - lines.append(f"{attacker} {target}") - return "\n".join(lines) + "\n" - - -def parse_apx(text: str) -> ArgumentationFramework: - """Parse ASPARTIX APX ``arg``/``att`` facts into a Dung AF.""" - arguments: set[str] = set() - attacks: set[tuple[str, str]] = set() - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith(("%", "#")): - continue - arg_match = APX_ARG_RE.fullmatch(line) - if arg_match: - arguments.add(arg_match.group(1)) - continue - att_match = APX_ATT_RE.fullmatch(line) - if att_match: - attacker, target = att_match.groups() - arguments.add(attacker) - arguments.add(target) - attacks.add((attacker, target)) - continue - raise ValueError(f"invalid APX line {line_number}: {line!r}") - return ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(attacks), - ) - - -def parse_tgf(text: str) -> ArgumentationFramework: - """Parse Trivial Graph Format into a Dung AF.""" - arguments: set[str] = set() - attacks: set[tuple[str, str]] = set() - in_attacks = False - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line: - continue - if line == "#": - in_attacks = True - continue - if not in_attacks: - arguments.add(line.split(maxsplit=1)[0]) - continue - parts = line.split() - if len(parts) != 2: - raise ValueError(f"invalid TGF attack line {line_number}: {line!r}") - attacker, target = parts - arguments.add(attacker) - arguments.add(target) - attacks.add((attacker, target)) - if not in_attacks: - raise ValueError("TGF input must contain # separator") - return ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(attacks), - ) - - -def parse_adf(text: str) -> AbstractDialecticalFramework: - """Parse a compact ICCMA-style ``p adf`` text format.""" - statements: set[str] = set() - links: set[tuple[str, str]] = set() - conditions: dict[str, AcceptanceCondition] = {} - seen_header = False - - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split(maxsplit=2) - if parts[:2] == ["p", "adf"]: - if seen_header: - raise ValueError("multiple p adf header lines") - seen_header = True - continue - if not seen_header: - raise ValueError("ICCMA ADF input must start with a p adf header") - if parts[0] == "s" and len(parts) == 2: - statements.add(parts[1]) - continue - if parts[0] == "l" and len(parts) == 3: - links.add((parts[1], parts[2])) - continue - if parts[0] == "c" and len(parts) == 3: - conditions[parts[1]] = parse_iccma_formula(parts[2]) - continue - raise ValueError(f"invalid ADF line {line_number}: {line!r}") - if not seen_header: - raise ValueError("ICCMA ADF input must include a p adf header") - return AbstractDialecticalFramework( - statements=frozenset(statements), - links=frozenset(links), - acceptance_conditions=conditions, - ) - - -def write_adf(framework: AbstractDialecticalFramework) -> str: - """Write a deterministic compact ICCMA-style ``p adf`` text format.""" - lines = ["p adf"] - for statement in sorted(framework.statements): - lines.append(f"s {statement}") - for parent, child in sorted(framework.links): - lines.append(f"l {parent} {child}") - for statement in sorted(framework.statements): - lines.append( - f"c {statement} {write_iccma_formula(framework.acceptance_conditions[statement])}" - ) - return "\n".join(lines) + "\n" - - -def parse_aba(text: str) -> ABAFramework: - """Parse an ICCMA flat-ABA text format. - - ICCMA 2025 uses the official numeric ``p aba `` format. The legacy - compact package-local ``p aba`` format is still accepted for existing - fixtures. - """ - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts[:2] == ["p", "aba"] and len(parts) == 3: - return _parse_numeric_aba(text) - if parts == ["p", "aba"]: - return _parse_compact_aba(text) - break - raise ValueError("ICCMA ABA input must start with a p aba header") - - -def _parse_compact_aba(text: str) -> ABAFramework: - """Parse a compact package-local ``p aba`` flat-ABA text format.""" - atoms: dict[str, Literal] = {} - assumptions: set[Literal] = set() - contraries: dict[Literal, Literal] = {} - rules: set[Rule] = set() - seen_header = False - - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts == ["p", "aba"]: - if seen_header: - raise ValueError("multiple p aba header lines") - seen_header = True - continue - if not seen_header: - raise ValueError("ICCMA ABA input must start with a p aba header") - if parts[0] == "a" and len(parts) == 2: - assumptions.add(_aba_literal(atoms, parts[1])) - continue - if parts[0] == "c" and len(parts) == 3: - contraries[_aba_literal(atoms, parts[1])] = _aba_literal(atoms, parts[2]) - continue - if parts[0] == "r" and len(parts) >= 2: - rules.add( - Rule( - tuple(_aba_literal(atoms, item) for item in parts[2:]), - _aba_literal(atoms, parts[1]), - "strict", - ) - ) - continue - raise ValueError(f"invalid ABA line {line_number}: {line!r}") - if not seen_header: - raise ValueError("ICCMA ABA input must include a p aba header") - language = frozenset(set(atoms.values()) | assumptions | set(contraries.values())) - return ABAFramework( - language=language, - rules=frozenset(rules), - assumptions=frozenset(assumptions), - contrary=contraries, - ) - - -def _parse_numeric_aba(text: str) -> ABAFramework: - atom_count: int | None = None - atoms: dict[str, Literal] = {} - assumptions: set[Literal] = set() - contraries: dict[Literal, Literal] = {} - rules: set[Rule] = set() - - for line_number, raw_line in enumerate(text.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts[:2] == ["p", "aba"]: - if atom_count is not None: - raise ValueError("multiple p aba header lines") - if len(parts) != 3 or not parts[2].isdigit(): - raise ValueError("p aba header must be: p aba ") - atom_count = int(parts[2]) - for index in range(1, atom_count + 1): - _aba_literal(atoms, str(index)) - continue - if atom_count is None: - raise ValueError("ICCMA ABA input must start with a p aba header") - if parts[0] == "a" and len(parts) == 2: - assumptions.add(_aba_numeric_literal(atoms, parts[1], atom_count, line_number)) - continue - if parts[0] == "c" and len(parts) == 3: - contraries[ - _aba_numeric_literal(atoms, parts[1], atom_count, line_number) - ] = _aba_numeric_literal(atoms, parts[2], atom_count, line_number) - continue - if parts[0] == "r" and len(parts) >= 2: - rules.add( - Rule( - tuple( - _aba_numeric_literal(atoms, item, atom_count, line_number) - for item in parts[2:] - ), - _aba_numeric_literal(atoms, parts[1], atom_count, line_number), - "strict", - ) - ) - continue - raise ValueError(f"invalid ABA line {line_number}: {line!r}") - if atom_count is None: - raise ValueError("ICCMA ABA input must include a p aba header") - return ABAFramework( - language=frozenset(atoms.values()), - rules=frozenset(rules), - assumptions=frozenset(assumptions), - contrary=contraries, - ) - - -def write_aba(framework: ABAFramework) -> str: - """Write a deterministic compact ICCMA-style ``p aba`` flat-ABA format.""" - lines = ["p aba"] - for assumption in sorted(framework.assumptions, key=repr): - lines.append(f"a {_aba_name(assumption)}") - for assumption, contrary in sorted(framework.contrary.items(), key=lambda item: repr(item[0])): - lines.append(f"c {_aba_name(assumption)} {_aba_name(contrary)}") - for rule in sorted(framework.rules, key=lambda item: (_aba_name(item.consequent), tuple(map(_aba_name, item.antecedents)))): - body = " ".join(_aba_name(antecedent) for antecedent in rule.antecedents) - lines.append(f"r {_aba_name(rule.consequent)}" + (f" {body}" if body else "")) - return "\n".join(lines) + "\n" - - -def write_numeric_aba(framework: ABAFramework) -> str: - """Write the official numeric ICCMA 2025 ``p aba `` flat-ABA format.""" - literals = sorted(framework.language, key=repr) - ids = {literal: str(index) for index, literal in enumerate(literals, start=1)} - lines = [f"p aba {len(literals)}"] - for assumption in sorted(framework.assumptions, key=repr): - lines.append(f"a {ids[assumption]}") - for assumption, contrary in sorted(framework.contrary.items(), key=lambda item: repr(item[0])): - lines.append(f"c {ids[assumption]} {ids[contrary]}") - for rule in sorted(framework.rules, key=lambda item: (repr(item.consequent), tuple(map(repr, item.antecedents)))): - body = " ".join(ids[antecedent] for antecedent in rule.antecedents) - lines.append(f"r {ids[rule.consequent]}" + (f" {body}" if body else "")) - return "\n".join(lines) + "\n" - - -def _validate_attack_id(value: str, argument_count: int, line_number: int) -> None: - numeric = int(value) - if numeric < 1 or numeric > argument_count: - raise ValueError( - f"attack line {line_number} references argument outside 1..{argument_count}" - ) - - -def _numeric_argument_ids(framework: ArgumentationFramework) -> list[int]: - if not all(argument.isdigit() for argument in framework.arguments): - raise ValueError("ICCMA AF arguments must be numeric ids") - return sorted(int(argument) for argument in framework.arguments) - - -def _aba_literal(atoms: dict[str, Literal], name: str) -> Literal: - if name not in atoms: - atoms[name] = Literal(GroundAtom(name)) - return atoms[name] - - -def _aba_numeric_literal( - atoms: dict[str, Literal], - name: str, - atom_count: int, - line_number: int, -) -> Literal: - if not name.isdigit(): - raise ValueError(f"ABA line {line_number} must contain numeric atom ids") - numeric = int(name) - if numeric < 1 or numeric > atom_count: - raise ValueError( - f"ABA line {line_number} references atom outside 1..{atom_count}" - ) - return atoms[name] - - -def _aba_name(literal: Literal) -> str: - if literal.negated or literal.atom.arguments: - raise ValueError("compact ABA ICCMA format supports only nullary positive literals") - return literal.atom.predicate - - -__all__ = [ - "parse_aba", - "parse_adf", - "parse_apx", - "parse_af", - "parse_tgf", - "write_aba", - "write_adf", - "write_af", - "write_numeric_aba", -] +"""ICCMA-style argumentation framework I/O.""" + +from __future__ import annotations + +import re + +from argumentation.frameworks.adf import ( + AcceptanceCondition, + AbstractDialecticalFramework, + parse_iccma_formula, + write_iccma_formula, +) +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.core.dung import ArgumentationFramework + +APX_ARG_RE = re.compile(r"arg\(([^)]+)\)\.") +APX_ATT_RE = re.compile(r"att\(([^,]+),([^)]+)\)\.") + + +def parse_af(text: str) -> ArgumentationFramework: + """Parse the ICCMA ``p af n`` numeric AF format.""" + argument_count: int | None = None + attacks: set[tuple[str, str]] = set() + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[:2] == ["p", "af"]: + if argument_count is not None: + raise ValueError("multiple p af header lines") + if len(parts) != 3 or not parts[2].isdigit(): + raise ValueError("p af header must be: p af ") + argument_count = int(parts[2]) + continue + if argument_count is None: + raise ValueError("ICCMA AF input must start with a p af header") + if len(parts) != 2 or not all(part.isdigit() for part in parts): + raise ValueError(f"attack line {line_number} must contain two numeric ids") + attacker, target = parts + _validate_attack_id(attacker, argument_count, line_number) + _validate_attack_id(target, argument_count, line_number) + attacks.add((attacker, target)) + + if argument_count is None: + raise ValueError("ICCMA AF input must include a p af header") + + arguments = frozenset(str(index) for index in range(1, argument_count + 1)) + return ArgumentationFramework(arguments=arguments, defeats=frozenset(attacks)) + + +def write_af(framework: ArgumentationFramework) -> str: + """Write a framework in deterministic ICCMA ``p af n`` format.""" + argument_ids = _numeric_argument_ids(framework) + expected = list(range(1, len(argument_ids) + 1)) + if argument_ids != expected: + raise ValueError("ICCMA AF arguments must be numeric ids 1..n") + + lines = [f"p af {len(argument_ids)}"] + for attacker, target in sorted( + framework.defeats, + key=lambda attack: (int(attack[0]), int(attack[1])), + ): + lines.append(f"{attacker} {target}") + return "\n".join(lines) + "\n" + + +def parse_apx(text: str) -> ArgumentationFramework: + """Parse ASPARTIX APX ``arg``/``att`` facts into a Dung AF.""" + arguments: set[str] = set() + attacks: set[tuple[str, str]] = set() + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith(("%", "#")): + continue + arg_match = APX_ARG_RE.fullmatch(line) + if arg_match: + arguments.add(arg_match.group(1)) + continue + att_match = APX_ATT_RE.fullmatch(line) + if att_match: + attacker, target = att_match.groups() + arguments.add(attacker) + arguments.add(target) + attacks.add((attacker, target)) + continue + raise ValueError(f"invalid APX line {line_number}: {line!r}") + return ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(attacks), + ) + + +def parse_tgf(text: str) -> ArgumentationFramework: + """Parse Trivial Graph Format into a Dung AF.""" + arguments: set[str] = set() + attacks: set[tuple[str, str]] = set() + in_attacks = False + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + if line == "#": + in_attacks = True + continue + if not in_attacks: + arguments.add(line.split(maxsplit=1)[0]) + continue + parts = line.split() + if len(parts) != 2: + raise ValueError(f"invalid TGF attack line {line_number}: {line!r}") + attacker, target = parts + arguments.add(attacker) + arguments.add(target) + attacks.add((attacker, target)) + if not in_attacks: + raise ValueError("TGF input must contain # separator") + return ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(attacks), + ) + + +def parse_adf(text: str) -> AbstractDialecticalFramework: + """Parse a compact ICCMA-style ``p adf`` text format.""" + statements: set[str] = set() + links: set[tuple[str, str]] = set() + conditions: dict[str, AcceptanceCondition] = {} + seen_header = False + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(maxsplit=2) + if parts[:2] == ["p", "adf"]: + if seen_header: + raise ValueError("multiple p adf header lines") + seen_header = True + continue + if not seen_header: + raise ValueError("ICCMA ADF input must start with a p adf header") + if parts[0] == "s" and len(parts) == 2: + statements.add(parts[1]) + continue + if parts[0] == "l" and len(parts) == 3: + links.add((parts[1], parts[2])) + continue + if parts[0] == "c" and len(parts) == 3: + conditions[parts[1]] = parse_iccma_formula(parts[2]) + continue + raise ValueError(f"invalid ADF line {line_number}: {line!r}") + if not seen_header: + raise ValueError("ICCMA ADF input must include a p adf header") + return AbstractDialecticalFramework( + statements=frozenset(statements), + links=frozenset(links), + acceptance_conditions=conditions, + ) + + +def write_adf(framework: AbstractDialecticalFramework) -> str: + """Write a deterministic compact ICCMA-style ``p adf`` text format.""" + lines = ["p adf"] + for statement in sorted(framework.statements): + lines.append(f"s {statement}") + for parent, child in sorted(framework.links): + lines.append(f"l {parent} {child}") + for statement in sorted(framework.statements): + lines.append( + f"c {statement} {write_iccma_formula(framework.acceptance_conditions[statement])}" + ) + return "\n".join(lines) + "\n" + + +def parse_aba(text: str) -> ABAFramework: + """Parse an ICCMA flat-ABA text format. + + ICCMA 2025 uses the official numeric ``p aba `` format. The legacy + compact package-local ``p aba`` format is still accepted for existing + fixtures. + """ + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[:2] == ["p", "aba"] and len(parts) == 3: + return _parse_numeric_aba(text) + if parts == ["p", "aba"]: + return _parse_compact_aba(text) + break + raise ValueError("ICCMA ABA input must start with a p aba header") + + +def _parse_compact_aba(text: str) -> ABAFramework: + """Parse a compact package-local ``p aba`` flat-ABA text format.""" + atoms: dict[str, Literal] = {} + assumptions: set[Literal] = set() + contraries: dict[Literal, Literal] = {} + rules: set[Rule] = set() + seen_header = False + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts == ["p", "aba"]: + if seen_header: + raise ValueError("multiple p aba header lines") + seen_header = True + continue + if not seen_header: + raise ValueError("ICCMA ABA input must start with a p aba header") + if parts[0] == "a" and len(parts) == 2: + assumptions.add(_aba_literal(atoms, parts[1])) + continue + if parts[0] == "c" and len(parts) == 3: + contraries[_aba_literal(atoms, parts[1])] = _aba_literal(atoms, parts[2]) + continue + if parts[0] == "r" and len(parts) >= 2: + rules.add( + Rule( + tuple(_aba_literal(atoms, item) for item in parts[2:]), + _aba_literal(atoms, parts[1]), + "strict", + ) + ) + continue + raise ValueError(f"invalid ABA line {line_number}: {line!r}") + if not seen_header: + raise ValueError("ICCMA ABA input must include a p aba header") + language = frozenset(set(atoms.values()) | assumptions | set(contraries.values())) + return ABAFramework( + language=language, + rules=frozenset(rules), + assumptions=frozenset(assumptions), + contrary=contraries, + ) + + +def _parse_numeric_aba(text: str) -> ABAFramework: + atom_count: int | None = None + atoms: dict[str, Literal] = {} + assumptions: set[Literal] = set() + contraries: dict[Literal, Literal] = {} + rules: set[Rule] = set() + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[:2] == ["p", "aba"]: + if atom_count is not None: + raise ValueError("multiple p aba header lines") + if len(parts) != 3 or not parts[2].isdigit(): + raise ValueError("p aba header must be: p aba ") + atom_count = int(parts[2]) + for index in range(1, atom_count + 1): + _aba_literal(atoms, str(index)) + continue + if atom_count is None: + raise ValueError("ICCMA ABA input must start with a p aba header") + if parts[0] == "a" and len(parts) == 2: + assumptions.add(_aba_numeric_literal(atoms, parts[1], atom_count, line_number)) + continue + if parts[0] == "c" and len(parts) == 3: + contraries[ + _aba_numeric_literal(atoms, parts[1], atom_count, line_number) + ] = _aba_numeric_literal(atoms, parts[2], atom_count, line_number) + continue + if parts[0] == "r" and len(parts) >= 2: + rules.add( + Rule( + tuple( + _aba_numeric_literal(atoms, item, atom_count, line_number) + for item in parts[2:] + ), + _aba_numeric_literal(atoms, parts[1], atom_count, line_number), + "strict", + ) + ) + continue + raise ValueError(f"invalid ABA line {line_number}: {line!r}") + if atom_count is None: + raise ValueError("ICCMA ABA input must include a p aba header") + return ABAFramework( + language=frozenset(atoms.values()), + rules=frozenset(rules), + assumptions=frozenset(assumptions), + contrary=contraries, + ) + + +def write_aba(framework: ABAFramework) -> str: + """Write a deterministic compact ICCMA-style ``p aba`` flat-ABA format.""" + lines = ["p aba"] + for assumption in sorted(framework.assumptions, key=repr): + lines.append(f"a {_aba_name(assumption)}") + for assumption, contrary in sorted(framework.contrary.items(), key=lambda item: repr(item[0])): + lines.append(f"c {_aba_name(assumption)} {_aba_name(contrary)}") + for rule in sorted(framework.rules, key=lambda item: (_aba_name(item.consequent), tuple(map(_aba_name, item.antecedents)))): + body = " ".join(_aba_name(antecedent) for antecedent in rule.antecedents) + lines.append(f"r {_aba_name(rule.consequent)}" + (f" {body}" if body else "")) + return "\n".join(lines) + "\n" + + +def write_numeric_aba(framework: ABAFramework) -> str: + """Write the official numeric ICCMA 2025 ``p aba `` flat-ABA format.""" + literals = sorted(framework.language, key=repr) + ids = {literal: str(index) for index, literal in enumerate(literals, start=1)} + lines = [f"p aba {len(literals)}"] + for assumption in sorted(framework.assumptions, key=repr): + lines.append(f"a {ids[assumption]}") + for assumption, contrary in sorted(framework.contrary.items(), key=lambda item: repr(item[0])): + lines.append(f"c {ids[assumption]} {ids[contrary]}") + for rule in sorted(framework.rules, key=lambda item: (repr(item.consequent), tuple(map(repr, item.antecedents)))): + body = " ".join(ids[antecedent] for antecedent in rule.antecedents) + lines.append(f"r {ids[rule.consequent]}" + (f" {body}" if body else "")) + return "\n".join(lines) + "\n" + + +def _validate_attack_id(value: str, argument_count: int, line_number: int) -> None: + numeric = int(value) + if numeric < 1 or numeric > argument_count: + raise ValueError( + f"attack line {line_number} references argument outside 1..{argument_count}" + ) + + +def _numeric_argument_ids(framework: ArgumentationFramework) -> list[int]: + if not all(argument.isdigit() for argument in framework.arguments): + raise ValueError("ICCMA AF arguments must be numeric ids") + return sorted(int(argument) for argument in framework.arguments) + + +def _aba_literal(atoms: dict[str, Literal], name: str) -> Literal: + if name not in atoms: + atoms[name] = Literal(GroundAtom(name)) + return atoms[name] + + +def _aba_numeric_literal( + atoms: dict[str, Literal], + name: str, + atom_count: int, + line_number: int, +) -> Literal: + if not name.isdigit(): + raise ValueError(f"ABA line {line_number} must contain numeric atom ids") + numeric = int(name) + if numeric < 1 or numeric > atom_count: + raise ValueError( + f"ABA line {line_number} references atom outside 1..{atom_count}" + ) + return atoms[name] + + +def _aba_name(literal: Literal) -> str: + if literal.negated or literal.atom.arguments: + raise ValueError("compact ABA ICCMA format supports only nullary positive literals") + return literal.atom.predicate + + +__all__ = [ + "parse_aba", + "parse_adf", + "parse_apx", + "parse_af", + "parse_tgf", + "write_aba", + "write_adf", + "write_af", + "write_numeric_aba", +] diff --git a/src/argumentation/probabilistic/__init__.py b/src/argumentation/probabilistic/__init__.py new file mode 100644 index 0000000..c9861c2 --- /dev/null +++ b/src/argumentation/probabilistic/__init__.py @@ -0,0 +1 @@ +"""Probabilistic and epistemic argumentation frameworks.""" diff --git a/src/argumentation/epistemic.py b/src/argumentation/probabilistic/epistemic.py similarity index 96% rename from src/argumentation/epistemic.py rename to src/argumentation/probabilistic/epistemic.py index 4894a76..4fcba3d 100644 --- a/src/argumentation/epistemic.py +++ b/src/argumentation/probabilistic/epistemic.py @@ -1,842 +1,842 @@ -"""Epistemic graph and probabilistic epistemic argumentation helpers. - -The paper-faithful surface includes Hunter, Polberg, and Thimm's epistemic -language over probabilities of Boolean argument terms, belief distributions over -possible worlds, multi-labelled epistemic arcs with positive, negative, and -dependent labels, and Potyka, Polberg, and Hunter-style linear atomic -constraints over probability labellings. The older ``Influence`` / -``BeliefConstraint`` functions remain as a finite belief-grid approximation and -should not be treated as the paper's labelled epistemic graph semantics. - -References: - Hunter, Polberg, and Thimm (2018-2020). Epistemic graphs for representing - and reasoning with positive and negative influences of arguments. - Potyka, Polberg, and Hunter (2019). Polynomial-time updates of epistemic - states in a fragment of probabilistic epistemic argumentation. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum, StrEnum -from itertools import product -import re -from typing import Literal, Mapping, Sequence - -from argumentation.dung import ArgumentationFramework -from argumentation.probabilistic import ProbabilisticAF - - -@dataclass(frozen=True) -class ArgumentTerm: - name: str - - -@dataclass(frozen=True) -class NotTerm: - term: Term - - -@dataclass(frozen=True) -class AndTerm: - left: Term - right: Term - - -@dataclass(frozen=True) -class OrTerm: - left: Term - right: Term - - -Term = ArgumentTerm | NotTerm | AndTerm | OrTerm - - -@dataclass(frozen=True) -class ProbabilityTerm: - term: Term - - -@dataclass(frozen=True) -class OperationalFormula: - terms: tuple[ProbabilityTerm, ...] - operators: tuple[Literal["+", "-"], ...] - - def __post_init__(self) -> None: - if not self.terms: - raise ValueError("operational formula must contain at least one probability term") - if len(self.operators) != len(self.terms) - 1: - raise ValueError("operational formula operators must connect probability terms") - - -ComparisonOperator = Literal["=", "!=", "<", "<=", ">", ">="] - - -@dataclass(frozen=True) -class EpistemicAtom: - formula: OperationalFormula - operator: ComparisonOperator - threshold: float - - def __post_init__(self) -> None: - if self.operator not in {"=", "!=", "<", "<=", ">", ">="}: - raise ValueError(f"unsupported epistemic comparison operator: {self.operator}") - if not 0.0 <= self.threshold <= 1.0: - raise ValueError("epistemic atom thresholds must lie in [0, 1]") - - -@dataclass(frozen=True) -class AtomFormula: - atom: EpistemicAtom - - -@dataclass(frozen=True) -class NotFormula: - formula: EpistemicFormula - - -@dataclass(frozen=True) -class AndFormula: - left: EpistemicFormula - right: EpistemicFormula - - -@dataclass(frozen=True) -class OrFormula: - left: EpistemicFormula - right: EpistemicFormula - - -EpistemicFormula = AtomFormula | NotFormula | AndFormula | OrFormula - - -@dataclass(frozen=True) -class ProbabilityFunction: - """Belief distribution over all possible worlds of a finite argument set.""" - - arguments: frozenset[str] - probabilities: Mapping[frozenset[str], float] - - def __post_init__(self) -> None: - arguments = frozenset(str(argument) for argument in self.arguments) - normalized = { - frozenset(str(argument) for argument in world): float(probability) - for world, probability in self.probabilities.items() - } - worlds = frozenset(possible_worlds(arguments)) - if frozenset(normalized) != worlds: - raise ValueError("probability function must assign all possible worlds exactly once") - negative = sorted(world for world, probability in normalized.items() if probability < 0.0) - if negative: - raise ValueError(f"world probabilities must be nonnegative: {negative!r}") - total = sum(normalized.values()) - if abs(total - 1.0) > 1e-9: - raise ValueError("world probabilities must sum to 1") - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "probabilities", normalized) - - -def possible_worlds(arguments: frozenset[str]) -> tuple[frozenset[str], ...]: - """Return all possible worlds ordered by size and argument name.""" - ordered = sorted(arguments) - worlds: list[frozenset[str]] = [] - for mask in range(1 << len(ordered)): - worlds.append( - frozenset(argument for index, argument in enumerate(ordered) if mask & (1 << index)) - ) - return tuple(sorted(worlds, key=lambda world: (len(world), tuple(sorted(world))))) - - -def term_satisfied(term: Term, world: frozenset[str]) -> bool: - if isinstance(term, ArgumentTerm): - return term.name in world - if isinstance(term, NotTerm): - return not term_satisfied(term.term, world) - if isinstance(term, AndTerm): - return term_satisfied(term.left, world) and term_satisfied(term.right, world) - if isinstance(term, OrTerm): - return term_satisfied(term.left, world) or term_satisfied(term.right, world) - raise TypeError(f"unsupported term: {term!r}") - - -def term_probability(term: Term, distribution: ProbabilityFunction) -> float: - """Return the sum of probabilities of worlds satisfying ``term``.""" - probability = sum( - probability - for world, probability in distribution.probabilities.items() - if term_satisfied(term, world) - ) - return min(1.0, max(0.0, probability)) - - -def operational_value( - formula: OperationalFormula, - distribution: ProbabilityFunction, -) -> float: - value = term_probability(formula.terms[0].term, distribution) - for operator, probability_term in zip(formula.operators, formula.terms[1:], strict=True): - term_value = term_probability(probability_term.term, distribution) - if operator == "+": - value += term_value - else: - value -= term_value - return value - - -def evaluate_epistemic_formula( - formula: EpistemicFormula, - distribution: ProbabilityFunction, -) -> bool: - if isinstance(formula, AtomFormula): - return _compare( - operational_value(formula.atom.formula, distribution), - formula.atom.operator, - formula.atom.threshold, - ) - if isinstance(formula, NotFormula): - return not evaluate_epistemic_formula(formula.formula, distribution) - if isinstance(formula, AndFormula): - return evaluate_epistemic_formula(formula.left, distribution) and evaluate_epistemic_formula( - formula.right, - distribution, - ) - if isinstance(formula, OrFormula): - return evaluate_epistemic_formula(formula.left, distribution) or evaluate_epistemic_formula( - formula.right, - distribution, - ) - raise TypeError(f"unsupported epistemic formula: {formula!r}") - - -def induced_probability_labelling(distribution: ProbabilityFunction) -> dict[str, float]: - """Return the argument-probability labelling induced by a belief distribution.""" - return { - argument: term_probability(ArgumentTerm(argument), distribution) - for argument in sorted(distribution.arguments) - } - - -_TOKEN_RE = re.compile(r"\s*(<=|>=|!=|[A-Za-z_][A-Za-z0-9_]*|[0-9]+(?:\.[0-9]+)?|[()!&|+\-=<>])") - - -def parse_term(text: str) -> Term: - parser = _TokenParser(text) - term = parser.parse_or_term() - parser.expect_end() - return term - - -def write_term(term: Term) -> str: - if isinstance(term, ArgumentTerm): - return term.name - if isinstance(term, NotTerm): - inner = write_term(term.term) - return f"!{inner}" if isinstance(term.term, ArgumentTerm) else f"!({inner})" - if isinstance(term, AndTerm): - return f"({write_term(term.left)} & {write_term(term.right)})" - if isinstance(term, OrTerm): - return f"({write_term(term.left)} | {write_term(term.right)})" - raise TypeError(f"unsupported term: {term!r}") - - -def parse_epistemic_formula(text: str) -> EpistemicFormula: - parser = _TokenParser(text) - formula = parser.parse_or_formula() - parser.expect_end() - return formula - - -def write_epistemic_formula(formula: EpistemicFormula) -> str: - if isinstance(formula, AtomFormula): - return ( - f"{write_operational_formula(formula.atom.formula)} " - f"{formula.atom.operator} {_format_number(formula.atom.threshold)}" - ) - if isinstance(formula, NotFormula): - inner = write_epistemic_formula(formula.formula) - return f"!({inner})" - if isinstance(formula, AndFormula): - return f"({write_epistemic_formula(formula.left)} & {write_epistemic_formula(formula.right)})" - if isinstance(formula, OrFormula): - return f"({write_epistemic_formula(formula.left)} | {write_epistemic_formula(formula.right)})" - raise TypeError(f"unsupported epistemic formula: {formula!r}") - - -def write_operational_formula(formula: OperationalFormula) -> str: - parts = [f"p({write_term(formula.terms[0].term)})"] - for operator, term in zip(formula.operators, formula.terms[1:], strict=True): - parts.append(f"{operator} p({write_term(term.term)})") - return " ".join(parts) - - -class _TokenParser: - def __init__(self, text: str) -> None: - self.tokens = _tokenize(text) - self.index = 0 - - def peek(self) -> str | None: - if self.index >= len(self.tokens): - return None - return self.tokens[self.index] - - def take(self) -> str: - token = self.peek() - if token is None: - raise ValueError("unexpected end of input") - self.index += 1 - return token - - def take_if(self, token: str) -> bool: - if self.peek() == token: - self.index += 1 - return True - return False - - def expect(self, token: str) -> None: - actual = self.take() - if actual != token: - raise ValueError(f"expected {token!r}, got {actual!r}") - - def expect_end(self) -> None: - if self.peek() is not None: - raise ValueError(f"unexpected token: {self.peek()!r}") - - def parse_or_term(self) -> Term: - term = self.parse_and_term() - while self.take_if("|"): - term = OrTerm(term, self.parse_and_term()) - return term - - def parse_and_term(self) -> Term: - term = self.parse_not_term() - while self.take_if("&"): - term = AndTerm(term, self.parse_not_term()) - return term - - def parse_not_term(self) -> Term: - if self.take_if("!"): - return NotTerm(self.parse_not_term()) - if self.take_if("("): - term = self.parse_or_term() - self.expect(")") - return term - token = self.take() - if not _is_identifier(token): - raise ValueError(f"expected argument identifier, got {token!r}") - return ArgumentTerm(token) - - def parse_or_formula(self) -> EpistemicFormula: - formula = self.parse_and_formula() - while self.take_if("|"): - formula = OrFormula(formula, self.parse_and_formula()) - return formula - - def parse_and_formula(self) -> EpistemicFormula: - formula = self.parse_not_formula() - while self.take_if("&"): - formula = AndFormula(formula, self.parse_not_formula()) - return formula - - def parse_not_formula(self) -> EpistemicFormula: - if self.take_if("!"): - return NotFormula(self.parse_not_formula()) - if self.take_if("("): - formula = self.parse_or_formula() - self.expect(")") - return formula - return AtomFormula(self.parse_atom()) - - def parse_atom(self) -> EpistemicAtom: - formula = self.parse_operational_formula() - operator = self.take() - if operator not in {"=", "!=", "<", "<=", ">", ">="}: - raise ValueError(f"expected epistemic comparison operator, got {operator!r}") - threshold = float(self.take()) - return EpistemicAtom(formula, operator, threshold) # type: ignore[arg-type] - - def parse_operational_formula(self) -> OperationalFormula: - terms = [self.parse_probability_term()] - operators: list[Literal["+", "-"]] = [] - while self.peek() in {"+", "-"}: - operator = self.take() - operators.append(operator) # type: ignore[arg-type] - terms.append(self.parse_probability_term()) - return OperationalFormula(tuple(terms), tuple(operators)) - - def parse_probability_term(self) -> ProbabilityTerm: - token = self.take() - if token != "p": - raise ValueError(f"expected probability term, got {token!r}") - self.expect("(") - term = self.parse_or_term() - self.expect(")") - return ProbabilityTerm(term) - - -def _tokenize(text: str) -> list[str]: - tokens: list[str] = [] - position = 0 - while position < len(text): - match = _TOKEN_RE.match(text, position) - if match is None: - raise ValueError(f"invalid token near {text[position:]!r}") - tokens.append(match.group(1)) - position = match.end() - return tokens - - -def _is_identifier(token: str) -> bool: - return re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token) is not None - - -def _compare(left: float, operator: ComparisonOperator, right: float) -> bool: - if operator == "=": - return abs(left - right) <= 1e-12 - if operator == "!=": - return abs(left - right) > 1e-12 - if operator == "<": - return left < right - if operator == "<=": - return left <= right + 1e-12 - if operator == ">": - return left > right - if operator == ">=": - return left + 1e-12 >= right - raise ValueError(f"unsupported comparison operator: {operator}") - - -def _format_number(value: float) -> str: - if value.is_integer(): - return str(int(value)) - return str(value) - - -class InfluenceKind(StrEnum): - POSITIVE = "positive" - NEGATIVE = "negative" - NEUTRAL = "neutral" - - -class EpistemicLabel(StrEnum): - POSITIVE = "+" - NEGATIVE = "-" - DEPENDENT = "*" - - -@dataclass(frozen=True) -class Influence: - source: str - target: str - kind: InfluenceKind - - -@dataclass(frozen=True) -class LabelledArc: - source: str - target: str - labels: frozenset[EpistemicLabel] - - def __post_init__(self) -> None: - labels = frozenset(EpistemicLabel(label) for label in self.labels) - if not labels: - raise ValueError("labelled epistemic arcs must have at least one label") - object.__setattr__(self, "source", str(self.source)) - object.__setattr__(self, "target", str(self.target)) - object.__setattr__(self, "labels", labels) - - -@dataclass(frozen=True) -class LabelledEpistemicGraph: - arguments: frozenset[str] - arcs: frozenset[LabelledArc] - - def __post_init__(self) -> None: - arguments = frozenset(str(argument) for argument in self.arguments) - unknown = sorted( - (arc.source, arc.target) - for arc in self.arcs - if arc.source not in arguments or arc.target not in arguments - ) - if unknown: - raise ValueError(f"labelled arcs must reference declared arguments: {unknown!r}") - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "arcs", frozenset(self.arcs)) - - def parents(self, argument: str) -> frozenset[str]: - return frozenset(arc.source for arc in self.arcs if arc.target == argument) - - def parents_by_label(self, argument: str, label: EpistemicLabel) -> frozenset[str]: - return frozenset( - arc.source - for arc in self.arcs - if arc.target == argument and label in arc.labels - ) - - -class LinearRelation(Enum): - LE = "<=" - GE = ">=" - EQ = "=" - - -@dataclass(frozen=True) -class LinearAtomicConstraint: - coefficients: Mapping[str, float] - relation: LinearRelation - constant: float - - def __post_init__(self) -> None: - normalized = { - str(argument): float(coefficient) - for argument, coefficient in self.coefficients.items() - if coefficient != 0.0 - } - object.__setattr__(self, "coefficients", normalized) - object.__setattr__(self, "constant", float(self.constant)) - - def satisfied_by(self, labelling: Mapping[str, float]) -> bool: - value = sum( - coefficient * float(labelling[argument]) - for argument, coefficient in self.coefficients.items() - ) - if self.relation == LinearRelation.LE: - return value <= self.constant + 1e-12 - if self.relation == LinearRelation.GE: - return value + 1e-12 >= self.constant - if self.relation == LinearRelation.EQ: - return abs(value - self.constant) <= 1e-12 - raise ValueError(f"unsupported linear relation: {self.relation}") - - -def coherence_attack_constraint(attacker: str, target: str) -> LinearAtomicConstraint: - """Return Potyka's attack coherence constraint P(target) <= 1 - P(attacker).""" - return LinearAtomicConstraint( - {attacker: 1.0, target: 1.0}, - LinearRelation.LE, - 1.0, - ) - - -def support_monotonic_constraint(supporter: str, target: str) -> LinearAtomicConstraint: - """Return the support-dual monotonic constraint P(target) >= P(supporter).""" - return LinearAtomicConstraint( - {supporter: 1.0, target: -1.0}, - LinearRelation.LE, - 0.0, - ) - - -def constraints_satisfiable( - arguments: frozenset[str], - constraints: Sequence[LinearAtomicConstraint], -) -> bool: - solver, variables = _linear_solver(arguments) - for constraint in constraints: - _add_linear_constraint(solver, variables, constraint) - return str(solver.check()) == "sat" - - -def constraints_entail( - arguments: frozenset[str], - constraints: Sequence[LinearAtomicConstraint], - conclusion: LinearAtomicConstraint, -) -> bool: - solver, variables = _linear_solver(arguments) - for constraint in constraints: - _add_linear_constraint(solver, variables, constraint) - _add_negated_linear_constraint(solver, variables, conclusion) - return str(solver.check()) == "unsat" - - -def least_squares_update_labelling( - arguments: frozenset[str], - current: Mapping[str, float], - constraints: Sequence[LinearAtomicConstraint], -) -> dict[str, float] | None: - """Return a least-squares atomic labelling update, or ``None`` if unsatisfiable.""" - arguments = frozenset(str(argument) for argument in arguments) - current = {str(argument): float(value) for argument, value in current.items()} - missing = sorted(arguments - set(current)) - extra = sorted(set(current) - arguments) - if missing or extra: - raise ValueError(f"current labelling keys must match arguments: missing={missing!r}, extra={extra!r}") - if any(value < 0.0 or value > 1.0 for value in current.values()): - raise ValueError("current labelling values must lie in [0, 1]") - if all(constraint.satisfied_by(current) for constraint in constraints): - return {argument: current[argument] for argument in sorted(arguments)} - if not constraints_satisfiable(arguments, constraints): - return None - - projection_constraints = list(constraints) - for argument in sorted(arguments): - projection_constraints.append( - LinearAtomicConstraint({argument: 1.0}, LinearRelation.GE, 0.0) - ) - projection_constraints.append( - LinearAtomicConstraint({argument: 1.0}, LinearRelation.LE, 1.0) - ) - updated = _project_labelling(current, projection_constraints) - return {argument: round(updated[argument], 12) for argument in sorted(arguments)} - - -def _linear_solver(arguments: frozenset[str]): - try: - import z3 # type: ignore[import-not-found] - except ImportError as exc: - raise RuntimeError("linear epistemic constraint reasoning requires z3-solver") from exc - - variables = {argument: z3.Real(argument) for argument in sorted(arguments)} - solver = z3.Solver() - for variable in variables.values(): - solver.add(variable >= 0, variable <= 1) - return solver, variables - - -def _linear_expr(variables, constraint: LinearAtomicConstraint): - expr = 0 - for argument, coefficient in constraint.coefficients.items(): - if argument not in variables: - raise ValueError(f"constraint references unknown argument: {argument!r}") - expr = expr + coefficient * variables[argument] - return expr - - -def _add_linear_constraint(solver, variables, constraint: LinearAtomicConstraint) -> None: - expr = _linear_expr(variables, constraint) - if constraint.relation == LinearRelation.LE: - solver.add(expr <= constraint.constant) - elif constraint.relation == LinearRelation.GE: - solver.add(expr >= constraint.constant) - elif constraint.relation == LinearRelation.EQ: - solver.add(expr == constraint.constant) - else: - raise ValueError(f"unsupported linear relation: {constraint.relation}") - - -def _add_negated_linear_constraint(solver, variables, constraint: LinearAtomicConstraint) -> None: - expr = _linear_expr(variables, constraint) - if constraint.relation == LinearRelation.LE: - solver.add(expr > constraint.constant) - elif constraint.relation == LinearRelation.GE: - solver.add(expr < constraint.constant) - elif constraint.relation == LinearRelation.EQ: - solver.add(expr != constraint.constant) - else: - raise ValueError(f"unsupported linear relation: {constraint.relation}") - - -def _project_labelling( - current: Mapping[str, float], - constraints: Sequence[LinearAtomicConstraint], -) -> dict[str, float]: - point = {argument: float(value) for argument, value in current.items()} - for _ in range(10_000): - max_violation = 0.0 - for constraint in constraints: - violation = _constraint_violation(point, constraint) - max_violation = max(max_violation, abs(violation)) - if abs(violation) <= 1e-12: - continue - norm = sum(coefficient * coefficient for coefficient in constraint.coefficients.values()) - if norm == 0.0: - continue - for argument, coefficient in constraint.coefficients.items(): - point[argument] = point[argument] - (violation / norm) * coefficient - if max_violation <= 1e-10: - break - return point - - -def _constraint_violation( - point: Mapping[str, float], - constraint: LinearAtomicConstraint, -) -> float: - value = sum( - coefficient * float(point[argument]) - for argument, coefficient in constraint.coefficients.items() - ) - if constraint.relation == LinearRelation.LE: - return max(0.0, value - constraint.constant) - if constraint.relation == LinearRelation.GE: - return min(0.0, value - constraint.constant) - if constraint.relation == LinearRelation.EQ: - return value - constraint.constant - raise ValueError(f"unsupported linear relation: {constraint.relation}") - - -@dataclass(frozen=True) -class BeliefConstraint: - argument: str - lower: float = 0.0 - upper: float = 1.0 - - def __post_init__(self) -> None: - if not 0.0 <= self.lower <= self.upper <= 1.0: - raise ValueError("belief constraint bounds must satisfy 0 <= lower <= upper <= 1") - - -@dataclass(frozen=True) -class EpistemicGraph: - arguments: frozenset[str] - influences: frozenset[Influence] = frozenset() - constraints: tuple[BeliefConstraint, ...] = () - - def __post_init__(self) -> None: - arguments = frozenset(self.arguments) - unknown_influences = sorted( - (influence.source, influence.target) - for influence in self.influences - if influence.source not in arguments or influence.target not in arguments - ) - if unknown_influences: - raise ValueError(f"influences must reference declared arguments: {unknown_influences!r}") - unknown_constraints = sorted( - constraint.argument - for constraint in self.constraints - if constraint.argument not in arguments - ) - if unknown_constraints: - raise ValueError(f"constraints must reference declared arguments: {unknown_constraints!r}") - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "influences", frozenset(self.influences)) - object.__setattr__(self, "constraints", tuple(self.constraints)) - - -def _constraint_by_argument(graph: EpistemicGraph) -> dict[str, BeliefConstraint]: - constraints: dict[str, BeliefConstraint] = {} - for constraint in graph.constraints: - existing = constraints.get(constraint.argument) - if existing is None: - constraints[constraint.argument] = constraint - else: - constraints[constraint.argument] = BeliefConstraint( - constraint.argument, - lower=max(existing.lower, constraint.lower), - upper=min(existing.upper, constraint.upper), - ) - return constraints - - -def _validate_assignment( - graph: EpistemicGraph, - assignment: Mapping[str, float], -) -> dict[str, float]: - missing = sorted(graph.arguments - set(assignment)) - extra = sorted(set(assignment) - graph.arguments) - if missing or extra: - raise ValueError(f"assignment keys must match graph arguments: missing={missing!r}, extra={extra!r}") - normalized = {argument: float(value) for argument, value in assignment.items()} - out_of_range = sorted( - argument - for argument, value in normalized.items() - if not 0.0 <= value <= 1.0 - ) - if out_of_range: - raise ValueError(f"assignment values must be in [0, 1]: {out_of_range!r}") - return normalized - - -def belief_assignment_satisfies( - graph: EpistemicGraph, - assignment: Mapping[str, float], -) -> bool: - """Return whether ``assignment`` satisfies graph constraints.""" - values = _validate_assignment(graph, assignment) - constraints = _constraint_by_argument(graph) - for argument, constraint in constraints.items(): - value = values[argument] - if value < constraint.lower or value > constraint.upper: - return False - - for influence in graph.influences: - source = values[influence.source] - target = values[influence.target] - if influence.kind == InfluenceKind.POSITIVE and target < source: - return False - if influence.kind == InfluenceKind.NEGATIVE and target > 1.0 - source: - return False - return True - - -def enumerate_satisfying_assignments( - graph: EpistemicGraph, - *, - levels: tuple[float, ...] = (0.0, 0.5, 1.0), -) -> tuple[dict[str, float], ...]: - """Enumerate satisfying assignments over a finite belief grid.""" - if not levels: - raise ValueError("levels must not be empty") - if any(level < 0.0 or level > 1.0 for level in levels): - raise ValueError("levels must lie in [0, 1]") - ordered = sorted(graph.arguments) - satisfying: list[dict[str, float]] = [] - for values in product(levels, repeat=len(ordered)): - assignment = dict(zip(ordered, values, strict=True)) - if belief_assignment_satisfies(graph, assignment): - satisfying.append(assignment) - return tuple(satisfying) - - -def update_assignment( - graph: EpistemicGraph, - evidence: Mapping[str, float], -) -> dict[str, float]: - """Update a belief assignment in the monotone influence fragment.""" - unknown = sorted(set(evidence) - graph.arguments) - if unknown: - raise ValueError(f"evidence references unknown arguments: {unknown!r}") - assignment = {argument: 0.5 for argument in graph.arguments} - for argument, value in evidence.items(): - if not 0.0 <= value <= 1.0: - raise ValueError("evidence values must lie in [0, 1]") - assignment[argument] = float(value) - - changed = True - while changed: - changed = False - for influence in sorted( - graph.influences, - key=lambda item: (item.source, item.target, item.kind.value), - ): - source = assignment[influence.source] - target = assignment[influence.target] - if influence.kind == InfluenceKind.POSITIVE and target < source: - assignment[influence.target] = source - changed = True - elif influence.kind == InfluenceKind.NEGATIVE and target > 1.0 - source: - assignment[influence.target] = 1.0 - source - changed = True - return { - argument: round(assignment[argument], 12) - for argument in sorted(graph.arguments) - } - - -def project_to_constellation_praf(graph: EpistemicGraph) -> ProbabilisticAF: - """Project influences to a constellation PrAF where the mapping is defined.""" - constraints = _constraint_by_argument(graph) - p_args = { - argument: constraints[argument].lower - if argument in constraints and constraints[argument].lower > 0.0 - else constraints[argument].upper - if argument in constraints - else 1.0 - for argument in graph.arguments - } - defeats = frozenset( - (influence.source, influence.target) - for influence in graph.influences - if influence.kind == InfluenceKind.NEGATIVE - ) - supports = frozenset( - (influence.source, influence.target) - for influence in graph.influences - if influence.kind == InfluenceKind.POSITIVE - ) - return ProbabilisticAF( - framework=ArgumentationFramework(arguments=graph.arguments, defeats=defeats), - p_args=p_args, - p_defeats={defeat: 1.0 for defeat in defeats}, - supports=supports, - p_supports={support: 1.0 for support in supports}, - ) +"""Epistemic graph and probabilistic epistemic argumentation helpers. + +The paper-faithful surface includes Hunter, Polberg, and Thimm's epistemic +language over probabilities of Boolean argument terms, belief distributions over +possible worlds, multi-labelled epistemic arcs with positive, negative, and +dependent labels, and Potyka, Polberg, and Hunter-style linear atomic +constraints over probability labellings. The older ``Influence`` / +``BeliefConstraint`` functions remain as a finite belief-grid approximation and +should not be treated as the paper's labelled epistemic graph semantics. + +References: + Hunter, Polberg, and Thimm (2018-2020). Epistemic graphs for representing + and reasoning with positive and negative influences of arguments. + Potyka, Polberg, and Hunter (2019). Polynomial-time updates of epistemic + states in a fragment of probabilistic epistemic argumentation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, StrEnum +from itertools import product +import re +from typing import Literal, Mapping, Sequence + +from argumentation.core.dung import ArgumentationFramework +from argumentation.probabilistic.probabilistic import ProbabilisticAF + + +@dataclass(frozen=True) +class ArgumentTerm: + name: str + + +@dataclass(frozen=True) +class NotTerm: + term: Term + + +@dataclass(frozen=True) +class AndTerm: + left: Term + right: Term + + +@dataclass(frozen=True) +class OrTerm: + left: Term + right: Term + + +Term = ArgumentTerm | NotTerm | AndTerm | OrTerm + + +@dataclass(frozen=True) +class ProbabilityTerm: + term: Term + + +@dataclass(frozen=True) +class OperationalFormula: + terms: tuple[ProbabilityTerm, ...] + operators: tuple[Literal["+", "-"], ...] + + def __post_init__(self) -> None: + if not self.terms: + raise ValueError("operational formula must contain at least one probability term") + if len(self.operators) != len(self.terms) - 1: + raise ValueError("operational formula operators must connect probability terms") + + +ComparisonOperator = Literal["=", "!=", "<", "<=", ">", ">="] + + +@dataclass(frozen=True) +class EpistemicAtom: + formula: OperationalFormula + operator: ComparisonOperator + threshold: float + + def __post_init__(self) -> None: + if self.operator not in {"=", "!=", "<", "<=", ">", ">="}: + raise ValueError(f"unsupported epistemic comparison operator: {self.operator}") + if not 0.0 <= self.threshold <= 1.0: + raise ValueError("epistemic atom thresholds must lie in [0, 1]") + + +@dataclass(frozen=True) +class AtomFormula: + atom: EpistemicAtom + + +@dataclass(frozen=True) +class NotFormula: + formula: EpistemicFormula + + +@dataclass(frozen=True) +class AndFormula: + left: EpistemicFormula + right: EpistemicFormula + + +@dataclass(frozen=True) +class OrFormula: + left: EpistemicFormula + right: EpistemicFormula + + +EpistemicFormula = AtomFormula | NotFormula | AndFormula | OrFormula + + +@dataclass(frozen=True) +class ProbabilityFunction: + """Belief distribution over all possible worlds of a finite argument set.""" + + arguments: frozenset[str] + probabilities: Mapping[frozenset[str], float] + + def __post_init__(self) -> None: + arguments = frozenset(str(argument) for argument in self.arguments) + normalized = { + frozenset(str(argument) for argument in world): float(probability) + for world, probability in self.probabilities.items() + } + worlds = frozenset(possible_worlds(arguments)) + if frozenset(normalized) != worlds: + raise ValueError("probability function must assign all possible worlds exactly once") + negative = sorted(world for world, probability in normalized.items() if probability < 0.0) + if negative: + raise ValueError(f"world probabilities must be nonnegative: {negative!r}") + total = sum(normalized.values()) + if abs(total - 1.0) > 1e-9: + raise ValueError("world probabilities must sum to 1") + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "probabilities", normalized) + + +def possible_worlds(arguments: frozenset[str]) -> tuple[frozenset[str], ...]: + """Return all possible worlds ordered by size and argument name.""" + ordered = sorted(arguments) + worlds: list[frozenset[str]] = [] + for mask in range(1 << len(ordered)): + worlds.append( + frozenset(argument for index, argument in enumerate(ordered) if mask & (1 << index)) + ) + return tuple(sorted(worlds, key=lambda world: (len(world), tuple(sorted(world))))) + + +def term_satisfied(term: Term, world: frozenset[str]) -> bool: + if isinstance(term, ArgumentTerm): + return term.name in world + if isinstance(term, NotTerm): + return not term_satisfied(term.term, world) + if isinstance(term, AndTerm): + return term_satisfied(term.left, world) and term_satisfied(term.right, world) + if isinstance(term, OrTerm): + return term_satisfied(term.left, world) or term_satisfied(term.right, world) + raise TypeError(f"unsupported term: {term!r}") + + +def term_probability(term: Term, distribution: ProbabilityFunction) -> float: + """Return the sum of probabilities of worlds satisfying ``term``.""" + probability = sum( + probability + for world, probability in distribution.probabilities.items() + if term_satisfied(term, world) + ) + return min(1.0, max(0.0, probability)) + + +def operational_value( + formula: OperationalFormula, + distribution: ProbabilityFunction, +) -> float: + value = term_probability(formula.terms[0].term, distribution) + for operator, probability_term in zip(formula.operators, formula.terms[1:], strict=True): + term_value = term_probability(probability_term.term, distribution) + if operator == "+": + value += term_value + else: + value -= term_value + return value + + +def evaluate_epistemic_formula( + formula: EpistemicFormula, + distribution: ProbabilityFunction, +) -> bool: + if isinstance(formula, AtomFormula): + return _compare( + operational_value(formula.atom.formula, distribution), + formula.atom.operator, + formula.atom.threshold, + ) + if isinstance(formula, NotFormula): + return not evaluate_epistemic_formula(formula.formula, distribution) + if isinstance(formula, AndFormula): + return evaluate_epistemic_formula(formula.left, distribution) and evaluate_epistemic_formula( + formula.right, + distribution, + ) + if isinstance(formula, OrFormula): + return evaluate_epistemic_formula(formula.left, distribution) or evaluate_epistemic_formula( + formula.right, + distribution, + ) + raise TypeError(f"unsupported epistemic formula: {formula!r}") + + +def induced_probability_labelling(distribution: ProbabilityFunction) -> dict[str, float]: + """Return the argument-probability labelling induced by a belief distribution.""" + return { + argument: term_probability(ArgumentTerm(argument), distribution) + for argument in sorted(distribution.arguments) + } + + +_TOKEN_RE = re.compile(r"\s*(<=|>=|!=|[A-Za-z_][A-Za-z0-9_]*|[0-9]+(?:\.[0-9]+)?|[()!&|+\-=<>])") + + +def parse_term(text: str) -> Term: + parser = _TokenParser(text) + term = parser.parse_or_term() + parser.expect_end() + return term + + +def write_term(term: Term) -> str: + if isinstance(term, ArgumentTerm): + return term.name + if isinstance(term, NotTerm): + inner = write_term(term.term) + return f"!{inner}" if isinstance(term.term, ArgumentTerm) else f"!({inner})" + if isinstance(term, AndTerm): + return f"({write_term(term.left)} & {write_term(term.right)})" + if isinstance(term, OrTerm): + return f"({write_term(term.left)} | {write_term(term.right)})" + raise TypeError(f"unsupported term: {term!r}") + + +def parse_epistemic_formula(text: str) -> EpistemicFormula: + parser = _TokenParser(text) + formula = parser.parse_or_formula() + parser.expect_end() + return formula + + +def write_epistemic_formula(formula: EpistemicFormula) -> str: + if isinstance(formula, AtomFormula): + return ( + f"{write_operational_formula(formula.atom.formula)} " + f"{formula.atom.operator} {_format_number(formula.atom.threshold)}" + ) + if isinstance(formula, NotFormula): + inner = write_epistemic_formula(formula.formula) + return f"!({inner})" + if isinstance(formula, AndFormula): + return f"({write_epistemic_formula(formula.left)} & {write_epistemic_formula(formula.right)})" + if isinstance(formula, OrFormula): + return f"({write_epistemic_formula(formula.left)} | {write_epistemic_formula(formula.right)})" + raise TypeError(f"unsupported epistemic formula: {formula!r}") + + +def write_operational_formula(formula: OperationalFormula) -> str: + parts = [f"p({write_term(formula.terms[0].term)})"] + for operator, term in zip(formula.operators, formula.terms[1:], strict=True): + parts.append(f"{operator} p({write_term(term.term)})") + return " ".join(parts) + + +class _TokenParser: + def __init__(self, text: str) -> None: + self.tokens = _tokenize(text) + self.index = 0 + + def peek(self) -> str | None: + if self.index >= len(self.tokens): + return None + return self.tokens[self.index] + + def take(self) -> str: + token = self.peek() + if token is None: + raise ValueError("unexpected end of input") + self.index += 1 + return token + + def take_if(self, token: str) -> bool: + if self.peek() == token: + self.index += 1 + return True + return False + + def expect(self, token: str) -> None: + actual = self.take() + if actual != token: + raise ValueError(f"expected {token!r}, got {actual!r}") + + def expect_end(self) -> None: + if self.peek() is not None: + raise ValueError(f"unexpected token: {self.peek()!r}") + + def parse_or_term(self) -> Term: + term = self.parse_and_term() + while self.take_if("|"): + term = OrTerm(term, self.parse_and_term()) + return term + + def parse_and_term(self) -> Term: + term = self.parse_not_term() + while self.take_if("&"): + term = AndTerm(term, self.parse_not_term()) + return term + + def parse_not_term(self) -> Term: + if self.take_if("!"): + return NotTerm(self.parse_not_term()) + if self.take_if("("): + term = self.parse_or_term() + self.expect(")") + return term + token = self.take() + if not _is_identifier(token): + raise ValueError(f"expected argument identifier, got {token!r}") + return ArgumentTerm(token) + + def parse_or_formula(self) -> EpistemicFormula: + formula = self.parse_and_formula() + while self.take_if("|"): + formula = OrFormula(formula, self.parse_and_formula()) + return formula + + def parse_and_formula(self) -> EpistemicFormula: + formula = self.parse_not_formula() + while self.take_if("&"): + formula = AndFormula(formula, self.parse_not_formula()) + return formula + + def parse_not_formula(self) -> EpistemicFormula: + if self.take_if("!"): + return NotFormula(self.parse_not_formula()) + if self.take_if("("): + formula = self.parse_or_formula() + self.expect(")") + return formula + return AtomFormula(self.parse_atom()) + + def parse_atom(self) -> EpistemicAtom: + formula = self.parse_operational_formula() + operator = self.take() + if operator not in {"=", "!=", "<", "<=", ">", ">="}: + raise ValueError(f"expected epistemic comparison operator, got {operator!r}") + threshold = float(self.take()) + return EpistemicAtom(formula, operator, threshold) # type: ignore[arg-type] + + def parse_operational_formula(self) -> OperationalFormula: + terms = [self.parse_probability_term()] + operators: list[Literal["+", "-"]] = [] + while self.peek() in {"+", "-"}: + operator = self.take() + operators.append(operator) # type: ignore[arg-type] + terms.append(self.parse_probability_term()) + return OperationalFormula(tuple(terms), tuple(operators)) + + def parse_probability_term(self) -> ProbabilityTerm: + token = self.take() + if token != "p": + raise ValueError(f"expected probability term, got {token!r}") + self.expect("(") + term = self.parse_or_term() + self.expect(")") + return ProbabilityTerm(term) + + +def _tokenize(text: str) -> list[str]: + tokens: list[str] = [] + position = 0 + while position < len(text): + match = _TOKEN_RE.match(text, position) + if match is None: + raise ValueError(f"invalid token near {text[position:]!r}") + tokens.append(match.group(1)) + position = match.end() + return tokens + + +def _is_identifier(token: str) -> bool: + return re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token) is not None + + +def _compare(left: float, operator: ComparisonOperator, right: float) -> bool: + if operator == "=": + return abs(left - right) <= 1e-12 + if operator == "!=": + return abs(left - right) > 1e-12 + if operator == "<": + return left < right + if operator == "<=": + return left <= right + 1e-12 + if operator == ">": + return left > right + if operator == ">=": + return left + 1e-12 >= right + raise ValueError(f"unsupported comparison operator: {operator}") + + +def _format_number(value: float) -> str: + if value.is_integer(): + return str(int(value)) + return str(value) + + +class InfluenceKind(StrEnum): + POSITIVE = "positive" + NEGATIVE = "negative" + NEUTRAL = "neutral" + + +class EpistemicLabel(StrEnum): + POSITIVE = "+" + NEGATIVE = "-" + DEPENDENT = "*" + + +@dataclass(frozen=True) +class Influence: + source: str + target: str + kind: InfluenceKind + + +@dataclass(frozen=True) +class LabelledArc: + source: str + target: str + labels: frozenset[EpistemicLabel] + + def __post_init__(self) -> None: + labels = frozenset(EpistemicLabel(label) for label in self.labels) + if not labels: + raise ValueError("labelled epistemic arcs must have at least one label") + object.__setattr__(self, "source", str(self.source)) + object.__setattr__(self, "target", str(self.target)) + object.__setattr__(self, "labels", labels) + + +@dataclass(frozen=True) +class LabelledEpistemicGraph: + arguments: frozenset[str] + arcs: frozenset[LabelledArc] + + def __post_init__(self) -> None: + arguments = frozenset(str(argument) for argument in self.arguments) + unknown = sorted( + (arc.source, arc.target) + for arc in self.arcs + if arc.source not in arguments or arc.target not in arguments + ) + if unknown: + raise ValueError(f"labelled arcs must reference declared arguments: {unknown!r}") + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "arcs", frozenset(self.arcs)) + + def parents(self, argument: str) -> frozenset[str]: + return frozenset(arc.source for arc in self.arcs if arc.target == argument) + + def parents_by_label(self, argument: str, label: EpistemicLabel) -> frozenset[str]: + return frozenset( + arc.source + for arc in self.arcs + if arc.target == argument and label in arc.labels + ) + + +class LinearRelation(Enum): + LE = "<=" + GE = ">=" + EQ = "=" + + +@dataclass(frozen=True) +class LinearAtomicConstraint: + coefficients: Mapping[str, float] + relation: LinearRelation + constant: float + + def __post_init__(self) -> None: + normalized = { + str(argument): float(coefficient) + for argument, coefficient in self.coefficients.items() + if coefficient != 0.0 + } + object.__setattr__(self, "coefficients", normalized) + object.__setattr__(self, "constant", float(self.constant)) + + def satisfied_by(self, labelling: Mapping[str, float]) -> bool: + value = sum( + coefficient * float(labelling[argument]) + for argument, coefficient in self.coefficients.items() + ) + if self.relation == LinearRelation.LE: + return value <= self.constant + 1e-12 + if self.relation == LinearRelation.GE: + return value + 1e-12 >= self.constant + if self.relation == LinearRelation.EQ: + return abs(value - self.constant) <= 1e-12 + raise ValueError(f"unsupported linear relation: {self.relation}") + + +def coherence_attack_constraint(attacker: str, target: str) -> LinearAtomicConstraint: + """Return Potyka's attack coherence constraint P(target) <= 1 - P(attacker).""" + return LinearAtomicConstraint( + {attacker: 1.0, target: 1.0}, + LinearRelation.LE, + 1.0, + ) + + +def support_monotonic_constraint(supporter: str, target: str) -> LinearAtomicConstraint: + """Return the support-dual monotonic constraint P(target) >= P(supporter).""" + return LinearAtomicConstraint( + {supporter: 1.0, target: -1.0}, + LinearRelation.LE, + 0.0, + ) + + +def constraints_satisfiable( + arguments: frozenset[str], + constraints: Sequence[LinearAtomicConstraint], +) -> bool: + solver, variables = _linear_solver(arguments) + for constraint in constraints: + _add_linear_constraint(solver, variables, constraint) + return str(solver.check()) == "sat" + + +def constraints_entail( + arguments: frozenset[str], + constraints: Sequence[LinearAtomicConstraint], + conclusion: LinearAtomicConstraint, +) -> bool: + solver, variables = _linear_solver(arguments) + for constraint in constraints: + _add_linear_constraint(solver, variables, constraint) + _add_negated_linear_constraint(solver, variables, conclusion) + return str(solver.check()) == "unsat" + + +def least_squares_update_labelling( + arguments: frozenset[str], + current: Mapping[str, float], + constraints: Sequence[LinearAtomicConstraint], +) -> dict[str, float] | None: + """Return a least-squares atomic labelling update, or ``None`` if unsatisfiable.""" + arguments = frozenset(str(argument) for argument in arguments) + current = {str(argument): float(value) for argument, value in current.items()} + missing = sorted(arguments - set(current)) + extra = sorted(set(current) - arguments) + if missing or extra: + raise ValueError(f"current labelling keys must match arguments: missing={missing!r}, extra={extra!r}") + if any(value < 0.0 or value > 1.0 for value in current.values()): + raise ValueError("current labelling values must lie in [0, 1]") + if all(constraint.satisfied_by(current) for constraint in constraints): + return {argument: current[argument] for argument in sorted(arguments)} + if not constraints_satisfiable(arguments, constraints): + return None + + projection_constraints = list(constraints) + for argument in sorted(arguments): + projection_constraints.append( + LinearAtomicConstraint({argument: 1.0}, LinearRelation.GE, 0.0) + ) + projection_constraints.append( + LinearAtomicConstraint({argument: 1.0}, LinearRelation.LE, 1.0) + ) + updated = _project_labelling(current, projection_constraints) + return {argument: round(updated[argument], 12) for argument in sorted(arguments)} + + +def _linear_solver(arguments: frozenset[str]): + try: + import z3 # type: ignore[import-not-found] + except ImportError as exc: + raise RuntimeError("linear epistemic constraint reasoning requires z3-solver") from exc + + variables = {argument: z3.Real(argument) for argument in sorted(arguments)} + solver = z3.Solver() + for variable in variables.values(): + solver.add(variable >= 0, variable <= 1) + return solver, variables + + +def _linear_expr(variables, constraint: LinearAtomicConstraint): + expr = 0 + for argument, coefficient in constraint.coefficients.items(): + if argument not in variables: + raise ValueError(f"constraint references unknown argument: {argument!r}") + expr = expr + coefficient * variables[argument] + return expr + + +def _add_linear_constraint(solver, variables, constraint: LinearAtomicConstraint) -> None: + expr = _linear_expr(variables, constraint) + if constraint.relation == LinearRelation.LE: + solver.add(expr <= constraint.constant) + elif constraint.relation == LinearRelation.GE: + solver.add(expr >= constraint.constant) + elif constraint.relation == LinearRelation.EQ: + solver.add(expr == constraint.constant) + else: + raise ValueError(f"unsupported linear relation: {constraint.relation}") + + +def _add_negated_linear_constraint(solver, variables, constraint: LinearAtomicConstraint) -> None: + expr = _linear_expr(variables, constraint) + if constraint.relation == LinearRelation.LE: + solver.add(expr > constraint.constant) + elif constraint.relation == LinearRelation.GE: + solver.add(expr < constraint.constant) + elif constraint.relation == LinearRelation.EQ: + solver.add(expr != constraint.constant) + else: + raise ValueError(f"unsupported linear relation: {constraint.relation}") + + +def _project_labelling( + current: Mapping[str, float], + constraints: Sequence[LinearAtomicConstraint], +) -> dict[str, float]: + point = {argument: float(value) for argument, value in current.items()} + for _ in range(10_000): + max_violation = 0.0 + for constraint in constraints: + violation = _constraint_violation(point, constraint) + max_violation = max(max_violation, abs(violation)) + if abs(violation) <= 1e-12: + continue + norm = sum(coefficient * coefficient for coefficient in constraint.coefficients.values()) + if norm == 0.0: + continue + for argument, coefficient in constraint.coefficients.items(): + point[argument] = point[argument] - (violation / norm) * coefficient + if max_violation <= 1e-10: + break + return point + + +def _constraint_violation( + point: Mapping[str, float], + constraint: LinearAtomicConstraint, +) -> float: + value = sum( + coefficient * float(point[argument]) + for argument, coefficient in constraint.coefficients.items() + ) + if constraint.relation == LinearRelation.LE: + return max(0.0, value - constraint.constant) + if constraint.relation == LinearRelation.GE: + return min(0.0, value - constraint.constant) + if constraint.relation == LinearRelation.EQ: + return value - constraint.constant + raise ValueError(f"unsupported linear relation: {constraint.relation}") + + +@dataclass(frozen=True) +class BeliefConstraint: + argument: str + lower: float = 0.0 + upper: float = 1.0 + + def __post_init__(self) -> None: + if not 0.0 <= self.lower <= self.upper <= 1.0: + raise ValueError("belief constraint bounds must satisfy 0 <= lower <= upper <= 1") + + +@dataclass(frozen=True) +class EpistemicGraph: + arguments: frozenset[str] + influences: frozenset[Influence] = frozenset() + constraints: tuple[BeliefConstraint, ...] = () + + def __post_init__(self) -> None: + arguments = frozenset(self.arguments) + unknown_influences = sorted( + (influence.source, influence.target) + for influence in self.influences + if influence.source not in arguments or influence.target not in arguments + ) + if unknown_influences: + raise ValueError(f"influences must reference declared arguments: {unknown_influences!r}") + unknown_constraints = sorted( + constraint.argument + for constraint in self.constraints + if constraint.argument not in arguments + ) + if unknown_constraints: + raise ValueError(f"constraints must reference declared arguments: {unknown_constraints!r}") + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "influences", frozenset(self.influences)) + object.__setattr__(self, "constraints", tuple(self.constraints)) + + +def _constraint_by_argument(graph: EpistemicGraph) -> dict[str, BeliefConstraint]: + constraints: dict[str, BeliefConstraint] = {} + for constraint in graph.constraints: + existing = constraints.get(constraint.argument) + if existing is None: + constraints[constraint.argument] = constraint + else: + constraints[constraint.argument] = BeliefConstraint( + constraint.argument, + lower=max(existing.lower, constraint.lower), + upper=min(existing.upper, constraint.upper), + ) + return constraints + + +def _validate_assignment( + graph: EpistemicGraph, + assignment: Mapping[str, float], +) -> dict[str, float]: + missing = sorted(graph.arguments - set(assignment)) + extra = sorted(set(assignment) - graph.arguments) + if missing or extra: + raise ValueError(f"assignment keys must match graph arguments: missing={missing!r}, extra={extra!r}") + normalized = {argument: float(value) for argument, value in assignment.items()} + out_of_range = sorted( + argument + for argument, value in normalized.items() + if not 0.0 <= value <= 1.0 + ) + if out_of_range: + raise ValueError(f"assignment values must be in [0, 1]: {out_of_range!r}") + return normalized + + +def belief_assignment_satisfies( + graph: EpistemicGraph, + assignment: Mapping[str, float], +) -> bool: + """Return whether ``assignment`` satisfies graph constraints.""" + values = _validate_assignment(graph, assignment) + constraints = _constraint_by_argument(graph) + for argument, constraint in constraints.items(): + value = values[argument] + if value < constraint.lower or value > constraint.upper: + return False + + for influence in graph.influences: + source = values[influence.source] + target = values[influence.target] + if influence.kind == InfluenceKind.POSITIVE and target < source: + return False + if influence.kind == InfluenceKind.NEGATIVE and target > 1.0 - source: + return False + return True + + +def enumerate_satisfying_assignments( + graph: EpistemicGraph, + *, + levels: tuple[float, ...] = (0.0, 0.5, 1.0), +) -> tuple[dict[str, float], ...]: + """Enumerate satisfying assignments over a finite belief grid.""" + if not levels: + raise ValueError("levels must not be empty") + if any(level < 0.0 or level > 1.0 for level in levels): + raise ValueError("levels must lie in [0, 1]") + ordered = sorted(graph.arguments) + satisfying: list[dict[str, float]] = [] + for values in product(levels, repeat=len(ordered)): + assignment = dict(zip(ordered, values, strict=True)) + if belief_assignment_satisfies(graph, assignment): + satisfying.append(assignment) + return tuple(satisfying) + + +def update_assignment( + graph: EpistemicGraph, + evidence: Mapping[str, float], +) -> dict[str, float]: + """Update a belief assignment in the monotone influence fragment.""" + unknown = sorted(set(evidence) - graph.arguments) + if unknown: + raise ValueError(f"evidence references unknown arguments: {unknown!r}") + assignment = {argument: 0.5 for argument in graph.arguments} + for argument, value in evidence.items(): + if not 0.0 <= value <= 1.0: + raise ValueError("evidence values must lie in [0, 1]") + assignment[argument] = float(value) + + changed = True + while changed: + changed = False + for influence in sorted( + graph.influences, + key=lambda item: (item.source, item.target, item.kind.value), + ): + source = assignment[influence.source] + target = assignment[influence.target] + if influence.kind == InfluenceKind.POSITIVE and target < source: + assignment[influence.target] = source + changed = True + elif influence.kind == InfluenceKind.NEGATIVE and target > 1.0 - source: + assignment[influence.target] = 1.0 - source + changed = True + return { + argument: round(assignment[argument], 12) + for argument in sorted(graph.arguments) + } + + +def project_to_constellation_praf(graph: EpistemicGraph) -> ProbabilisticAF: + """Project influences to a constellation PrAF where the mapping is defined.""" + constraints = _constraint_by_argument(graph) + p_args = { + argument: constraints[argument].lower + if argument in constraints and constraints[argument].lower > 0.0 + else constraints[argument].upper + if argument in constraints + else 1.0 + for argument in graph.arguments + } + defeats = frozenset( + (influence.source, influence.target) + for influence in graph.influences + if influence.kind == InfluenceKind.NEGATIVE + ) + supports = frozenset( + (influence.source, influence.target) + for influence in graph.influences + if influence.kind == InfluenceKind.POSITIVE + ) + return ProbabilisticAF( + framework=ArgumentationFramework(arguments=graph.arguments, defeats=defeats), + p_args=p_args, + p_defeats={defeat: 1.0 for defeat in defeats}, + supports=supports, + p_supports={support: 1.0 for support in supports}, + ) diff --git a/src/argumentation/probabilistic.py b/src/argumentation/probabilistic/probabilistic.py similarity index 95% rename from src/argumentation/probabilistic.py rename to src/argumentation/probabilistic/probabilistic.py index 04f4e65..632e41f 100644 --- a/src/argumentation/probabilistic.py +++ b/src/argumentation/probabilistic/probabilistic.py @@ -1,1479 +1,1479 @@ -"""Probabilistic argumentation over primitive relation worlds. - -This module keeps uncertainty on primitive arguments, attacks, and supports, -then realizes semantic AFs per sampled world. Direct defeats are primitive -semantic relations; Cayrol derived defeats are world-derived consequences. - -Monte Carlo uses Agresti-Coull stopping per Li et al. (2012, Algorithm 1). -Connected component decomposition follows Hunter & Thimm (2017, Prop 18) -over the primitive semantic dependency graph. -""" - -from __future__ import annotations - -import math -import random as _random_mod -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - -from argumentation.probabilistic_components import connected_components - -_Z_SCORES = {0.90: 1.645, 0.95: 1.960, 0.99: 2.576} -_DETERMINISTIC_EPSILON = 1e-12 -_UNSET = object() -_ALLOWED_STRATEGIES = frozenset({ - "auto", - "deterministic", - "mc", - "exact", - "exact_enum", - "exact_dp", - "paper_td", - "dfquad", - "dfquad_quad", - "dfquad_baf", -}) -_STRATEGY_ALIASES = { - "exact": "exact_enum", -} - - -def _z_for_confidence(confidence: float) -> float: - """Z-score for two-tailed confidence interval.""" - if confidence in _Z_SCORES: - return _Z_SCORES[confidence] - if not 0.0 < confidence < 1.0: - raise ValueError(f"mc_confidence must be in (0,1), got {confidence}") - p = 1.0 - (1.0 - confidence) / 2.0 - return _inverse_standard_normal_cdf(p) - - -def _inverse_standard_normal_cdf(p: float) -> float: - """Acklam inverse-normal CDF approximation for dependency-free z scores.""" - a = ( - -3.969683028665376e01, - 2.209460984245205e02, - -2.759285104469687e02, - 1.383577518672690e02, - -3.066479806614716e01, - 2.506628277459239e00, - ) - b = ( - -5.447609879822406e01, - 1.615858368580409e02, - -1.556989798598866e02, - 6.680131188771972e01, - -1.328068155288572e01, - ) - c = ( - -7.784894002430293e-03, - -3.223964580411365e-01, - -2.400758277161838e00, - -2.549732539343734e00, - 4.374664141464968e00, - 2.938163982698783e00, - ) - d = ( - 7.784695709041462e-03, - 3.224671290700398e-01, - 2.445134137142996e00, - 3.754408661907416e00, - ) - low = 0.02425 - high = 1.0 - low - if p < low: - q = math.sqrt(-2.0 * math.log(p)) - return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( - ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q) + 1.0 - ) - if p > high: - q = math.sqrt(-2.0 * math.log(1.0 - p)) - return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( - ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q) + 1.0 - ) - q = p - 0.5 - r = q * q - return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / ( - (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r) + 1.0 - ) - - -def _normalize_strategy(strategy: str) -> tuple[str, str]: - """Normalize public strategy aliases and reject unsupported values.""" - requested = str(strategy) - if requested not in _ALLOWED_STRATEGIES: - supported = ", ".join(sorted(_ALLOWED_STRATEGIES)) - raise ValueError(f"Unknown strategy: {requested}. Supported strategies: {supported}") - return _STRATEGY_ALIASES.get(requested, requested), requested - -from argumentation.dung import ( - ArgumentationFramework, - complete_extensions, - grounded_extension, - preferred_extensions, - stable_extensions, -) - - -ProbabilityValue = float - - -def _validate_probability(value: ProbabilityValue, name: str) -> float: - probability = float(value) - if not 0.0 <= probability <= 1.0: - raise ValueError(f"{name} must be in [0,1], got {probability}") - return probability - - -def _normalize_relation( - name: str, - relation: frozenset[tuple[str, str]], - arguments: frozenset[str], -) -> frozenset[tuple[str, str]]: - normalized = frozenset((str(source), str(target)) for source, target in relation) - unknown = sorted( - (source, target) - for source, target in normalized - if source not in arguments or target not in arguments - ) - if unknown: - raise ValueError( - f"{name} must only contain pairs over framework arguments: {unknown!r}" - ) - return normalized - - -def _validate_probability_keys( - name: str, - probabilities: Mapping[tuple[str, str], ProbabilityValue], - relation: frozenset[tuple[str, str]], -) -> None: - extra = sorted(edge for edge in probabilities if edge not in relation) - if extra: - raise ValueError(f"{name} contains undeclared relation pairs: {extra!r}") - - -def _expectation(value: ProbabilityValue | None) -> float: - if value is None: - return 1.0 - return float(value) - -@dataclass(frozen=True) -class ProbabilisticAF: - """Probabilistic AF with primitive-relation uncertainty. - - framework: the semantic AF envelope used for deterministic evaluation. - p_args: P_A per argument. - p_defeats: direct defeat probabilities. - p_attacks: optional primitive attack probabilities when attacks and defeats differ. - supports / p_supports: optional primitive support relations with existence probabilities. - base_defeats: optional direct defeats before Cayrol closure; defaults to framework.defeats. - """ - - framework: ArgumentationFramework - p_args: Mapping[str, ProbabilityValue] - p_defeats: Mapping[tuple[str, str], ProbabilityValue] - p_attacks: Mapping[tuple[str, str], ProbabilityValue] | None = None - supports: frozenset[tuple[str, str]] = frozenset() - p_supports: Mapping[tuple[str, str], ProbabilityValue] | None = None - base_defeats: frozenset[tuple[str, str]] | None = None - - def __post_init__(self) -> None: - framework_args = frozenset(self.framework.arguments) - supports = _normalize_relation("supports", self.supports, framework_args) - object.__setattr__(self, "supports", supports) - - if self.base_defeats is not None: - base_defeats = _normalize_relation( - "base_defeats", - self.base_defeats, - framework_args, - ) - primitive_attacks = _primitive_attacks(self) - extra_base_defeats = sorted( - edge for edge in base_defeats if edge not in primitive_attacks - ) - if extra_base_defeats: - raise ValueError( - "base_defeats must be a subset of primitive attacks: " - f"{extra_base_defeats!r}" - ) - object.__setattr__(self, "base_defeats", base_defeats) - - object.__setattr__( - self, - "p_args", - {str(arg): _validate_probability(probability, f"p_args[{arg!r}]") for arg, probability in self.p_args.items()}, - ) - p_arg_keys = set(self.p_args) - if p_arg_keys != set(framework_args): - missing = sorted(framework_args - p_arg_keys) - extra = sorted(p_arg_keys - framework_args) - details: list[str] = [] - if missing: - details.append(f"missing={missing!r}") - if extra: - details.append(f"extra={extra!r}") - raise ValueError( - "p_args must contain exactly the framework arguments" - f": {', '.join(details)}" - ) - - object.__setattr__( - self, - "p_defeats", - { - (str(src), str(tgt)): _validate_probability(probability, f"p_defeats[{(src, tgt)!r}]") - for (src, tgt), probability in self.p_defeats.items() - }, - ) - _validate_probability_keys("p_defeats", self.p_defeats, _direct_defeats(self)) - if self.p_attacks is not None: - object.__setattr__( - self, - "p_attacks", - { - (str(src), str(tgt)): _validate_probability(probability, f"p_attacks[{(src, tgt)!r}]") - for (src, tgt), probability in self.p_attacks.items() - }, - ) - _validate_probability_keys( - "p_attacks", - self.p_attacks, - _primitive_attacks(self), - ) - if self.p_supports is not None: - object.__setattr__( - self, - "p_supports", - { - (str(src), str(tgt)): _validate_probability(probability, f"p_supports[{(src, tgt)!r}]") - for (src, tgt), probability in self.p_supports.items() - }, - ) - _validate_probability_keys("p_supports", self.p_supports, self.supports) - - @property - def argument_probabilities(self) -> Mapping[str, ProbabilityValue]: - return self.p_args - - @property - def direct_defeat_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue]: - return self.p_defeats - - @property - def attack_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue] | None: - return self.p_attacks - - @property - def support_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue] | None: - return self.p_supports - - -ProbabilisticArgumentationFramework = ProbabilisticAF - - -@dataclass(frozen=True) -class PrAFResult: - """Result of probabilistic extension computation. - - `query_kind` and `inference_mode` make the queried object explicit: - argument-acceptance vs exact-set extension probability, and credulous - vs skeptical singleton acceptance for multi-extension semantics. - """ - - acceptance_probs: dict[str, float] | None = None - extension_probability: float | None = None - strategy_used: str = "" - strategy_requested: str | None = None - downgraded_from: str | None = None - samples: int | None = None - confidence_interval_half: float | None = None - semantics: str = "grounded" - query_kind: str = "argument_acceptance" - inference_mode: str | None = "credulous" - queried_set: tuple[str, ...] | None = None - strategy_metadata: Mapping[str, Any] | None = None - - def __post_init__(self) -> None: - if self.strategy_requested is None: - object.__setattr__(self, "strategy_requested", self.strategy_used) - if self.queried_set is not None: - object.__setattr__( - self, - "queried_set", - tuple(sorted(dict.fromkeys(str(arg) for arg in self.queried_set))), - ) - - -def _is_deterministic_opinion(opinion: ProbabilityValue | None) -> bool: - if opinion is None: - return True - expectation = _expectation(opinion) - return ( - expectation >= 1.0 - _DETERMINISTIC_EPSILON - or expectation <= _DETERMINISTIC_EPSILON - ) - - -def _edge_is_present(opinion: ProbabilityValue | None) -> bool: - if opinion is None: - return True - return _expectation(opinion) >= 1.0 - _DETERMINISTIC_EPSILON - - -def _primitive_attacks(praf: ProbabilisticAF) -> frozenset[tuple[str, str]]: - if praf.framework.attacks is not None: - return praf.framework.attacks - return praf.framework.defeats - - -def _direct_defeats(praf: ProbabilisticAF) -> frozenset[tuple[str, str]]: - if praf.base_defeats is not None: - return praf.base_defeats - return praf.framework.defeats - - -def _attack_opinion( - praf: ProbabilisticAF, - edge: tuple[str, str], -) -> ProbabilityValue | None: - if praf.p_attacks is not None and edge in praf.p_attacks: - return praf.p_attacks[edge] - if edge in _direct_defeats(praf) and edge in praf.p_defeats: - return praf.p_defeats[edge] - return None - - -def _support_opinion( - praf: ProbabilisticAF, - edge: tuple[str, str], -) -> ProbabilityValue | None: - if praf.p_supports is not None and edge in praf.p_supports: - return praf.p_supports[edge] - return None - - -def _sample_edge( - rng: _random_mod.Random, - opinion: ProbabilityValue | None, -) -> bool: - if opinion is None: - return True - return rng.random() < _expectation(opinion) - - -def _supports_structure(praf: ProbabilisticAF) -> bool: - return bool(praf.supports) - - -def _uses_attack_only_conflicts(praf: ProbabilisticAF) -> bool: - return ( - praf.framework.attacks is not None - and praf.framework.attacks != praf.framework.defeats - ) - - -def _requires_relation_rich_worlds(praf: ProbabilisticAF) -> bool: - return _supports_structure(praf) or _uses_attack_only_conflicts(praf) - - -def _all_structure_deterministic(praf: ProbabilisticAF) -> bool: - if not all(_is_deterministic_opinion(p) for p in praf.p_args.values()): - return False - for edge in _primitive_attacks(praf): - opinion = _attack_opinion(praf, edge) - if not _is_deterministic_opinion(opinion): - return False - for edge in praf.supports: - opinion = _support_opinion(praf, edge) - if not _is_deterministic_opinion(opinion): - return False - return True - - -def _normalize_queried_set( - queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None, -) -> tuple[str, ...] | None: - if queried_set is None: - return None - return tuple(sorted(dict.fromkeys(str(arg) for arg in queried_set))) - - -def _validate_query_contract( - *, - strategy: str, - query_kind: object, - inference_mode: object, - queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None, -) -> tuple[str, str | None, tuple[str, ...] | None]: - """Validate explicit query selectors for PrAF acceptance queries.""" - if strategy in {"dfquad", "dfquad_quad", "dfquad_baf"}: - return "gradual_strength", None, None - - if query_kind is _UNSET: - if inference_mode is not _UNSET and inference_mode is not None: - raise ValueError("inference_mode requires an explicit query_kind") - return "argument_acceptance", "credulous", None - - query_kind_str = str(query_kind) - normalized_queried_set = _normalize_queried_set(queried_set) - - if query_kind_str == "argument_acceptance": - if inference_mode is _UNSET or inference_mode is None: - raise ValueError( - "argument_acceptance requires inference_mode='credulous' or 'skeptical'" - ) - inference_mode_str = str(inference_mode) - if inference_mode_str not in {"credulous", "skeptical"}: - raise ValueError( - "argument_acceptance requires inference_mode='credulous' or 'skeptical'" - ) - if normalized_queried_set is not None: - raise ValueError("argument_acceptance does not use queried_set") - return query_kind_str, inference_mode_str, None - - if query_kind_str == "extension_probability": - if inference_mode is not _UNSET and inference_mode is not None: - raise ValueError("extension_probability does not use inference_mode") - if normalized_queried_set is None: - raise ValueError("extension_probability requires queried_set") - return query_kind_str, None, normalized_queried_set - - raise ValueError(f"Unknown query_kind: {query_kind_str}") - - -def _exact_dp_supports_query( - *, - query_kind: str, - inference_mode: str | None, -) -> bool: - if query_kind == "argument_acceptance": - return inference_mode == "credulous" - return False - - -def _extensions_for_semantics( - af: ArgumentationFramework, - semantics: str, -) -> tuple[frozenset[str], ...]: - if semantics == "grounded": - return (grounded_extension(af),) - if semantics == "preferred": - return tuple(preferred_extensions(af)) - if semantics == "stable": - return tuple(stable_extensions(af)) - if semantics == "complete": - return tuple(complete_extensions(af)) - raise ValueError(f"Unknown semantics: {semantics}") - - -def _accepted_arguments_from_extensions( - extensions: tuple[frozenset[str], ...], - *, - inference_mode: str, -) -> frozenset[str]: - if not extensions: - return frozenset() - if inference_mode == "credulous": - accepted = set().union(*extensions) - return frozenset(accepted) - if inference_mode == "skeptical": - accepted = set(extensions[0]) - for extension in extensions[1:]: - accepted &= set(extension) - return frozenset(accepted) - raise ValueError(f"Unknown inference_mode: {inference_mode}") - - -def _evaluate_world_query( - af: ArgumentationFramework, - *, - semantics: str, - query_kind: str, - inference_mode: str | None, - queried_set: tuple[str, ...] | None, -) -> frozenset[str] | bool: - extensions = _extensions_for_semantics(af, semantics) - if query_kind == "argument_acceptance": - assert inference_mode is not None - return _accepted_arguments_from_extensions( - extensions, - inference_mode=inference_mode, - ) - if query_kind == "extension_probability": - target = frozenset(queried_set or ()) - return target in set(extensions) - raise ValueError(f"Unknown query_kind: {query_kind}") - - -def _with_strategy_override( - result: PrAFResult, - *, - strategy_requested: str, - downgraded_from: str | None = None, -) -> PrAFResult: - return PrAFResult( - acceptance_probs=result.acceptance_probs, - extension_probability=result.extension_probability, - strategy_used=result.strategy_used, - strategy_requested=strategy_requested, - downgraded_from=downgraded_from, - samples=result.samples, - confidence_interval_half=result.confidence_interval_half, - semantics=result.semantics, - query_kind=result.query_kind, - inference_mode=result.inference_mode, - queried_set=result.queried_set, - ) - - -def _deterministic_world( - praf: ProbabilisticAF, - arg_subset: frozenset[str] | None = None, -) -> ArgumentationFramework: - """Realize the unique deterministic world for a deterministic PrAF.""" - args_to_consider = arg_subset if arg_subset is not None else praf.framework.arguments - sampled_args = frozenset( - a for a in args_to_consider - if _edge_is_present(praf.p_args[a]) - ) - sampled_attacks = frozenset( - edge for edge in _primitive_attacks(praf) - if edge[0] in sampled_args - and edge[1] in sampled_args - and _edge_is_present(_attack_opinion(praf, edge)) - ) - sampled_supports = frozenset( - edge for edge in praf.supports - if edge[0] in sampled_args - and edge[1] in sampled_args - and _edge_is_present(_support_opinion(praf, edge)) - ) - return _build_sampled_framework(praf, sampled_args, sampled_attacks, sampled_supports) - - -def _build_sampled_framework( - praf: ProbabilisticAF, - sampled_args: frozenset[str], - sampled_attacks: frozenset[tuple[str, str]], - sampled_supports: frozenset[tuple[str, str]], -) -> ArgumentationFramework: - """Build one sampled world, deriving Cayrol defeats after sampling primitives.""" - direct_defeats = frozenset( - edge for edge in sampled_attacks if edge in _direct_defeats(praf) - ) - all_defeats = set(direct_defeats) - if sampled_supports and direct_defeats: - from argumentation.bipolar import cayrol_derived_defeats - - all_defeats |= set(cayrol_derived_defeats(frozenset(direct_defeats), frozenset(sampled_supports))) - - sampled_attacks_relation: frozenset[tuple[str, str]] | None = None - if praf.framework.attacks is not None: - sampled_attacks_relation = sampled_attacks - - return ArgumentationFramework( - arguments=sampled_args, - defeats=frozenset(all_defeats), - attacks=sampled_attacks_relation, - ) - - -def _enumerate_worlds( - praf: ProbabilisticAF, - sampled_args: frozenset[str], -): - """Enumerate all sampled worlds induced by a fixed argument subset.""" - deterministic_attacks: set[tuple[str, str]] = set() - probabilistic_attacks: list[tuple[tuple[str, str], float]] = [] - for edge in sorted(_primitive_attacks(praf)): - if edge[0] not in sampled_args or edge[1] not in sampled_args: - continue - opinion = _attack_opinion(praf, edge) - if opinion is None: - deterministic_attacks.add(edge) - else: - probabilistic_attacks.append((edge, _expectation(opinion))) - - deterministic_supports: set[tuple[str, str]] = set() - probabilistic_supports: list[tuple[tuple[str, str], float]] = [] - for edge in sorted(praf.supports): - if edge[0] not in sampled_args or edge[1] not in sampled_args: - continue - opinion = _support_opinion(praf, edge) - if opinion is None: - deterministic_supports.add(edge) - else: - probabilistic_supports.append((edge, _expectation(opinion))) - - n_prob_attacks = len(probabilistic_attacks) - n_prob_supports = len(probabilistic_supports) - - for attack_mask in range(1 << n_prob_attacks): - sampled_attacks = set(deterministic_attacks) - p_attacks_config = 1.0 - for idx, (edge, p_edge) in enumerate(probabilistic_attacks): - if attack_mask & (1 << idx): - p_attacks_config *= p_edge - sampled_attacks.add(edge) - else: - p_attacks_config *= (1.0 - p_edge) - if p_attacks_config < 1e-15: - continue - - for support_mask in range(1 << n_prob_supports): - sampled_supports = set(deterministic_supports) - p_supports_config = 1.0 - for idx, (edge, p_edge) in enumerate(probabilistic_supports): - if support_mask & (1 << idx): - p_supports_config *= p_edge - sampled_supports.add(edge) - else: - p_supports_config *= (1.0 - p_edge) - - total_prob = p_attacks_config * p_supports_config - if total_prob < 1e-15: - continue - - yield total_prob, _build_sampled_framework( - praf, - sampled_args, - frozenset(sampled_attacks), - frozenset(sampled_supports), - ) - - -def _compute_probabilistic_acceptance( - praf: ProbabilisticAF, - *, - semantics: str = "grounded", - strategy: str = "auto", - query_kind: object = _UNSET, - inference_mode: object = _UNSET, - queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None = None, - tau: dict[str, float] | None = None, - mc_epsilon: float = 0.01, - mc_confidence: float = 0.95, - treewidth_cutoff: int = 12, - rng_seed: int | None = None, -) -> PrAFResult: - """Main dispatch for PrAF acceptance computation. - - Per Li et al. (2012, Algorithm 1): MC sampler with Agresti-Coull stopping. - Per Hunter & Thimm (2017, Prop 18): connected component decomposition. - - query_kind: - "argument_acceptance" — per-argument acceptance probabilities. - "extension_probability" — probability that the exact queried_set is an extension. - - inference_mode: - Required only for argument_acceptance: "credulous" or "skeptical". - """ - normalized_strategy, requested_strategy = _normalize_strategy(strategy) - normalized_query_kind, normalized_inference_mode, normalized_queried_set = _validate_query_contract( - strategy=normalized_strategy, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - if normalized_strategy == "deterministic": - result = _deterministic_fallback( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - if normalized_strategy == "mc": - result = _compute_mc( - praf, - semantics, - mc_epsilon, - mc_confidence, - rng_seed, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - if normalized_strategy == "exact_enum": - result = _compute_exact_enumeration( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - if normalized_strategy == "exact_dp": - from argumentation.probabilistic_treedecomp import supports_exact_dp - - if not _exact_dp_supports_query( - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - ): - raise ValueError( - "exact_dp only supports credulous argument acceptance queries" - ) - if not supports_exact_dp(praf, semantics): - raise ValueError( - "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" - ) - result = _compute_exact_dp( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - if normalized_strategy == "paper_td": - result = _compute_paper_td( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - if normalized_strategy == "dfquad": - raise ValueError( - "strategy='dfquad' is ambiguous; use 'dfquad_quad' or 'dfquad_baf'" - ) - if normalized_strategy in {"dfquad_quad", "dfquad_baf"}: - result = _compute_dfquad( - praf, - semantics, - strategy=normalized_strategy, - tau=tau, - ) - return ( - _with_strategy_override(result, strategy_requested=requested_strategy) - if requested_strategy != normalized_strategy else result - ) - - # Auto dispatch - # Fast path: if all P_D expectations are ~1.0 and all P_A expectations are ~1.0, - # this is a deterministic AF — no sampling needed. - # Per Li (2012, p.2): PrAF with P_A=1, P_D=1 equals standard Dung evaluation. - all_deterministic = _all_structure_deterministic(praf) - if all_deterministic: - return _deterministic_fallback( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - - # Small AF: exact enumeration (Li 2012, p.8: exact beats MC below ~13 args) - n_args = len(praf.framework.arguments) - if n_args <= 13: - return _compute_exact_enumeration( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - - # Relation-rich worlds currently require exact enumeration or MC. - if _requires_relation_rich_worlds(praf): - return _compute_mc( - praf, - semantics, - mc_epsilon, - mc_confidence, - rng_seed, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - - # Medium AF with low treewidth: exact DP (Popescu & Wallner 2024) - # Per plan Section 2.4: estimate treewidth, use DP if below cutoff. - from argumentation.probabilistic_treedecomp import estimate_treewidth - - tw = estimate_treewidth(praf.framework) - if ( - tw <= treewidth_cutoff - and _exact_dp_supports_query( - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - ) - ): - from argumentation.probabilistic_treedecomp import supports_exact_dp - - if supports_exact_dp(praf, semantics): - return _compute_exact_dp( - praf, - semantics, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - ) - - # Default: MC - return _compute_mc( - praf, - semantics, - mc_epsilon, - mc_confidence, - rng_seed, - query_kind=normalized_query_kind, - inference_mode=normalized_inference_mode, - queried_set=normalized_queried_set, - ) - - -def _deterministic_fallback( - praf: ProbabilisticAF, - semantics: str, - *, - query_kind: str, - inference_mode: str | None, - queried_set: tuple[str, ...] | None, -) -> PrAFResult: - """All P_D ≈ 1.0 case: run standard Dung evaluation. - - Per Li (2012, p.2): PrAF with P_A=1, P_D=1 yields acceptance probabilities - of exactly 0.0 or 1.0, matching standard Dung extension computation. - """ - deterministic_af = _deterministic_world(praf) - evaluation = _evaluate_world_query( - deterministic_af, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - if query_kind == "argument_acceptance": - assert isinstance(evaluation, frozenset) - acceptance: dict[str, float] = {} - for arg in deterministic_af.arguments: - acceptance[arg] = 1.0 if arg in evaluation else 0.0 - for arg in praf.framework.arguments - deterministic_af.arguments: - acceptance[arg] = 0.0 - return PrAFResult( - acceptance_probs=acceptance, - extension_probability=None, - strategy_used="deterministic", - strategy_requested="deterministic", - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=None, - ) - - assert isinstance(evaluation, bool) - return PrAFResult( - acceptance_probs=None, - extension_probability=1.0 if evaluation else 0.0, - strategy_used="deterministic", - strategy_requested="deterministic", - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=semantics, - query_kind=query_kind, - inference_mode=None, - queried_set=queried_set, - ) - -def _sample_subgraph( - praf: ProbabilisticAF, - rng: _random_mod.Random, - arg_subset: set[str] | None = None, -) -> ArgumentationFramework: - """Sample one semantic AF world from primitive probabilistic relations. - - Per Li et al. (2012, Algorithm 1, p.5): - 1. For each argument a, include with probability P_A(a).expectation() - 2. For each primitive attack/support where both endpoints are included, - sample existence from its opinion expectation - 3. Derive direct defeats from sampled attacks and direct-defeat policy - 4. Derive Cayrol defeats from sampled direct defeats and supports - """ - args_to_sample = arg_subset if arg_subset is not None else praf.framework.arguments - - # Step 1: Sample arguments - sampled_args: set[str] = set() - for a in args_to_sample: - p_a = _expectation(praf.p_args[a]) - if rng.random() < p_a: - sampled_args.add(a) - - sampled_attacks: set[tuple[str, str]] = set() - for edge in _primitive_attacks(praf): - if edge[0] in sampled_args and edge[1] in sampled_args: - if _sample_edge(rng, _attack_opinion(praf, edge)): - sampled_attacks.add(edge) - - sampled_supports: set[tuple[str, str]] = set() - for edge in praf.supports: - if edge[0] in sampled_args and edge[1] in sampled_args: - if _sample_edge(rng, _support_opinion(praf, edge)): - sampled_supports.add(edge) - - return _build_sampled_framework( - praf, - frozenset(sampled_args), - frozenset(sampled_attacks), - frozenset(sampled_supports), - ) - - -def _compute_mc( - praf: ProbabilisticAF, - semantics: str, - epsilon: float, - confidence: float, - seed: int | None, - *, - query_kind: str, - inference_mode: str | None, - queried_set: tuple[str, ...] | None, -) -> PrAFResult: - """Monte Carlo sampler with per-argument acceptance and Agresti-Coull stopping. - - Per Li et al. (2012, Algorithm 1, p.5): sample DAFs, evaluate semantics, - accumulate per-argument acceptance counts. - - Stopping criterion per Li et al. (2012, Eq. 5, p.7): - N > 4 * p' * (1 - p') / epsilon^2 - 4 - where p' is the observed proportion for the argument with widest CI. - - Min 30 samples before convergence check. - """ - rng = _random_mod.Random(seed) - z = _z_for_confidence(confidence) - - if query_kind == "extension_probability": - hits = 0 - n = 0 - min_samples = 30 - while True: - n += 1 - sub_af = _sample_subgraph(praf, rng) - evaluation = _evaluate_world_query( - sub_af, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - assert isinstance(evaluation, bool) - if evaluation: - hits += 1 - - if n >= min_samples: - adjusted_n = n + z * z - adjusted_count = hits + (z * z) / 2.0 - adjusted_p = adjusted_count / adjusted_n - ci_half = z * math.sqrt( - adjusted_p * (1.0 - adjusted_p) / adjusted_n - ) - if ci_half <= epsilon: - break - - if n >= 100000: - break - - adjusted_n = n + z * z - adjusted_count = hits + (z * z) / 2.0 - adjusted_p = adjusted_count / adjusted_n - ci_half = z * math.sqrt( - adjusted_p * (1.0 - adjusted_p) / adjusted_n - ) - return PrAFResult( - acceptance_probs=None, - extension_probability=hits / n if n > 0 else 0.0, - strategy_used="mc", - strategy_requested="mc", - downgraded_from=None, - samples=n, - confidence_interval_half=ci_half, - semantics=semantics, - query_kind=query_kind, - inference_mode=None, - queried_set=queried_set, - ) - - # Decompose into connected components per Hunter & Thimm (2017, Prop 18) - components = connected_components(praf) - - # Compute acceptance per component independently - all_acceptance: dict[str, float] = {} - total_samples = 0 - max_ci_half = 0.0 - - for comp_args in components: - # Build sub-PrAF for this component - comp_defeats = frozenset( - (f, t) for f, t in praf.framework.defeats - if f in comp_args and t in comp_args - ) - comp_attacks = None - if praf.framework.attacks is not None: - comp_attacks = frozenset( - (f, t) for f, t in praf.framework.attacks - if f in comp_args and t in comp_args - ) - comp_supports = frozenset( - (f, t) for f, t in praf.supports - if f in comp_args and t in comp_args - ) - comp_base_defeats = frozenset( - (f, t) for f, t in _direct_defeats(praf) - if f in comp_args and t in comp_args - ) - - comp_af = ArgumentationFramework( - arguments=frozenset(comp_args), - defeats=comp_defeats, - attacks=comp_attacks, - ) - comp_p_args = {a: praf.p_args[a] for a in comp_args} - comp_p_defeats = { - d: praf.p_defeats[d] for d in comp_defeats - if d in praf.p_defeats - } - comp_p_attacks = None - if praf.p_attacks is not None: - comp_p_attacks = { - d: praf.p_attacks[d] for d in comp_attacks or frozenset() - if d in praf.p_attacks - } - comp_p_supports = None - if praf.p_supports is not None: - comp_p_supports = { - d: praf.p_supports[d] for d in comp_supports - if d in praf.p_supports - } - - comp_praf = ProbabilisticAF( - framework=comp_af, - p_args=comp_p_args, - p_defeats=comp_p_defeats, - p_attacks=comp_p_attacks, - supports=comp_supports, - p_supports=comp_p_supports, - base_defeats=comp_base_defeats, - ) - - # Check if this component is deterministic - comp_all_det = _all_structure_deterministic(comp_praf) - if comp_all_det: - evaluation = _evaluate_world_query( - _deterministic_world(comp_praf), - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - if query_kind == "argument_acceptance": - assert isinstance(evaluation, frozenset) - for arg in comp_args: - all_acceptance[arg] = 1.0 if arg in evaluation else 0.0 - continue - - # MC sampling for this component - counts: dict[str, int] = {a: 0 for a in comp_args} - n = 0 - min_samples = 30 - - while True: - n += 1 - sub_af = _sample_subgraph(comp_praf, rng, comp_args) - evaluation = _evaluate_world_query( - sub_af, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - if query_kind == "argument_acceptance": - assert isinstance(evaluation, frozenset) - for a in comp_args: - if a in evaluation: - counts[a] += 1 - - # Agresti-Coull stopping (Li 2012, Eq. 5, p.7) - if n >= min_samples: - converged = True - for a in comp_args: - adjusted_n = n + z * z - adjusted_count = counts[a] + (z * z) / 2.0 - adjusted_p = adjusted_count / adjusted_n - ci_half = z * math.sqrt( - adjusted_p * (1.0 - adjusted_p) / adjusted_n - ) - if ci_half > epsilon: - converged = False - break - if converged: - break - - # Safety cap to prevent infinite loops - if n >= 100000: - break - - for a in comp_args: - all_acceptance[a] = counts[a] / n - - total_samples = max(total_samples, n) - - # Compute CI half-width for this component - if n > 0: - for a in comp_args: - adjusted_n = n + z * z - adjusted_count = counts[a] + (z * z) / 2.0 - adjusted_p = adjusted_count / adjusted_n - ci = z * math.sqrt( - adjusted_p * (1.0 - adjusted_p) / adjusted_n - ) - max_ci_half = max(max_ci_half, ci) - - return PrAFResult( - acceptance_probs=all_acceptance, - extension_probability=None, - strategy_used="mc", - strategy_requested="mc", - downgraded_from=None, - samples=total_samples, - confidence_interval_half=max_ci_half, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - -def _compute_exact_enumeration( - praf: ProbabilisticAF, - semantics: str, - *, - query_kind: str, - inference_mode: str | None, - queried_set: tuple[str, ...] | None, -) -> PrAFResult: - """Brute-force exact computation for small AFs. - - Per Li et al. (2012, p.3-4): enumerate all inducible DAFs, compute - P_PrAF(AF) for each, sum probabilities where argument is in extension. - - Complexity: O(2^(|A|+|D|)) — only feasible for small AFs. - """ - args_list = sorted(praf.framework.arguments) - n_args = len(args_list) - - acceptance: dict[str, float] = {a: 0.0 for a in args_list} - extension_probability = 0.0 - - # Enumerate all subsets of arguments - for arg_mask in range(1 << n_args): - sampled_args = frozenset( - args_list[i] for i in range(n_args) if arg_mask & (1 << i) - ) - - # Compute probability of this argument subset - p_args_present = 1.0 - for i, a in enumerate(args_list): - p_a = _expectation(praf.p_args[a]) - if arg_mask & (1 << i): - p_args_present *= p_a - else: - p_args_present *= (1.0 - p_a) - - if p_args_present < 1e-15: - continue - - # Find valid defeats (both endpoints present) - for p_world, sub_af in _enumerate_worlds(praf, sampled_args): - total_prob = p_args_present * p_world - if total_prob < 1e-15: - continue - evaluation = _evaluate_world_query( - sub_af, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - if query_kind == "argument_acceptance": - assert isinstance(evaluation, frozenset) - for a in sampled_args: - if a in evaluation: - acceptance[a] += total_prob - else: - assert isinstance(evaluation, bool) - if evaluation: - extension_probability += total_prob - - return PrAFResult( - acceptance_probs=acceptance if query_kind == "argument_acceptance" else None, - extension_probability=None if query_kind == "argument_acceptance" else extension_probability, - strategy_used="exact_enum", - strategy_requested="exact_enum", - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=queried_set, - ) - - -def _compute_exact_dp( - praf: ProbabilisticAF, - semantics: str, - *, - query_kind: str, - inference_mode: str | None, -) -> PrAFResult: - """Exact computation via tree-decomposition DP. - - Per Popescu & Wallner (2024): compute extension probabilities using - dynamic programming on tree decompositions. Tractable for low-treewidth - AFs (complexity O(3^k * n) where k is treewidth). - """ - from argumentation.probabilistic_treedecomp import compute_exact_dp - - if query_kind != "argument_acceptance" or inference_mode != "credulous": - raise ValueError("exact_dp currently only supports credulous argument acceptance") - - acceptance = compute_exact_dp(praf, semantics) - - return PrAFResult( - acceptance_probs=acceptance, - extension_probability=None, - strategy_used="exact_dp", - strategy_requested="exact_dp", - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=semantics, - query_kind=query_kind, - inference_mode=inference_mode, - queried_set=None, - strategy_metadata={ - "backend": "grounded_edge_tracking_td", - "paper_conformance": "adapted_not_popescu_iou_witness_dp", - }, - ) - - -def _compute_paper_td( - praf: ProbabilisticAF, - semantics: str, - *, - query_kind: str, - inference_mode: str | None, - queried_set: tuple[str, ...] | None, -) -> PrAFResult: - """Exact complete-extension probability via the paper-faithful TD backend.""" - if query_kind != "extension_probability" or inference_mode is not None: - raise ValueError("paper_td currently only supports extension_probability queries") - if queried_set is None: - raise ValueError("paper_td requires queried_set") - - from argumentation.probabilistic_treedecomp import ( - compute_paper_exact_extension_probability, - ) - - result = compute_paper_exact_extension_probability( - praf, - queried_set=frozenset(queried_set), - semantics=semantics, - ) - return PrAFResult( - acceptance_probs=None, - extension_probability=result.extension_probability, - strategy_used="paper_td", - strategy_requested="paper_td", - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=semantics, - query_kind=query_kind, - inference_mode=None, - queried_set=queried_set, - strategy_metadata={ - "backend": result.backend, - "paper_conformance": "popescu_wallner_2024_algorithm_1", - "treewidth": result.treewidth, - "node_count": result.node_count, - "root_table_rows": result.root_table_rows, - "root_probability_mass": result.root_probability_mass, - "table_summaries": result.table_summaries, - "argument_witnesses": result.argument_witnesses, - }, - ) - - -def summarize_defeat_relations( - praf: ProbabilisticAF, - *, - include_derived: bool = True, -) -> dict[tuple[str, str], float]: - """Compute exact defeat marginals as derived query results. - - This helper is explicit and potentially exponential. It is intended for - explanation and diagnostics, not as an input to the semantic core. - """ - args_list = sorted(praf.framework.arguments) - n_args = len(args_list) - acceptance: dict[tuple[str, str], float] = {} - - for arg_mask in range(1 << n_args): - sampled_args = frozenset( - args_list[i] for i in range(n_args) if arg_mask & (1 << i) - ) - - p_args_present = 1.0 - for i, a in enumerate(args_list): - p_a = _expectation(praf.p_args[a]) - if arg_mask & (1 << i): - p_args_present *= p_a - else: - p_args_present *= (1.0 - p_a) - - if p_args_present < 1e-15: - continue - - for p_world, sub_af in _enumerate_worlds(praf, sampled_args): - total_prob = p_args_present * p_world - if total_prob < 1e-15: - continue - for defeat in sub_af.defeats: - acceptance[defeat] = acceptance.get(defeat, 0.0) + total_prob - - direct_defeats = _direct_defeats(praf) - return { - edge: probability - for edge, probability in sorted(acceptance.items()) - if include_derived or edge in direct_defeats - } - - -def _compute_dfquad( - praf: ProbabilisticAF, - semantics: str, - *, - strategy: str, - tau: dict[str, float] | None = None, - supports: dict[tuple[str, str], float] | None = None, -) -> PrAFResult: - """DF-QuAD gradual semantics for QBAFs. - - Per Freedman et al. (2025, p.3): computes continuous strengths in [0,1] - by propagating base scores through attack and support relations. - - Complementary to PrAF MC/exact strategies which compute extension - membership probabilities. DF-QuAD computes graded argument strengths. - - **Design note:** Li 2012's P_A (argument existence probability for MC sampling) - is currently used as Rago 2016's τ (intrinsic strength for DF-QuAD gradual - semantics). These are conceptually distinct: a rarely-existing argument is not - the same as a weak argument. A principled separation would maintain P_A for - sampling and τ as an independent parameter. - """ - from argumentation.dfquad import dfquad_bipolar_strengths, dfquad_strengths - from argumentation.gradual import WeightedBipolarGraph - - if semantics != "grounded": - raise ValueError( - "DF-QuAD is a gradual semantics, not a Dung semantics label; " - "leave semantics at the default or use the dedicated DF-QuAD result." - ) - - if supports is None: - supports = {} - for edge in praf.supports: - opinion = _support_opinion(praf, edge) - supports[edge] = _expectation(opinion) - - if strategy == "dfquad_quad": - if tau is None: - raise ValueError("dfquad_quad requires explicit tau") - missing_tau = sorted(argument for argument in praf.framework.arguments if argument not in tau) - if missing_tau: - missing_text = ", ".join(missing_tau) - raise ValueError(f"missing tau for arguments: {missing_text}") - initial_weights = tau - else: - initial_weights = {argument: 0.5 for argument in praf.framework.arguments} - - graph = WeightedBipolarGraph( - arguments=praf.framework.arguments, - initial_weights=initial_weights, - attacks=praf.framework.defeats, - supports=frozenset(supports), - ) - - if strategy == "dfquad_quad": - result = dfquad_strengths(graph, base_scores=tau, support_weights=supports) - elif strategy == "dfquad_baf": - if tau is not None: - raise ValueError("dfquad_baf does not use tau") - result = dfquad_bipolar_strengths(graph) - else: - raise ValueError( - "strategy='dfquad' is ambiguous; use 'dfquad_quad' or 'dfquad_baf'" - ) - - return PrAFResult( - acceptance_probs=result.strengths, - extension_probability=None, - strategy_used=strategy, - strategy_requested=strategy, - downgraded_from=None, - samples=None, - confidence_interval_half=None, - semantics=strategy, - query_kind="gradual_strength", - inference_mode=None, - queried_set=None, - ) - - -def compute_probabilistic_acceptance(*args: Any, **kwargs: Any) -> PrAFResult: - return _compute_probabilistic_acceptance(*args, **kwargs) - - -ProbabilisticResult = PrAFResult - - -__all__ = [ - "ProbabilityValue", - "ProbabilisticAF", - "ProbabilisticArgumentationFramework", - "PrAFResult", - "ProbabilisticResult", - "compute_probabilistic_acceptance", - "summarize_defeat_relations", -] +"""Probabilistic argumentation over primitive relation worlds. + +This module keeps uncertainty on primitive arguments, attacks, and supports, +then realizes semantic AFs per sampled world. Direct defeats are primitive +semantic relations; Cayrol derived defeats are world-derived consequences. + +Monte Carlo uses Agresti-Coull stopping per Li et al. (2012, Algorithm 1). +Connected component decomposition follows Hunter & Thimm (2017, Prop 18) +over the primitive semantic dependency graph. +""" + +from __future__ import annotations + +import math +import random as _random_mod +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from argumentation.probabilistic.probabilistic_components import connected_components + +_Z_SCORES = {0.90: 1.645, 0.95: 1.960, 0.99: 2.576} +_DETERMINISTIC_EPSILON = 1e-12 +_UNSET = object() +_ALLOWED_STRATEGIES = frozenset({ + "auto", + "deterministic", + "mc", + "exact", + "exact_enum", + "exact_dp", + "paper_td", + "dfquad", + "dfquad_quad", + "dfquad_baf", +}) +_STRATEGY_ALIASES = { + "exact": "exact_enum", +} + + +def _z_for_confidence(confidence: float) -> float: + """Z-score for two-tailed confidence interval.""" + if confidence in _Z_SCORES: + return _Z_SCORES[confidence] + if not 0.0 < confidence < 1.0: + raise ValueError(f"mc_confidence must be in (0,1), got {confidence}") + p = 1.0 - (1.0 - confidence) / 2.0 + return _inverse_standard_normal_cdf(p) + + +def _inverse_standard_normal_cdf(p: float) -> float: + """Acklam inverse-normal CDF approximation for dependency-free z scores.""" + a = ( + -3.969683028665376e01, + 2.209460984245205e02, + -2.759285104469687e02, + 1.383577518672690e02, + -3.066479806614716e01, + 2.506628277459239e00, + ) + b = ( + -5.447609879822406e01, + 1.615858368580409e02, + -1.556989798598866e02, + 6.680131188771972e01, + -1.328068155288572e01, + ) + c = ( + -7.784894002430293e-03, + -3.223964580411365e-01, + -2.400758277161838e00, + -2.549732539343734e00, + 4.374664141464968e00, + 2.938163982698783e00, + ) + d = ( + 7.784695709041462e-03, + 3.224671290700398e-01, + 2.445134137142996e00, + 3.754408661907416e00, + ) + low = 0.02425 + high = 1.0 - low + if p < low: + q = math.sqrt(-2.0 * math.log(p)) + return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( + ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q) + 1.0 + ) + if p > high: + q = math.sqrt(-2.0 * math.log(1.0 - p)) + return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ( + ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q) + 1.0 + ) + q = p - 0.5 + r = q * q + return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / ( + (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r) + 1.0 + ) + + +def _normalize_strategy(strategy: str) -> tuple[str, str]: + """Normalize public strategy aliases and reject unsupported values.""" + requested = str(strategy) + if requested not in _ALLOWED_STRATEGIES: + supported = ", ".join(sorted(_ALLOWED_STRATEGIES)) + raise ValueError(f"Unknown strategy: {requested}. Supported strategies: {supported}") + return _STRATEGY_ALIASES.get(requested, requested), requested + +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + grounded_extension, + preferred_extensions, + stable_extensions, +) + + +ProbabilityValue = float + + +def _validate_probability(value: ProbabilityValue, name: str) -> float: + probability = float(value) + if not 0.0 <= probability <= 1.0: + raise ValueError(f"{name} must be in [0,1], got {probability}") + return probability + + +def _normalize_relation( + name: str, + relation: frozenset[tuple[str, str]], + arguments: frozenset[str], +) -> frozenset[tuple[str, str]]: + normalized = frozenset((str(source), str(target)) for source, target in relation) + unknown = sorted( + (source, target) + for source, target in normalized + if source not in arguments or target not in arguments + ) + if unknown: + raise ValueError( + f"{name} must only contain pairs over framework arguments: {unknown!r}" + ) + return normalized + + +def _validate_probability_keys( + name: str, + probabilities: Mapping[tuple[str, str], ProbabilityValue], + relation: frozenset[tuple[str, str]], +) -> None: + extra = sorted(edge for edge in probabilities if edge not in relation) + if extra: + raise ValueError(f"{name} contains undeclared relation pairs: {extra!r}") + + +def _expectation(value: ProbabilityValue | None) -> float: + if value is None: + return 1.0 + return float(value) + +@dataclass(frozen=True) +class ProbabilisticAF: + """Probabilistic AF with primitive-relation uncertainty. + + framework: the semantic AF envelope used for deterministic evaluation. + p_args: P_A per argument. + p_defeats: direct defeat probabilities. + p_attacks: optional primitive attack probabilities when attacks and defeats differ. + supports / p_supports: optional primitive support relations with existence probabilities. + base_defeats: optional direct defeats before Cayrol closure; defaults to framework.defeats. + """ + + framework: ArgumentationFramework + p_args: Mapping[str, ProbabilityValue] + p_defeats: Mapping[tuple[str, str], ProbabilityValue] + p_attacks: Mapping[tuple[str, str], ProbabilityValue] | None = None + supports: frozenset[tuple[str, str]] = frozenset() + p_supports: Mapping[tuple[str, str], ProbabilityValue] | None = None + base_defeats: frozenset[tuple[str, str]] | None = None + + def __post_init__(self) -> None: + framework_args = frozenset(self.framework.arguments) + supports = _normalize_relation("supports", self.supports, framework_args) + object.__setattr__(self, "supports", supports) + + if self.base_defeats is not None: + base_defeats = _normalize_relation( + "base_defeats", + self.base_defeats, + framework_args, + ) + primitive_attacks = _primitive_attacks(self) + extra_base_defeats = sorted( + edge for edge in base_defeats if edge not in primitive_attacks + ) + if extra_base_defeats: + raise ValueError( + "base_defeats must be a subset of primitive attacks: " + f"{extra_base_defeats!r}" + ) + object.__setattr__(self, "base_defeats", base_defeats) + + object.__setattr__( + self, + "p_args", + {str(arg): _validate_probability(probability, f"p_args[{arg!r}]") for arg, probability in self.p_args.items()}, + ) + p_arg_keys = set(self.p_args) + if p_arg_keys != set(framework_args): + missing = sorted(framework_args - p_arg_keys) + extra = sorted(p_arg_keys - framework_args) + details: list[str] = [] + if missing: + details.append(f"missing={missing!r}") + if extra: + details.append(f"extra={extra!r}") + raise ValueError( + "p_args must contain exactly the framework arguments" + f": {', '.join(details)}" + ) + + object.__setattr__( + self, + "p_defeats", + { + (str(src), str(tgt)): _validate_probability(probability, f"p_defeats[{(src, tgt)!r}]") + for (src, tgt), probability in self.p_defeats.items() + }, + ) + _validate_probability_keys("p_defeats", self.p_defeats, _direct_defeats(self)) + if self.p_attacks is not None: + object.__setattr__( + self, + "p_attacks", + { + (str(src), str(tgt)): _validate_probability(probability, f"p_attacks[{(src, tgt)!r}]") + for (src, tgt), probability in self.p_attacks.items() + }, + ) + _validate_probability_keys( + "p_attacks", + self.p_attacks, + _primitive_attacks(self), + ) + if self.p_supports is not None: + object.__setattr__( + self, + "p_supports", + { + (str(src), str(tgt)): _validate_probability(probability, f"p_supports[{(src, tgt)!r}]") + for (src, tgt), probability in self.p_supports.items() + }, + ) + _validate_probability_keys("p_supports", self.p_supports, self.supports) + + @property + def argument_probabilities(self) -> Mapping[str, ProbabilityValue]: + return self.p_args + + @property + def direct_defeat_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue]: + return self.p_defeats + + @property + def attack_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue] | None: + return self.p_attacks + + @property + def support_probabilities(self) -> Mapping[tuple[str, str], ProbabilityValue] | None: + return self.p_supports + + +ProbabilisticArgumentationFramework = ProbabilisticAF + + +@dataclass(frozen=True) +class PrAFResult: + """Result of probabilistic extension computation. + + `query_kind` and `inference_mode` make the queried object explicit: + argument-acceptance vs exact-set extension probability, and credulous + vs skeptical singleton acceptance for multi-extension semantics. + """ + + acceptance_probs: dict[str, float] | None = None + extension_probability: float | None = None + strategy_used: str = "" + strategy_requested: str | None = None + downgraded_from: str | None = None + samples: int | None = None + confidence_interval_half: float | None = None + semantics: str = "grounded" + query_kind: str = "argument_acceptance" + inference_mode: str | None = "credulous" + queried_set: tuple[str, ...] | None = None + strategy_metadata: Mapping[str, Any] | None = None + + def __post_init__(self) -> None: + if self.strategy_requested is None: + object.__setattr__(self, "strategy_requested", self.strategy_used) + if self.queried_set is not None: + object.__setattr__( + self, + "queried_set", + tuple(sorted(dict.fromkeys(str(arg) for arg in self.queried_set))), + ) + + +def _is_deterministic_opinion(opinion: ProbabilityValue | None) -> bool: + if opinion is None: + return True + expectation = _expectation(opinion) + return ( + expectation >= 1.0 - _DETERMINISTIC_EPSILON + or expectation <= _DETERMINISTIC_EPSILON + ) + + +def _edge_is_present(opinion: ProbabilityValue | None) -> bool: + if opinion is None: + return True + return _expectation(opinion) >= 1.0 - _DETERMINISTIC_EPSILON + + +def _primitive_attacks(praf: ProbabilisticAF) -> frozenset[tuple[str, str]]: + if praf.framework.attacks is not None: + return praf.framework.attacks + return praf.framework.defeats + + +def _direct_defeats(praf: ProbabilisticAF) -> frozenset[tuple[str, str]]: + if praf.base_defeats is not None: + return praf.base_defeats + return praf.framework.defeats + + +def _attack_opinion( + praf: ProbabilisticAF, + edge: tuple[str, str], +) -> ProbabilityValue | None: + if praf.p_attacks is not None and edge in praf.p_attacks: + return praf.p_attacks[edge] + if edge in _direct_defeats(praf) and edge in praf.p_defeats: + return praf.p_defeats[edge] + return None + + +def _support_opinion( + praf: ProbabilisticAF, + edge: tuple[str, str], +) -> ProbabilityValue | None: + if praf.p_supports is not None and edge in praf.p_supports: + return praf.p_supports[edge] + return None + + +def _sample_edge( + rng: _random_mod.Random, + opinion: ProbabilityValue | None, +) -> bool: + if opinion is None: + return True + return rng.random() < _expectation(opinion) + + +def _supports_structure(praf: ProbabilisticAF) -> bool: + return bool(praf.supports) + + +def _uses_attack_only_conflicts(praf: ProbabilisticAF) -> bool: + return ( + praf.framework.attacks is not None + and praf.framework.attacks != praf.framework.defeats + ) + + +def _requires_relation_rich_worlds(praf: ProbabilisticAF) -> bool: + return _supports_structure(praf) or _uses_attack_only_conflicts(praf) + + +def _all_structure_deterministic(praf: ProbabilisticAF) -> bool: + if not all(_is_deterministic_opinion(p) for p in praf.p_args.values()): + return False + for edge in _primitive_attacks(praf): + opinion = _attack_opinion(praf, edge) + if not _is_deterministic_opinion(opinion): + return False + for edge in praf.supports: + opinion = _support_opinion(praf, edge) + if not _is_deterministic_opinion(opinion): + return False + return True + + +def _normalize_queried_set( + queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None, +) -> tuple[str, ...] | None: + if queried_set is None: + return None + return tuple(sorted(dict.fromkeys(str(arg) for arg in queried_set))) + + +def _validate_query_contract( + *, + strategy: str, + query_kind: object, + inference_mode: object, + queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None, +) -> tuple[str, str | None, tuple[str, ...] | None]: + """Validate explicit query selectors for PrAF acceptance queries.""" + if strategy in {"dfquad", "dfquad_quad", "dfquad_baf"}: + return "gradual_strength", None, None + + if query_kind is _UNSET: + if inference_mode is not _UNSET and inference_mode is not None: + raise ValueError("inference_mode requires an explicit query_kind") + return "argument_acceptance", "credulous", None + + query_kind_str = str(query_kind) + normalized_queried_set = _normalize_queried_set(queried_set) + + if query_kind_str == "argument_acceptance": + if inference_mode is _UNSET or inference_mode is None: + raise ValueError( + "argument_acceptance requires inference_mode='credulous' or 'skeptical'" + ) + inference_mode_str = str(inference_mode) + if inference_mode_str not in {"credulous", "skeptical"}: + raise ValueError( + "argument_acceptance requires inference_mode='credulous' or 'skeptical'" + ) + if normalized_queried_set is not None: + raise ValueError("argument_acceptance does not use queried_set") + return query_kind_str, inference_mode_str, None + + if query_kind_str == "extension_probability": + if inference_mode is not _UNSET and inference_mode is not None: + raise ValueError("extension_probability does not use inference_mode") + if normalized_queried_set is None: + raise ValueError("extension_probability requires queried_set") + return query_kind_str, None, normalized_queried_set + + raise ValueError(f"Unknown query_kind: {query_kind_str}") + + +def _exact_dp_supports_query( + *, + query_kind: str, + inference_mode: str | None, +) -> bool: + if query_kind == "argument_acceptance": + return inference_mode == "credulous" + return False + + +def _extensions_for_semantics( + af: ArgumentationFramework, + semantics: str, +) -> tuple[frozenset[str], ...]: + if semantics == "grounded": + return (grounded_extension(af),) + if semantics == "preferred": + return tuple(preferred_extensions(af)) + if semantics == "stable": + return tuple(stable_extensions(af)) + if semantics == "complete": + return tuple(complete_extensions(af)) + raise ValueError(f"Unknown semantics: {semantics}") + + +def _accepted_arguments_from_extensions( + extensions: tuple[frozenset[str], ...], + *, + inference_mode: str, +) -> frozenset[str]: + if not extensions: + return frozenset() + if inference_mode == "credulous": + accepted = set().union(*extensions) + return frozenset(accepted) + if inference_mode == "skeptical": + accepted = set(extensions[0]) + for extension in extensions[1:]: + accepted &= set(extension) + return frozenset(accepted) + raise ValueError(f"Unknown inference_mode: {inference_mode}") + + +def _evaluate_world_query( + af: ArgumentationFramework, + *, + semantics: str, + query_kind: str, + inference_mode: str | None, + queried_set: tuple[str, ...] | None, +) -> frozenset[str] | bool: + extensions = _extensions_for_semantics(af, semantics) + if query_kind == "argument_acceptance": + assert inference_mode is not None + return _accepted_arguments_from_extensions( + extensions, + inference_mode=inference_mode, + ) + if query_kind == "extension_probability": + target = frozenset(queried_set or ()) + return target in set(extensions) + raise ValueError(f"Unknown query_kind: {query_kind}") + + +def _with_strategy_override( + result: PrAFResult, + *, + strategy_requested: str, + downgraded_from: str | None = None, +) -> PrAFResult: + return PrAFResult( + acceptance_probs=result.acceptance_probs, + extension_probability=result.extension_probability, + strategy_used=result.strategy_used, + strategy_requested=strategy_requested, + downgraded_from=downgraded_from, + samples=result.samples, + confidence_interval_half=result.confidence_interval_half, + semantics=result.semantics, + query_kind=result.query_kind, + inference_mode=result.inference_mode, + queried_set=result.queried_set, + ) + + +def _deterministic_world( + praf: ProbabilisticAF, + arg_subset: frozenset[str] | None = None, +) -> ArgumentationFramework: + """Realize the unique deterministic world for a deterministic PrAF.""" + args_to_consider = arg_subset if arg_subset is not None else praf.framework.arguments + sampled_args = frozenset( + a for a in args_to_consider + if _edge_is_present(praf.p_args[a]) + ) + sampled_attacks = frozenset( + edge for edge in _primitive_attacks(praf) + if edge[0] in sampled_args + and edge[1] in sampled_args + and _edge_is_present(_attack_opinion(praf, edge)) + ) + sampled_supports = frozenset( + edge for edge in praf.supports + if edge[0] in sampled_args + and edge[1] in sampled_args + and _edge_is_present(_support_opinion(praf, edge)) + ) + return _build_sampled_framework(praf, sampled_args, sampled_attacks, sampled_supports) + + +def _build_sampled_framework( + praf: ProbabilisticAF, + sampled_args: frozenset[str], + sampled_attacks: frozenset[tuple[str, str]], + sampled_supports: frozenset[tuple[str, str]], +) -> ArgumentationFramework: + """Build one sampled world, deriving Cayrol defeats after sampling primitives.""" + direct_defeats = frozenset( + edge for edge in sampled_attacks if edge in _direct_defeats(praf) + ) + all_defeats = set(direct_defeats) + if sampled_supports and direct_defeats: + from argumentation.core.bipolar import cayrol_derived_defeats + + all_defeats |= set(cayrol_derived_defeats(frozenset(direct_defeats), frozenset(sampled_supports))) + + sampled_attacks_relation: frozenset[tuple[str, str]] | None = None + if praf.framework.attacks is not None: + sampled_attacks_relation = sampled_attacks + + return ArgumentationFramework( + arguments=sampled_args, + defeats=frozenset(all_defeats), + attacks=sampled_attacks_relation, + ) + + +def _enumerate_worlds( + praf: ProbabilisticAF, + sampled_args: frozenset[str], +): + """Enumerate all sampled worlds induced by a fixed argument subset.""" + deterministic_attacks: set[tuple[str, str]] = set() + probabilistic_attacks: list[tuple[tuple[str, str], float]] = [] + for edge in sorted(_primitive_attacks(praf)): + if edge[0] not in sampled_args or edge[1] not in sampled_args: + continue + opinion = _attack_opinion(praf, edge) + if opinion is None: + deterministic_attacks.add(edge) + else: + probabilistic_attacks.append((edge, _expectation(opinion))) + + deterministic_supports: set[tuple[str, str]] = set() + probabilistic_supports: list[tuple[tuple[str, str], float]] = [] + for edge in sorted(praf.supports): + if edge[0] not in sampled_args or edge[1] not in sampled_args: + continue + opinion = _support_opinion(praf, edge) + if opinion is None: + deterministic_supports.add(edge) + else: + probabilistic_supports.append((edge, _expectation(opinion))) + + n_prob_attacks = len(probabilistic_attacks) + n_prob_supports = len(probabilistic_supports) + + for attack_mask in range(1 << n_prob_attacks): + sampled_attacks = set(deterministic_attacks) + p_attacks_config = 1.0 + for idx, (edge, p_edge) in enumerate(probabilistic_attacks): + if attack_mask & (1 << idx): + p_attacks_config *= p_edge + sampled_attacks.add(edge) + else: + p_attacks_config *= (1.0 - p_edge) + if p_attacks_config < 1e-15: + continue + + for support_mask in range(1 << n_prob_supports): + sampled_supports = set(deterministic_supports) + p_supports_config = 1.0 + for idx, (edge, p_edge) in enumerate(probabilistic_supports): + if support_mask & (1 << idx): + p_supports_config *= p_edge + sampled_supports.add(edge) + else: + p_supports_config *= (1.0 - p_edge) + + total_prob = p_attacks_config * p_supports_config + if total_prob < 1e-15: + continue + + yield total_prob, _build_sampled_framework( + praf, + sampled_args, + frozenset(sampled_attacks), + frozenset(sampled_supports), + ) + + +def _compute_probabilistic_acceptance( + praf: ProbabilisticAF, + *, + semantics: str = "grounded", + strategy: str = "auto", + query_kind: object = _UNSET, + inference_mode: object = _UNSET, + queried_set: frozenset[str] | set[str] | tuple[str, ...] | list[str] | None = None, + tau: dict[str, float] | None = None, + mc_epsilon: float = 0.01, + mc_confidence: float = 0.95, + treewidth_cutoff: int = 12, + rng_seed: int | None = None, +) -> PrAFResult: + """Main dispatch for PrAF acceptance computation. + + Per Li et al. (2012, Algorithm 1): MC sampler with Agresti-Coull stopping. + Per Hunter & Thimm (2017, Prop 18): connected component decomposition. + + query_kind: + "argument_acceptance" — per-argument acceptance probabilities. + "extension_probability" — probability that the exact queried_set is an extension. + + inference_mode: + Required only for argument_acceptance: "credulous" or "skeptical". + """ + normalized_strategy, requested_strategy = _normalize_strategy(strategy) + normalized_query_kind, normalized_inference_mode, normalized_queried_set = _validate_query_contract( + strategy=normalized_strategy, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + if normalized_strategy == "deterministic": + result = _deterministic_fallback( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + if normalized_strategy == "mc": + result = _compute_mc( + praf, + semantics, + mc_epsilon, + mc_confidence, + rng_seed, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + if normalized_strategy == "exact_enum": + result = _compute_exact_enumeration( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + if normalized_strategy == "exact_dp": + from argumentation.probabilistic.probabilistic_treedecomp import supports_exact_dp + + if not _exact_dp_supports_query( + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + ): + raise ValueError( + "exact_dp only supports credulous argument acceptance queries" + ) + if not supports_exact_dp(praf, semantics): + raise ValueError( + "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" + ) + result = _compute_exact_dp( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + if normalized_strategy == "paper_td": + result = _compute_paper_td( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + if normalized_strategy == "dfquad": + raise ValueError( + "strategy='dfquad' is ambiguous; use 'dfquad_quad' or 'dfquad_baf'" + ) + if normalized_strategy in {"dfquad_quad", "dfquad_baf"}: + result = _compute_dfquad( + praf, + semantics, + strategy=normalized_strategy, + tau=tau, + ) + return ( + _with_strategy_override(result, strategy_requested=requested_strategy) + if requested_strategy != normalized_strategy else result + ) + + # Auto dispatch + # Fast path: if all P_D expectations are ~1.0 and all P_A expectations are ~1.0, + # this is a deterministic AF — no sampling needed. + # Per Li (2012, p.2): PrAF with P_A=1, P_D=1 equals standard Dung evaluation. + all_deterministic = _all_structure_deterministic(praf) + if all_deterministic: + return _deterministic_fallback( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + + # Small AF: exact enumeration (Li 2012, p.8: exact beats MC below ~13 args) + n_args = len(praf.framework.arguments) + if n_args <= 13: + return _compute_exact_enumeration( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + + # Relation-rich worlds currently require exact enumeration or MC. + if _requires_relation_rich_worlds(praf): + return _compute_mc( + praf, + semantics, + mc_epsilon, + mc_confidence, + rng_seed, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + + # Medium AF with low treewidth: exact DP (Popescu & Wallner 2024) + # Per plan Section 2.4: estimate treewidth, use DP if below cutoff. + from argumentation.probabilistic.probabilistic_treedecomp import estimate_treewidth + + tw = estimate_treewidth(praf.framework) + if ( + tw <= treewidth_cutoff + and _exact_dp_supports_query( + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + ) + ): + from argumentation.probabilistic.probabilistic_treedecomp import supports_exact_dp + + if supports_exact_dp(praf, semantics): + return _compute_exact_dp( + praf, + semantics, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + ) + + # Default: MC + return _compute_mc( + praf, + semantics, + mc_epsilon, + mc_confidence, + rng_seed, + query_kind=normalized_query_kind, + inference_mode=normalized_inference_mode, + queried_set=normalized_queried_set, + ) + + +def _deterministic_fallback( + praf: ProbabilisticAF, + semantics: str, + *, + query_kind: str, + inference_mode: str | None, + queried_set: tuple[str, ...] | None, +) -> PrAFResult: + """All P_D ≈ 1.0 case: run standard Dung evaluation. + + Per Li (2012, p.2): PrAF with P_A=1, P_D=1 yields acceptance probabilities + of exactly 0.0 or 1.0, matching standard Dung extension computation. + """ + deterministic_af = _deterministic_world(praf) + evaluation = _evaluate_world_query( + deterministic_af, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + if query_kind == "argument_acceptance": + assert isinstance(evaluation, frozenset) + acceptance: dict[str, float] = {} + for arg in deterministic_af.arguments: + acceptance[arg] = 1.0 if arg in evaluation else 0.0 + for arg in praf.framework.arguments - deterministic_af.arguments: + acceptance[arg] = 0.0 + return PrAFResult( + acceptance_probs=acceptance, + extension_probability=None, + strategy_used="deterministic", + strategy_requested="deterministic", + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=None, + ) + + assert isinstance(evaluation, bool) + return PrAFResult( + acceptance_probs=None, + extension_probability=1.0 if evaluation else 0.0, + strategy_used="deterministic", + strategy_requested="deterministic", + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=semantics, + query_kind=query_kind, + inference_mode=None, + queried_set=queried_set, + ) + +def _sample_subgraph( + praf: ProbabilisticAF, + rng: _random_mod.Random, + arg_subset: set[str] | None = None, +) -> ArgumentationFramework: + """Sample one semantic AF world from primitive probabilistic relations. + + Per Li et al. (2012, Algorithm 1, p.5): + 1. For each argument a, include with probability P_A(a).expectation() + 2. For each primitive attack/support where both endpoints are included, + sample existence from its opinion expectation + 3. Derive direct defeats from sampled attacks and direct-defeat policy + 4. Derive Cayrol defeats from sampled direct defeats and supports + """ + args_to_sample = arg_subset if arg_subset is not None else praf.framework.arguments + + # Step 1: Sample arguments + sampled_args: set[str] = set() + for a in args_to_sample: + p_a = _expectation(praf.p_args[a]) + if rng.random() < p_a: + sampled_args.add(a) + + sampled_attacks: set[tuple[str, str]] = set() + for edge in _primitive_attacks(praf): + if edge[0] in sampled_args and edge[1] in sampled_args: + if _sample_edge(rng, _attack_opinion(praf, edge)): + sampled_attacks.add(edge) + + sampled_supports: set[tuple[str, str]] = set() + for edge in praf.supports: + if edge[0] in sampled_args and edge[1] in sampled_args: + if _sample_edge(rng, _support_opinion(praf, edge)): + sampled_supports.add(edge) + + return _build_sampled_framework( + praf, + frozenset(sampled_args), + frozenset(sampled_attacks), + frozenset(sampled_supports), + ) + + +def _compute_mc( + praf: ProbabilisticAF, + semantics: str, + epsilon: float, + confidence: float, + seed: int | None, + *, + query_kind: str, + inference_mode: str | None, + queried_set: tuple[str, ...] | None, +) -> PrAFResult: + """Monte Carlo sampler with per-argument acceptance and Agresti-Coull stopping. + + Per Li et al. (2012, Algorithm 1, p.5): sample DAFs, evaluate semantics, + accumulate per-argument acceptance counts. + + Stopping criterion per Li et al. (2012, Eq. 5, p.7): + N > 4 * p' * (1 - p') / epsilon^2 - 4 + where p' is the observed proportion for the argument with widest CI. + + Min 30 samples before convergence check. + """ + rng = _random_mod.Random(seed) + z = _z_for_confidence(confidence) + + if query_kind == "extension_probability": + hits = 0 + n = 0 + min_samples = 30 + while True: + n += 1 + sub_af = _sample_subgraph(praf, rng) + evaluation = _evaluate_world_query( + sub_af, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + assert isinstance(evaluation, bool) + if evaluation: + hits += 1 + + if n >= min_samples: + adjusted_n = n + z * z + adjusted_count = hits + (z * z) / 2.0 + adjusted_p = adjusted_count / adjusted_n + ci_half = z * math.sqrt( + adjusted_p * (1.0 - adjusted_p) / adjusted_n + ) + if ci_half <= epsilon: + break + + if n >= 100000: + break + + adjusted_n = n + z * z + adjusted_count = hits + (z * z) / 2.0 + adjusted_p = adjusted_count / adjusted_n + ci_half = z * math.sqrt( + adjusted_p * (1.0 - adjusted_p) / adjusted_n + ) + return PrAFResult( + acceptance_probs=None, + extension_probability=hits / n if n > 0 else 0.0, + strategy_used="mc", + strategy_requested="mc", + downgraded_from=None, + samples=n, + confidence_interval_half=ci_half, + semantics=semantics, + query_kind=query_kind, + inference_mode=None, + queried_set=queried_set, + ) + + # Decompose into connected components per Hunter & Thimm (2017, Prop 18) + components = connected_components(praf) + + # Compute acceptance per component independently + all_acceptance: dict[str, float] = {} + total_samples = 0 + max_ci_half = 0.0 + + for comp_args in components: + # Build sub-PrAF for this component + comp_defeats = frozenset( + (f, t) for f, t in praf.framework.defeats + if f in comp_args and t in comp_args + ) + comp_attacks = None + if praf.framework.attacks is not None: + comp_attacks = frozenset( + (f, t) for f, t in praf.framework.attacks + if f in comp_args and t in comp_args + ) + comp_supports = frozenset( + (f, t) for f, t in praf.supports + if f in comp_args and t in comp_args + ) + comp_base_defeats = frozenset( + (f, t) for f, t in _direct_defeats(praf) + if f in comp_args and t in comp_args + ) + + comp_af = ArgumentationFramework( + arguments=frozenset(comp_args), + defeats=comp_defeats, + attacks=comp_attacks, + ) + comp_p_args = {a: praf.p_args[a] for a in comp_args} + comp_p_defeats = { + d: praf.p_defeats[d] for d in comp_defeats + if d in praf.p_defeats + } + comp_p_attacks = None + if praf.p_attacks is not None: + comp_p_attacks = { + d: praf.p_attacks[d] for d in comp_attacks or frozenset() + if d in praf.p_attacks + } + comp_p_supports = None + if praf.p_supports is not None: + comp_p_supports = { + d: praf.p_supports[d] for d in comp_supports + if d in praf.p_supports + } + + comp_praf = ProbabilisticAF( + framework=comp_af, + p_args=comp_p_args, + p_defeats=comp_p_defeats, + p_attacks=comp_p_attacks, + supports=comp_supports, + p_supports=comp_p_supports, + base_defeats=comp_base_defeats, + ) + + # Check if this component is deterministic + comp_all_det = _all_structure_deterministic(comp_praf) + if comp_all_det: + evaluation = _evaluate_world_query( + _deterministic_world(comp_praf), + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + if query_kind == "argument_acceptance": + assert isinstance(evaluation, frozenset) + for arg in comp_args: + all_acceptance[arg] = 1.0 if arg in evaluation else 0.0 + continue + + # MC sampling for this component + counts: dict[str, int] = {a: 0 for a in comp_args} + n = 0 + min_samples = 30 + + while True: + n += 1 + sub_af = _sample_subgraph(comp_praf, rng, comp_args) + evaluation = _evaluate_world_query( + sub_af, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + if query_kind == "argument_acceptance": + assert isinstance(evaluation, frozenset) + for a in comp_args: + if a in evaluation: + counts[a] += 1 + + # Agresti-Coull stopping (Li 2012, Eq. 5, p.7) + if n >= min_samples: + converged = True + for a in comp_args: + adjusted_n = n + z * z + adjusted_count = counts[a] + (z * z) / 2.0 + adjusted_p = adjusted_count / adjusted_n + ci_half = z * math.sqrt( + adjusted_p * (1.0 - adjusted_p) / adjusted_n + ) + if ci_half > epsilon: + converged = False + break + if converged: + break + + # Safety cap to prevent infinite loops + if n >= 100000: + break + + for a in comp_args: + all_acceptance[a] = counts[a] / n + + total_samples = max(total_samples, n) + + # Compute CI half-width for this component + if n > 0: + for a in comp_args: + adjusted_n = n + z * z + adjusted_count = counts[a] + (z * z) / 2.0 + adjusted_p = adjusted_count / adjusted_n + ci = z * math.sqrt( + adjusted_p * (1.0 - adjusted_p) / adjusted_n + ) + max_ci_half = max(max_ci_half, ci) + + return PrAFResult( + acceptance_probs=all_acceptance, + extension_probability=None, + strategy_used="mc", + strategy_requested="mc", + downgraded_from=None, + samples=total_samples, + confidence_interval_half=max_ci_half, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + +def _compute_exact_enumeration( + praf: ProbabilisticAF, + semantics: str, + *, + query_kind: str, + inference_mode: str | None, + queried_set: tuple[str, ...] | None, +) -> PrAFResult: + """Brute-force exact computation for small AFs. + + Per Li et al. (2012, p.3-4): enumerate all inducible DAFs, compute + P_PrAF(AF) for each, sum probabilities where argument is in extension. + + Complexity: O(2^(|A|+|D|)) — only feasible for small AFs. + """ + args_list = sorted(praf.framework.arguments) + n_args = len(args_list) + + acceptance: dict[str, float] = {a: 0.0 for a in args_list} + extension_probability = 0.0 + + # Enumerate all subsets of arguments + for arg_mask in range(1 << n_args): + sampled_args = frozenset( + args_list[i] for i in range(n_args) if arg_mask & (1 << i) + ) + + # Compute probability of this argument subset + p_args_present = 1.0 + for i, a in enumerate(args_list): + p_a = _expectation(praf.p_args[a]) + if arg_mask & (1 << i): + p_args_present *= p_a + else: + p_args_present *= (1.0 - p_a) + + if p_args_present < 1e-15: + continue + + # Find valid defeats (both endpoints present) + for p_world, sub_af in _enumerate_worlds(praf, sampled_args): + total_prob = p_args_present * p_world + if total_prob < 1e-15: + continue + evaluation = _evaluate_world_query( + sub_af, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + if query_kind == "argument_acceptance": + assert isinstance(evaluation, frozenset) + for a in sampled_args: + if a in evaluation: + acceptance[a] += total_prob + else: + assert isinstance(evaluation, bool) + if evaluation: + extension_probability += total_prob + + return PrAFResult( + acceptance_probs=acceptance if query_kind == "argument_acceptance" else None, + extension_probability=None if query_kind == "argument_acceptance" else extension_probability, + strategy_used="exact_enum", + strategy_requested="exact_enum", + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=queried_set, + ) + + +def _compute_exact_dp( + praf: ProbabilisticAF, + semantics: str, + *, + query_kind: str, + inference_mode: str | None, +) -> PrAFResult: + """Exact computation via tree-decomposition DP. + + Per Popescu & Wallner (2024): compute extension probabilities using + dynamic programming on tree decompositions. Tractable for low-treewidth + AFs (complexity O(3^k * n) where k is treewidth). + """ + from argumentation.probabilistic.probabilistic_treedecomp import compute_exact_dp + + if query_kind != "argument_acceptance" or inference_mode != "credulous": + raise ValueError("exact_dp currently only supports credulous argument acceptance") + + acceptance = compute_exact_dp(praf, semantics) + + return PrAFResult( + acceptance_probs=acceptance, + extension_probability=None, + strategy_used="exact_dp", + strategy_requested="exact_dp", + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=semantics, + query_kind=query_kind, + inference_mode=inference_mode, + queried_set=None, + strategy_metadata={ + "backend": "grounded_edge_tracking_td", + "paper_conformance": "adapted_not_popescu_iou_witness_dp", + }, + ) + + +def _compute_paper_td( + praf: ProbabilisticAF, + semantics: str, + *, + query_kind: str, + inference_mode: str | None, + queried_set: tuple[str, ...] | None, +) -> PrAFResult: + """Exact complete-extension probability via the paper-faithful TD backend.""" + if query_kind != "extension_probability" or inference_mode is not None: + raise ValueError("paper_td currently only supports extension_probability queries") + if queried_set is None: + raise ValueError("paper_td requires queried_set") + + from argumentation.probabilistic.probabilistic_treedecomp import ( + compute_paper_exact_extension_probability, + ) + + result = compute_paper_exact_extension_probability( + praf, + queried_set=frozenset(queried_set), + semantics=semantics, + ) + return PrAFResult( + acceptance_probs=None, + extension_probability=result.extension_probability, + strategy_used="paper_td", + strategy_requested="paper_td", + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=semantics, + query_kind=query_kind, + inference_mode=None, + queried_set=queried_set, + strategy_metadata={ + "backend": result.backend, + "paper_conformance": "popescu_wallner_2024_algorithm_1", + "treewidth": result.treewidth, + "node_count": result.node_count, + "root_table_rows": result.root_table_rows, + "root_probability_mass": result.root_probability_mass, + "table_summaries": result.table_summaries, + "argument_witnesses": result.argument_witnesses, + }, + ) + + +def summarize_defeat_relations( + praf: ProbabilisticAF, + *, + include_derived: bool = True, +) -> dict[tuple[str, str], float]: + """Compute exact defeat marginals as derived query results. + + This helper is explicit and potentially exponential. It is intended for + explanation and diagnostics, not as an input to the semantic core. + """ + args_list = sorted(praf.framework.arguments) + n_args = len(args_list) + acceptance: dict[tuple[str, str], float] = {} + + for arg_mask in range(1 << n_args): + sampled_args = frozenset( + args_list[i] for i in range(n_args) if arg_mask & (1 << i) + ) + + p_args_present = 1.0 + for i, a in enumerate(args_list): + p_a = _expectation(praf.p_args[a]) + if arg_mask & (1 << i): + p_args_present *= p_a + else: + p_args_present *= (1.0 - p_a) + + if p_args_present < 1e-15: + continue + + for p_world, sub_af in _enumerate_worlds(praf, sampled_args): + total_prob = p_args_present * p_world + if total_prob < 1e-15: + continue + for defeat in sub_af.defeats: + acceptance[defeat] = acceptance.get(defeat, 0.0) + total_prob + + direct_defeats = _direct_defeats(praf) + return { + edge: probability + for edge, probability in sorted(acceptance.items()) + if include_derived or edge in direct_defeats + } + + +def _compute_dfquad( + praf: ProbabilisticAF, + semantics: str, + *, + strategy: str, + tau: dict[str, float] | None = None, + supports: dict[tuple[str, str], float] | None = None, +) -> PrAFResult: + """DF-QuAD gradual semantics for QBAFs. + + Per Freedman et al. (2025, p.3): computes continuous strengths in [0,1] + by propagating base scores through attack and support relations. + + Complementary to PrAF MC/exact strategies which compute extension + membership probabilities. DF-QuAD computes graded argument strengths. + + **Design note:** Li 2012's P_A (argument existence probability for MC sampling) + is currently used as Rago 2016's τ (intrinsic strength for DF-QuAD gradual + semantics). These are conceptually distinct: a rarely-existing argument is not + the same as a weak argument. A principled separation would maintain P_A for + sampling and τ as an independent parameter. + """ + from argumentation.gradual.dfquad import dfquad_bipolar_strengths, dfquad_strengths + from argumentation.gradual.gradual import WeightedBipolarGraph + + if semantics != "grounded": + raise ValueError( + "DF-QuAD is a gradual semantics, not a Dung semantics label; " + "leave semantics at the default or use the dedicated DF-QuAD result." + ) + + if supports is None: + supports = {} + for edge in praf.supports: + opinion = _support_opinion(praf, edge) + supports[edge] = _expectation(opinion) + + if strategy == "dfquad_quad": + if tau is None: + raise ValueError("dfquad_quad requires explicit tau") + missing_tau = sorted(argument for argument in praf.framework.arguments if argument not in tau) + if missing_tau: + missing_text = ", ".join(missing_tau) + raise ValueError(f"missing tau for arguments: {missing_text}") + initial_weights = tau + else: + initial_weights = {argument: 0.5 for argument in praf.framework.arguments} + + graph = WeightedBipolarGraph( + arguments=praf.framework.arguments, + initial_weights=initial_weights, + attacks=praf.framework.defeats, + supports=frozenset(supports), + ) + + if strategy == "dfquad_quad": + result = dfquad_strengths(graph, base_scores=tau, support_weights=supports) + elif strategy == "dfquad_baf": + if tau is not None: + raise ValueError("dfquad_baf does not use tau") + result = dfquad_bipolar_strengths(graph) + else: + raise ValueError( + "strategy='dfquad' is ambiguous; use 'dfquad_quad' or 'dfquad_baf'" + ) + + return PrAFResult( + acceptance_probs=result.strengths, + extension_probability=None, + strategy_used=strategy, + strategy_requested=strategy, + downgraded_from=None, + samples=None, + confidence_interval_half=None, + semantics=strategy, + query_kind="gradual_strength", + inference_mode=None, + queried_set=None, + ) + + +def compute_probabilistic_acceptance(*args: Any, **kwargs: Any) -> PrAFResult: + return _compute_probabilistic_acceptance(*args, **kwargs) + + +ProbabilisticResult = PrAFResult + + +__all__ = [ + "ProbabilityValue", + "ProbabilisticAF", + "ProbabilisticArgumentationFramework", + "PrAFResult", + "ProbabilisticResult", + "compute_probabilistic_acceptance", + "summarize_defeat_relations", +] diff --git a/src/argumentation/probabilistic_components.py b/src/argumentation/probabilistic/probabilistic_components.py similarity index 94% rename from src/argumentation/probabilistic_components.py rename to src/argumentation/probabilistic/probabilistic_components.py index 9b93777..a5ee16b 100644 --- a/src/argumentation/probabilistic_components.py +++ b/src/argumentation/probabilistic/probabilistic_components.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from argumentation.probabilistic import ProbabilisticAF + from argumentation.probabilistic.probabilistic import ProbabilisticAF def connected_components(praf: "ProbabilisticAF") -> list[set[str]]: diff --git a/src/argumentation/probabilistic_treedecomp.py b/src/argumentation/probabilistic/probabilistic_treedecomp.py similarity index 96% rename from src/argumentation/probabilistic_treedecomp.py rename to src/argumentation/probabilistic/probabilistic_treedecomp.py index da99bf0..91b62d9 100644 --- a/src/argumentation/probabilistic_treedecomp.py +++ b/src/argumentation/probabilistic/probabilistic_treedecomp.py @@ -1,1663 +1,1663 @@ -"""Tree decomposition and exact grounded DP for probabilistic argumentation. - -This module reuses the tree-decomposition setup used by Popescu & Wallner -(2024), but the executable DP is currently an adapted grounded-semantics -edge-tracking backend, not their full I/O/U witness-table algorithm. - -Current native support is intentionally narrower than the paper: -grounded semantics on defeat-only probabilistic worlds where -`attacks == defeats` and there are no support relations. Richer worlds -are rejected by this backend. - -**Known limitation:** The tree decomposition DP currently tracks full edge sets -and forgotten arguments in table keys, giving row count O(2^|defeats| * 2^|args|). -This provides zero asymptotic improvement over brute-force enumeration. -Effective for AFs with treewidth <= ~15. A principled redesign would track -only local state per bag, achieving the theoretical O(2^tw) bound. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from itertools import product -from typing import TYPE_CHECKING - -from argumentation.dung import ArgumentationFramework - -if TYPE_CHECKING: - from argumentation.probabilistic import ProbabilisticAF - - -def supports_exact_dp( - praf: ProbabilisticAF, - semantics: str, -) -> bool: - """Return whether the current DP can evaluate this PrAF natively.""" - if semantics != "grounded": - return False - if getattr(praf, "supports", frozenset()): - return False - if praf.framework.attacks is not None and praf.framework.attacks != praf.framework.defeats: - return False - return True - - -# =================================================================== -# Data structures -# =================================================================== - -@dataclass -class TreeDecomposition: - """Tree decomposition of an AF's primal graph. - - Per Popescu & Wallner (2024, p.4-5): bags satisfy - - Every argument appears in at least one bag - - For every attack, some bag contains both endpoints - - Bags containing the same argument form a connected subtree - """ - - bags: dict[int, frozenset[str]] # node_id -> set of arguments - adj: dict[int, set[int]] # adjacency list for the tree - root: int - width: int # max bag size - 1 - - -@dataclass -class NiceTDNode: - """A node in a nice tree decomposition. - - Per Popescu & Wallner (2024, p.5): four node types. - """ - - bag: frozenset[str] - node_type: str # "leaf", "introduce", "forget", "join" - introduced: str | None = None # for introduce nodes - forgotten: str | None = None # for forget nodes - children: list[int] = field(default_factory=list) - - -@dataclass -class NiceTreeDecomposition: - """Nice TD with typed nodes. - - Per Popescu & Wallner (2024, p.5): leaf (empty bag), introduce (add one), - forget (remove one), join (two children with identical bags). - """ - - nodes: dict[int, NiceTDNode] - root: int - - -@dataclass(frozen=True) -class DPTableSummary: - """Summary of one dynamic-programming table built for a nice TD node.""" - - component_index: int - node_id: int - node_type: str - bag: frozenset[str] - row_count: int - probability_mass: float - - -@dataclass(frozen=True) -class GroundedOutcomeProbabilities: - """Probability mass for one argument's grounded status outcomes.""" - - accepted: float - rejected: float - undecided: float - absent: float - - -@dataclass(frozen=True) -class GroundedOutcomeWitness: - """One realized subworld witnessing an argument outcome.""" - - argument: str - outcome: str - present_arguments: frozenset[str] - active_defeats: frozenset[tuple[str, str]] - probability: float - - -@dataclass(frozen=True) -class GroundedOutcomeWitnesses: - """Example realized subworlds for each possible grounded outcome.""" - - accepted: GroundedOutcomeWitness | None = None - rejected: GroundedOutcomeWitness | None = None - undecided: GroundedOutcomeWitness | None = None - absent: GroundedOutcomeWitness | None = None - - -@dataclass(frozen=True) -class ExactDPDiagnostics: - """Acceptance probabilities plus auditable table-level DP diagnostics.""" - - acceptance_probs: dict[str, float] - status_probabilities: dict[str, GroundedOutcomeProbabilities] - status_witnesses: dict[str, GroundedOutcomeWitnesses] - table_summaries: tuple[DPTableSummary, ...] - treewidth: int - node_count: int - component_count: int - root_table_rows: int - root_probability_mass: float - - -@dataclass(frozen=True) -class PaperTDExactResult: - """Exact complete-extension probability from the paper-style TD evaluator.""" - - extension_probability: float - table_summaries: tuple[DPTableSummary, ...] - argument_witnesses: dict[str, PaperTDArgumentWitness] - treewidth: int - node_count: int - root_table_rows: int - root_probability_mass: float - backend: str = "popescu_wallner_iou_witness_td" - - -@dataclass(frozen=True) -class PaperTDArgumentWitness: - """Lifted label and witness metadata for one queried extension result.""" - - argument: str - label: PaperTDLabel - witnesses: frozenset[str] - - -class PaperTDLabel(Enum): - """The I/O/U labels used by Popescu and Wallner's TD tables.""" - - IN = "I" - OUT = "O" - UNDECIDED = "U" - - -@dataclass -class PaperTDRow: - """One row `(s, w, p)` in the paper-faithful TD dynamic program. - - Popescu and Wallner 2024, p.590 defines a row as a structure `s`, a - witness `w`, and a probability `p`. The structure is represented here by - the visible subframework components and its partial labelling. - """ - - present_arguments: frozenset[str] - active_defeats: frozenset[tuple[str, str]] - labels: dict[str, PaperTDLabel] - witnesses: dict[str, str] - probability: float - - -def paper_leaf_rows() -> tuple[PaperTDRow, ...]: - """Return the unit table for a nice-TD leaf node. - - Popescu and Wallner 2024, Algorithm 1 line 4 initializes a leaf table with - the empty structure, empty witness, and probability 1. - """ - return ( - PaperTDRow( - present_arguments=frozenset(), - active_defeats=frozenset(), - labels={}, - witnesses={}, - probability=1.0, - ), - ) - - -def paper_introduce_rows( - child_rows: tuple[PaperTDRow, ...], - *, - argument: str, - bag: frozenset[str], - all_defeats: frozenset[tuple[str, str]], - p_argument: float, - p_defeats: dict[tuple[str, str], float], - queried_in: frozenset[str], -) -> tuple[PaperTDRow, ...]: - """Apply the paper TD introduce transition for one argument. - - This implements the first narrow part of Popescu and Wallner 2024, - Algorithm 2: branch on whether the introduced argument is present, branch - on incident defeats when present, label resulting structures, update simple - OUT/UNDEC witnesses, and filter rows that violate required in-arguments. - """ - introduced_rows: list[PaperTDRow] = [] - for row in child_rows: - if p_argument != 1.0: - absent_row = PaperTDRow( - present_arguments=row.present_arguments, - active_defeats=row.active_defeats, - labels=dict(row.labels), - witnesses=dict(row.witnesses), - probability=row.probability * (1.0 - p_argument), - ) - if argument not in queried_in and _paper_td_accepts_required_in(absent_row, queried_in): - introduced_rows.append(absent_row) - - present_arguments = row.present_arguments | frozenset({argument}) - incident_defeats = tuple( - sorted( - defeat - for defeat in all_defeats - if argument in defeat - and defeat[0] in present_arguments - and defeat[1] in present_arguments - ) - ) - for selected in product((False, True), repeat=len(incident_defeats)): - active_defeats = set(row.active_defeats) - p_edges = 1.0 - for included, defeat in zip(selected, incident_defeats, strict=True): - probability = p_defeats.get(defeat, 1.0) - if included: - active_defeats.add(defeat) - p_edges *= probability - else: - p_edges *= 1.0 - probability - - if p_edges < 1e-18: - continue - - for labels in _paper_td_introduced_labelings( - argument, - frozenset(active_defeats), - prior_labels=row.labels, - queried_in=queried_in, - ): - witnesses = _paper_td_update_witnesses( - labels, - frozenset(active_defeats), - row.witnesses, - ) - present_row = PaperTDRow( - present_arguments=present_arguments, - active_defeats=frozenset(active_defeats), - labels=labels, - witnesses=witnesses, - probability=row.probability * p_argument * p_edges, - ) - if _paper_td_accepts_required_in(present_row, queried_in): - introduced_rows.append(present_row) - - return tuple( - sorted( - _paper_td_merge_rows(introduced_rows), - key=_paper_td_row_sort_key, - ) - ) - - -def paper_forget_rows( - child_rows: tuple[PaperTDRow, ...], - *, - argument: str, - exact_extension: frozenset[str] | None = None, -) -> tuple[PaperTDRow, ...]: - """Apply the paper TD forget transition for one argument. - - Popescu and Wallner 2024, Algorithm 3 filters rows whose forgotten - out/undecided label lacks a witness, then removes the forgotten argument, - incident defeats, and witness facts from the row state. - """ - forgotten_rows: list[PaperTDRow] = [] - for row in child_rows: - label = row.labels.get(argument) - if exact_extension is not None: - if argument in exact_extension and label is not PaperTDLabel.IN: - continue - if argument not in exact_extension and label is PaperTDLabel.IN: - continue - if not _paper_td_forget_accepts(row, argument): - continue - - labels = { - row_argument: row_label - for row_argument, row_label in row.labels.items() - if row_argument != argument - } - witnesses = { - row_argument: witness - for row_argument, witness in row.witnesses.items() - if row_argument != argument - } - forgotten_rows.append( - PaperTDRow( - present_arguments=row.present_arguments - frozenset({argument}), - active_defeats=frozenset( - defeat - for defeat in row.active_defeats - if argument not in defeat - ), - labels=labels, - witnesses=witnesses, - probability=row.probability, - ) - ) - - return tuple( - sorted( - _paper_td_merge_rows(forgotten_rows), - key=_paper_td_row_sort_key, - ) - ) - - -def paper_join_rows( - left_rows: tuple[PaperTDRow, ...], - right_rows: tuple[PaperTDRow, ...], - *, - bag: frozenset[str], - p_arguments: dict[str, float], - p_defeats: dict[tuple[str, str], float], - all_defeats: frozenset[tuple[str, str]], -) -> tuple[PaperTDRow, ...]: - """Apply the paper TD join transition for two child tables. - - Popescu and Wallner 2024, Algorithm 4 combines compatible rows and divides - out the probability mass common to both child tables for the current bag. - """ - joined_rows: list[PaperTDRow] = [] - right_by_structure: dict[ - tuple[ - frozenset[str], - frozenset[tuple[str, str]], - tuple[tuple[str, PaperTDLabel], ...], - ], - list[PaperTDRow], - ] = {} - for right in right_rows: - right_by_structure.setdefault(_paper_td_structure_key(right), []).append(right) - - for left in left_rows: - for right in right_by_structure.get(_paper_td_structure_key(left), ()): - witnesses = dict(left.witnesses) - for argument, witness in right.witnesses.items(): - witnesses.setdefault(argument, witness) - - common_probability = _paper_td_common_probability( - left, - bag=bag, - p_arguments=p_arguments, - p_defeats=p_defeats, - all_defeats=all_defeats, - ) - if common_probability < 1e-18: - continue - joined_rows.append( - PaperTDRow( - present_arguments=left.present_arguments, - active_defeats=left.active_defeats, - labels=dict(left.labels), - witnesses=witnesses, - probability=left.probability * right.probability / common_probability, - ) - ) - - return tuple( - sorted( - _paper_td_merge_rows(joined_rows), - key=_paper_td_row_sort_key, - ) - ) - - -def compute_paper_exact_extension_probability( - praf: ProbabilisticAF, - *, - queried_set: frozenset[str], - semantics: str = "complete", -) -> PaperTDExactResult: - """Compute exact `P-Ext` for complete semantics via paper-style TD rows. - - Popescu and Wallner 2024, Algorithm 1 evaluates a nice tree decomposition - bottom-up using I/O/U-labelled rows with witnesses. This public surface is - intentionally scoped to the paper's complete-semantics extension query. - """ - if semantics != "complete": - raise ValueError("paper TD exact extension probability currently supports complete semantics") - if getattr(praf, "supports", frozenset()): - raise ValueError("paper TD exact extension probability does not support support relations") - if praf.framework.attacks is not None and praf.framework.attacks != praf.framework.defeats: - raise ValueError("paper TD exact extension probability requires attacks == defeats") - - unknown = sorted(queried_set - praf.framework.arguments) - if unknown: - raise ValueError(f"queried_set contains unknown arguments: {unknown!r}") - - from argumentation.probabilistic import _expectation - - p_arguments = { - argument: _expectation(praf.p_args[argument]) - for argument in praf.framework.arguments - } - p_defeats = { - defeat: _expectation(praf.p_defeats[defeat]) - for defeat in praf.framework.defeats - } - - td = compute_tree_decomposition(praf.framework) - ntd = to_nice_tree_decomposition(td) - post_order = _nice_td_post_order(ntd) - tables: dict[int, tuple[PaperTDRow, ...]] = {} - summaries: list[DPTableSummary] = [] - - for node_id in post_order: - node = ntd.nodes[node_id] - if node.node_type == "leaf": - table = paper_leaf_rows() - elif node.node_type == "introduce": - assert node.introduced is not None - table = paper_introduce_rows( - tables[node.children[0]], - argument=node.introduced, - bag=node.bag, - all_defeats=praf.framework.defeats, - p_argument=p_arguments[node.introduced], - p_defeats=p_defeats, - queried_in=queried_set, - ) - elif node.node_type == "forget": - assert node.forgotten is not None - table = paper_forget_rows( - tables[node.children[0]], - argument=node.forgotten, - exact_extension=queried_set, - ) - elif node.node_type == "join": - table = paper_join_rows( - tables[node.children[0]], - tables[node.children[1]], - bag=node.bag, - p_arguments=p_arguments, - p_defeats=p_defeats, - all_defeats=praf.framework.defeats, - ) - else: - raise ValueError(f"Unknown nice TD node type: {node.node_type!r}") - - tables[node_id] = table - summaries.append( - DPTableSummary( - component_index=0, - node_id=node_id, - node_type=node.node_type, - bag=node.bag, - row_count=len(table), - probability_mass=sum(row.probability for row in table), - ) - ) - for child in node.children: - tables.pop(child, None) - - root_table = tables.get(ntd.root, ()) - root_probability_mass = sum(row.probability for row in root_table) - return PaperTDExactResult( - extension_probability=root_probability_mass, - table_summaries=tuple(summaries), - argument_witnesses=_paper_td_lift_argument_witnesses( - praf.framework, - queried_set, - ), - treewidth=td.width, - node_count=len(ntd.nodes), - root_table_rows=len(root_table), - root_probability_mass=root_probability_mass, - ) - - -def _paper_td_lift_argument_witnesses( - framework: ArgumentationFramework, - queried_set: frozenset[str], -) -> dict[str, PaperTDArgumentWitness]: - witnesses: dict[str, PaperTDArgumentWitness] = {} - for argument in sorted(framework.arguments): - if argument in queried_set: - witnesses[argument] = PaperTDArgumentWitness( - argument=argument, - label=PaperTDLabel.IN, - witnesses=frozenset(), - ) - continue - out_witnesses = frozenset( - attacker - for attacker, target in framework.defeats - if target == argument and attacker in queried_set - ) - if out_witnesses: - witnesses[argument] = PaperTDArgumentWitness( - argument=argument, - label=PaperTDLabel.OUT, - witnesses=out_witnesses, - ) - else: - witnesses[argument] = PaperTDArgumentWitness( - argument=argument, - label=PaperTDLabel.UNDECIDED, - witnesses=frozenset(), - ) - return witnesses - - -def _paper_td_accepts_required_in( - row: PaperTDRow, - queried_in: frozenset[str], -) -> bool: - return all( - argument not in row.labels or row.labels[argument] is PaperTDLabel.IN - for argument in queried_in - ) - - -def _paper_td_introduced_labelings( - argument: str, - active_defeats: frozenset[tuple[str, str]], - *, - prior_labels: dict[str, PaperTDLabel], - queried_in: frozenset[str], -) -> tuple[dict[str, PaperTDLabel], ...]: - choices = ( - (PaperTDLabel.IN,) - if argument in queried_in - else (PaperTDLabel.IN, PaperTDLabel.OUT, PaperTDLabel.UNDECIDED) - ) - rows: list[dict[str, PaperTDLabel]] = [] - for choice in choices: - labels = dict(prior_labels) - labels[argument] = choice - if choice is PaperTDLabel.IN and not _paper_td_conflict_free_in_label( - argument, - labels, - active_defeats, - ): - continue - rows.append(labels) - return tuple(rows) - - -def _paper_td_conflict_free_in_label( - argument: str, - labels: dict[str, PaperTDLabel], - active_defeats: frozenset[tuple[str, str]], -) -> bool: - for source, target in active_defeats: - if source == argument and labels.get(target) is PaperTDLabel.IN: - return False - if target == argument and labels.get(source) is PaperTDLabel.IN: - return False - return True - - -def _paper_td_forget_accepts(row: PaperTDRow, argument: str) -> bool: - label = row.labels.get(argument) - if label is None: - return True - if label is PaperTDLabel.IN: - return all( - labels_attacker is PaperTDLabel.OUT - for source, target in row.active_defeats - if target == argument - for labels_attacker in (row.labels.get(source),) - ) - if label is PaperTDLabel.OUT: - return argument in row.witnesses - return ( - argument in row.witnesses - and not any( - target == argument and row.labels.get(source) is PaperTDLabel.IN - for source, target in row.active_defeats - ) - ) - - -def _paper_td_update_witnesses( - labels: dict[str, PaperTDLabel], - active_defeats: frozenset[tuple[str, str]], - prior_witnesses: dict[str, str], -) -> dict[str, str]: - witnesses = dict(prior_witnesses) - for argument, label in sorted(labels.items()): - if label is PaperTDLabel.IN: - witnesses.pop(argument, None) - continue - if argument in witnesses: - continue - if label is PaperTDLabel.OUT: - attacker = next( - ( - source - for source, target in sorted(active_defeats) - if target == argument and labels.get(source) is PaperTDLabel.IN - ), - None, - ) - if attacker is not None: - witnesses[argument] = attacker - elif label is PaperTDLabel.UNDECIDED: - attacker = next( - ( - source - for source, target in sorted(active_defeats) - if target == argument and labels.get(source) is PaperTDLabel.UNDECIDED - ), - None, - ) - if attacker is not None: - witnesses[argument] = attacker - return witnesses - - -def _paper_td_structure_key( - row: PaperTDRow, -) -> tuple[ - frozenset[str], - frozenset[tuple[str, str]], - tuple[tuple[str, PaperTDLabel], ...], -]: - return ( - row.present_arguments, - row.active_defeats, - tuple(sorted(row.labels.items())), - ) - - -def _paper_td_common_probability( - row: PaperTDRow, - *, - bag: frozenset[str], - p_arguments: dict[str, float], - p_defeats: dict[tuple[str, str], float], - all_defeats: frozenset[tuple[str, str]], -) -> float: - probability = 1.0 - for argument in sorted(bag): - p_argument = p_arguments.get(argument, 1.0) - if argument in row.present_arguments: - probability *= p_argument - else: - probability *= 1.0 - p_argument - - bag_defeats = sorted( - defeat - for defeat in all_defeats - if defeat[0] in bag and defeat[1] in bag - ) - for defeat in bag_defeats: - p_defeat = p_defeats.get(defeat, 1.0) - if defeat in row.active_defeats: - probability *= p_defeat - else: - probability *= 1.0 - p_defeat - return probability - - -def _paper_td_merge_rows(rows: list[PaperTDRow]) -> tuple[PaperTDRow, ...]: - merged: dict[ - tuple[ - frozenset[str], - frozenset[tuple[str, str]], - tuple[tuple[str, PaperTDLabel], ...], - tuple[tuple[str, str], ...], - ], - PaperTDRow, - ] = {} - for row in rows: - key = ( - row.present_arguments, - row.active_defeats, - tuple(sorted(row.labels.items())), - tuple(sorted(row.witnesses.items())), - ) - if key in merged: - merged[key].probability += row.probability - else: - merged[key] = PaperTDRow( - present_arguments=row.present_arguments, - active_defeats=row.active_defeats, - labels=dict(row.labels), - witnesses=dict(row.witnesses), - probability=row.probability, - ) - return tuple(merged.values()) - - -def _paper_td_row_sort_key( - row: PaperTDRow, -) -> tuple[ - tuple[str, ...], - tuple[tuple[str, str], ...], - tuple[tuple[str, str], ...], - tuple[tuple[str, str], ...], -]: - return ( - tuple(sorted(row.present_arguments)), - tuple(sorted(row.active_defeats)), - tuple(sorted((argument, label.value) for argument, label in row.labels.items())), - tuple(sorted(row.witnesses.items())), - ) - - -# =================================================================== -# Treewidth estimation: min-degree heuristic -# =================================================================== - -def _build_primal_graph( - framework: ArgumentationFramework, -) -> dict[str, set[str]]: - """Build undirected primal graph from the semantic attack relation. - - Per Popescu & Wallner (2024, p.4): primal graph has arguments as - nodes, undirected edges between attack endpoints. - """ - adj: dict[str, set[str]] = {a: set() for a in framework.arguments} - relation = framework.attacks if framework.attacks is not None else framework.defeats - for src, tgt in relation: - if src == tgt: - continue - adj[src].add(tgt) - adj[tgt].add(src) - return adj - - -def estimate_treewidth(framework: ArgumentationFramework) -> int: - """Estimate treewidth using min-degree heuristic. - - Per Popescu & Wallner (2024, p.4): primal graph has arguments as - nodes, edges between attack endpoints. Min-degree heuristic gives - upper bound on treewidth. - - The min-degree heuristic repeatedly removes the vertex with minimum - degree, adding edges between its neighbors (fill-in). The maximum - degree at removal time is an upper bound on treewidth. - """ - if not framework.arguments: - return 0 - - adj = _build_primal_graph(framework) - - # Work on a mutable copy - remaining = set(adj.keys()) - neighbors: dict[str, set[str]] = {v: set(adj[v]) for v in remaining} - tw = 0 - - while remaining: - # Find vertex with minimum degree among remaining - min_v = min(remaining, key=lambda v: len(neighbors[v] & remaining)) - nbrs = neighbors[min_v] & remaining - deg = len(nbrs) - tw = max(tw, deg) - - # Add fill-in edges between neighbors (make them a clique) - nbrs_list = sorted(nbrs) - for i in range(len(nbrs_list)): - for j in range(i + 1, len(nbrs_list)): - u, w = nbrs_list[i], nbrs_list[j] - neighbors[u].add(w) - neighbors[w].add(u) - - # Remove the vertex - remaining.discard(min_v) - - return tw - - -# =================================================================== -# Tree decomposition computation -# =================================================================== - -def compute_tree_decomposition( - framework: ArgumentationFramework, -) -> TreeDecomposition: - """Compute tree decomposition via min-degree elimination ordering. - - Returns a tree where each node (bag) contains a subset of arguments. - Per Popescu & Wallner (2024, p.4-5): bags satisfy vertex coverage, - edge coverage, and running intersection (connectedness). - """ - if not framework.arguments: - return TreeDecomposition(bags={0: frozenset()}, adj={0: set()}, root=0, width=0) - - adj = _build_primal_graph(framework) - remaining = set(adj.keys()) - neighbors: dict[str, set[str]] = {v: set(adj[v]) for v in remaining} - - # Elimination ordering produces bags - bags: dict[int, frozenset[str]] = {} - bag_id = 0 - # Map: vertex -> bag_id where it was eliminated - vertex_bag: dict[str, int] = {} - bag_vertex: dict[int, str] = {} - width = 0 - - while remaining: - min_v = min(remaining, key=lambda v: len(neighbors[v] & remaining)) - nbrs = neighbors[min_v] & remaining - - # Bag = {min_v} ∪ neighbors in remaining - bag = frozenset({min_v}) | nbrs - bags[bag_id] = bag - vertex_bag[min_v] = bag_id - bag_vertex[bag_id] = min_v - width = max(width, len(bag) - 1) - - # Fill-in - nbrs_list = sorted(nbrs) - for i in range(len(nbrs_list)): - for j in range(i + 1, len(nbrs_list)): - u, w = nbrs_list[i], nbrs_list[j] - neighbors[u].add(w) - neighbors[w].add(u) - - remaining.discard(min_v) - bag_id += 1 - - # Build tree edges from the elimination ordering. For each bag B_i created - # when eliminating v_i, connect it to the bag of the earliest later-eliminated - # vertex still present in B_i \ {v_i}. This is the standard elimination-based - # reconstruction and ensures B_i \ {v_i} is contained in the parent bag. - tree_adj: dict[int, set[int]] = {i: set() for i in bags} - for current_bag_id, current_bag in bags.items(): - eliminated_vertex = bag_vertex[current_bag_id] - remaining_in_bag = current_bag - {eliminated_vertex} - if remaining_in_bag: - parent = min(vertex_bag[vertex] for vertex in remaining_in_bag) - tree_adj[current_bag_id].add(parent) - tree_adj[parent].add(current_bag_id) - - root = max(bags) if bags else 0 - td = TreeDecomposition(bags=bags, adj=tree_adj, root=root, width=width) - validate_tree_decomposition(td, framework) - return td - - -def validate_tree_decomposition( - td: TreeDecomposition, - framework: ArgumentationFramework | None = None, -) -> None: - """Validate that a TD is internally well-formed and optionally AF-complete.""" - bag_ids = set(td.bags) - if not bag_ids: - raise ValueError("tree decomposition must contain at least one bag") - if td.root not in bag_ids: - raise ValueError("tree decomposition root must reference an existing bag") - - for bag_id in bag_ids: - neighbors = td.adj.get(bag_id, set()) - for neighbor in neighbors: - if neighbor not in bag_ids: - raise ValueError("tree decomposition adjacency references an unknown bag") - if bag_id not in td.adj.get(neighbor, set()): - raise ValueError("tree decomposition adjacency must be symmetric") - - visited: set[int] = set() - stack = [td.root] - while stack: - node = stack.pop() - if node in visited: - continue - visited.add(node) - stack.extend(td.adj.get(node, set()) - visited) - - if visited != bag_ids: - raise ValueError("tree decomposition adjacency must form a connected tree") - - edge_count = sum(len(td.adj.get(bag_id, set())) for bag_id in bag_ids) // 2 - if edge_count != len(bag_ids) - 1: - raise ValueError("tree decomposition adjacency must form a connected tree") - - actual_width = max(len(bag) - 1 for bag in td.bags.values()) - if td.width != actual_width: - raise ValueError( - f"tree decomposition width mismatch: expected {actual_width}, got {td.width}" - ) - - covered_arguments = set().union(*td.bags.values()) - for argument in sorted(covered_arguments): - containing = { - bag_id - for bag_id, bag in td.bags.items() - if argument in bag - } - component: set[int] = set() - stack = [next(iter(containing))] - while stack: - node = stack.pop() - if node in component or node not in containing: - continue - component.add(node) - stack.extend(td.adj.get(node, set()) - component) - if component != containing: - raise ValueError( - f"tree decomposition running intersection violated for argument '{argument}'" - ) - - if framework is None: - return - - framework_arguments = set(framework.arguments) - extra_arguments = covered_arguments - framework_arguments - if extra_arguments: - raise ValueError( - f"tree decomposition contains arguments outside the framework: {sorted(extra_arguments)}" - ) - missing_arguments = framework_arguments - covered_arguments - if missing_arguments: - raise ValueError( - f"tree decomposition does not cover all arguments: {sorted(missing_arguments)}" - ) - - relation = framework.attacks if framework.attacks is not None else framework.defeats - for src, tgt in relation: - if not any(src in bag and tgt in bag for bag in td.bags.values()): - raise ValueError( - f"tree decomposition does not cover edge ({src}, {tgt})" - ) - - -# =================================================================== -# Nice tree decomposition conversion -# =================================================================== - -def to_nice_tree_decomposition( - td: TreeDecomposition, -) -> NiceTreeDecomposition: - """Convert to nice tree decomposition with 4 node types. - - Per Popescu & Wallner (2024, p.5): - - Leaf: empty bag, no children - - Introduce(v): adds argument v, one child - - Forget(v): removes argument v, one child - - Join: two children with identical bags - """ - validate_tree_decomposition(td) - nodes: dict[int, NiceTDNode] = {} - next_id = max(td.bags.keys()) + 1 if td.bags else 0 - - def _new_id() -> int: - nonlocal next_id - nid = next_id - next_id += 1 - return nid - - # BFS from root to determine parent-child relationships in the rooted tree - children_map: dict[int, list[int]] = {n: [] for n in td.bags} - visited = {td.root} - queue = [td.root] - while queue: - node = queue.pop(0) - for neighbor in td.adj.get(node, set()): - if neighbor not in visited: - visited.add(neighbor) - children_map[node].append(neighbor) - queue.append(neighbor) - - def _build_introduce_chain( - target_bag: frozenset[str], - start_bag: frozenset[str], - child_id: int, - ) -> int: - """Build a chain of introduce nodes from start_bag up to target_bag.""" - to_add = sorted(target_bag - start_bag) - current_bag = start_bag - current_child = child_id - for v in to_add: - nid = _new_id() - current_bag = current_bag | frozenset({v}) - nodes[nid] = NiceTDNode( - bag=current_bag, - node_type="introduce", - introduced=v, - children=[current_child], - ) - current_child = nid - return current_child - - def _build_forget_chain( - target_bag: frozenset[str], - start_bag: frozenset[str], - child_id: int, - ) -> int: - """Build a chain of forget nodes from start_bag down to target_bag.""" - to_remove = sorted(start_bag - target_bag) - current_bag = start_bag - current_child = child_id - for v in to_remove: - nid = _new_id() - current_bag = current_bag - frozenset({v}) - nodes[nid] = NiceTDNode( - bag=current_bag, - node_type="forget", - forgotten=v, - children=[current_child], - ) - current_child = nid - return current_child - - def _convert(td_node: int) -> int: - """Recursively convert a TD node to nice TD nodes. Returns the ID of the top node.""" - bag = td.bags[td_node] - kids = children_map[td_node] - - if not kids: - # Leaf case: build leaf (empty bag) then introduce chain up to bag - leaf_id = _new_id() - nodes[leaf_id] = NiceTDNode( - bag=frozenset(), - node_type="leaf", - children=[], - ) - if not bag: - return leaf_id - return _build_introduce_chain(bag, frozenset(), leaf_id) - - # Recursively convert children - converted_kids = [] - for kid in kids: - kid_top = _convert(kid) - # The child's top node has bag = td.bags[kid] (after introduces). - # We need to adapt it to match our bag for joining. - child_bag = td.bags[kid] - - # First forget extra vertices the child has that we don't - extra = child_bag - bag - if extra: - kid_top = _build_forget_chain(child_bag - extra, child_bag, kid_top) - - # Then introduce vertices we have that the child doesn't - missing = bag - child_bag - adapted_bag = child_bag - extra - if missing: - kid_top = _build_introduce_chain(bag, adapted_bag, kid_top) - - converted_kids.append(kid_top) - - if len(converted_kids) == 1: - return converted_kids[0] - - # Multiple children: build a binary join tree - while len(converted_kids) > 1: - left = converted_kids.pop(0) - right = converted_kids.pop(0) - join_id = _new_id() - nodes[join_id] = NiceTDNode( - bag=bag, - node_type="join", - children=[left, right], - ) - converted_kids.insert(0, join_id) - - return converted_kids[0] - - top = _convert(td.root) - - # Now add forget nodes at the top for the root bag -> empty bag - root_bag = nodes[top].bag if top in nodes else td.bags[td.root] - final_top = _build_forget_chain(frozenset(), root_bag, top) - - return NiceTreeDecomposition(nodes=nodes, root=final_top) - - -def _nice_td_post_order(ntd: NiceTreeDecomposition) -> list[int]: - post_order: list[int] = [] - visit_stack: list[tuple[int, bool]] = [(ntd.root, False)] - while visit_stack: - node_id, processed = visit_stack.pop() - if processed: - post_order.append(node_id) - continue - visit_stack.append((node_id, True)) - node = ntd.nodes[node_id] - for child in reversed(node.children): - visit_stack.append((child, False)) - return post_order - - -def compute_exact_dp( - praf: ProbabilisticAF, - semantics: str = "grounded", -) -> dict[str, float]: - """Exact grounded acceptance via the adapted edge-tracking TD backend. - - For grounded semantics, the current backend processes a nice tree - decomposition bottom-up while tracking realized edge configurations and - present forgotten arguments. It then computes the grounded fixpoint for - each root row. This is exact for the supported defeat-only constellation - PrAFs, but it is not the full Popescu & Wallner I/O/U witness-table DP. - - Unsupported semantics or relation structures are rejected. - - Known limitation: table keys include accumulated edge configurations and - forgotten arguments, so this implementation does not yet achieve the - paper's treewidth-sensitive asymptotic bound. - """ - if not supports_exact_dp(praf, semantics): - raise ValueError( - "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" - ) - - return _compute_grounded_dp(praf) - - -def compute_exact_dp_with_diagnostics( - praf: ProbabilisticAF, - semantics: str = "grounded", -) -> ExactDPDiagnostics: - """Exact grounded acceptance with diagnostics for the current DP tables.""" - if not supports_exact_dp(praf, semantics): - raise ValueError( - "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" - ) - - return _compute_grounded_dp_with_diagnostics(praf) - - -# =================================================================== -# Grounded-semantics tree-decomposition DP -# Reuses Popescu & Wallner-style nice tree decompositions, but the executable -# grounded backend below is edge-tracking rather than their I/O/U witness DP. -# -# Adapted for grounded semantics: instead of tracking I/O/U labels -# (which enumerate ALL complete labellings including non-grounded), -# we track the presence/absence of bag arguments and the active edge -# configuration. The grounded labelling is computed via fixpoint at -# forget time. This ensures exactly one labelling per subworld. -# -# Per research-popescu-pacc-report.md: P_ext = P_acc for grounded -# (unique extension per subframework). -# =================================================================== - -# Row key: (bag_state, active_edges, present_forgotten) → probability. -# bag_state: tuple of (arg, present:bool) pairs for args in bag. -# active_edges: frozenset of realized defeat edges (accumulated). -# present_forgotten: frozenset of forgotten args that were present. -_RowKey = tuple[ - tuple[tuple[str, bool], ...], # bag_state - frozenset[tuple[str, str]], # active_edges - frozenset[str], # present_forgotten -] -DPTable = dict[_RowKey, float] - - -@dataclass(frozen=True) -class _GroundedDPComponentResult: - acceptance_probs: dict[str, float] - status_probabilities: dict[str, GroundedOutcomeProbabilities] - status_witnesses: dict[str, GroundedOutcomeWitnesses] - table_summaries: tuple[DPTableSummary, ...] - treewidth: int - node_count: int - root_table_rows: int - root_probability_mass: float - - -def _make_key( - bag_state: dict[str, bool], - active_edges: frozenset[tuple[str, str]], - present_forgotten: frozenset[str], -) -> _RowKey: - """Build an immutable table key.""" - state_tuple = tuple(sorted(bag_state.items())) - return (state_tuple, active_edges, present_forgotten) - - -def _add_to_table( - table: DPTable, - bag_state: dict[str, bool], - active_edges: frozenset[tuple[str, str]], - present_forgotten: frozenset[str], - prob: float, -) -> None: - """Add probability to a table row, creating it if needed.""" - if prob < 1e-18: - return - key = _make_key(bag_state, active_edges, present_forgotten) - table[key] = table.get(key, 0.0) + prob - - -def _compute_grounded_dp(praf: ProbabilisticAF) -> dict[str, float]: - """Tree-decomposition DP for grounded semantics. - - Per Popescu & Wallner (2024, Algorithms 1-3): processes a nice tree - decomposition bottom-up with I/O/U labelling tables and witness - mechanism. - - Per Hunter & Thimm (2017, Prop 18): acceptance probability separates - over connected components. Each component is solved independently. - - For grounded semantics, each subframework has exactly one grounded - extension (Dung 1995, Theorem 25), so P_ext = P_acc. - """ - return _compute_grounded_dp_with_diagnostics(praf).acceptance_probs - - -def _compute_grounded_dp_with_diagnostics(praf: ProbabilisticAF) -> ExactDPDiagnostics: - af = praf.framework - args_list = sorted(af.arguments) - - if not args_list: - return ExactDPDiagnostics( - acceptance_probs={}, - status_probabilities={}, - status_witnesses={}, - table_summaries=(), - treewidth=0, - node_count=0, - component_count=0, - root_table_rows=0, - root_probability_mass=1.0, - ) - - from argumentation.probabilistic import _expectation - - p_arg: dict[str, float] = { - a: _expectation(praf.p_args[a]) for a in af.arguments - } - p_defeat: dict[tuple[str, str], float] = { - d: _expectation(praf.p_defeats[d]) for d in af.defeats - } - - from argumentation.probabilistic_components import connected_components - components = connected_components(praf) - - acceptance: dict[str, float] = {} - status_probabilities: dict[str, GroundedOutcomeProbabilities] = {} - status_witnesses: dict[str, GroundedOutcomeWitnesses] = {} - summaries: list[DPTableSummary] = [] - treewidth = 0 - node_count = 0 - root_table_rows = 0 - root_probability_mass = 1.0 - - for component_index, comp_args in enumerate(components): - comp_defeats = frozenset( - (f, t) for f, t in af.defeats - if f in comp_args and t in comp_args - ) - comp_af = ArgumentationFramework( - arguments=frozenset(comp_args), - defeats=comp_defeats, - attacks=( - frozenset( - (f, t) for f, t in af.attacks - if f in comp_args and t in comp_args - ) if af.attacks is not None else None - ), - ) - comp_result = _compute_grounded_dp_component_result( - comp_af, p_arg, p_defeat, component_index, - ) - acceptance.update(comp_result.acceptance_probs) - status_probabilities.update(comp_result.status_probabilities) - status_witnesses.update(comp_result.status_witnesses) - summaries.extend(comp_result.table_summaries) - treewidth = max(treewidth, comp_result.treewidth) - node_count += comp_result.node_count - root_table_rows += comp_result.root_table_rows - root_probability_mass *= comp_result.root_probability_mass - - return ExactDPDiagnostics( - acceptance_probs=acceptance, - status_probabilities=status_probabilities, - status_witnesses=status_witnesses, - table_summaries=tuple(summaries), - treewidth=treewidth, - node_count=node_count, - component_count=len(components), - root_table_rows=root_table_rows, - root_probability_mass=root_probability_mass, - ) - - -def _compute_grounded_dp_component( - af: ArgumentationFramework, - p_arg: dict[str, float], - p_defeat: dict[tuple[str, str], float], -) -> dict[str, float]: - return _compute_grounded_dp_component_result( - af, p_arg, p_defeat, component_index=0, - ).acceptance_probs - - -def _compute_grounded_dp_component_result( - af: ArgumentationFramework, - p_arg: dict[str, float], - p_defeat: dict[tuple[str, str], float], - component_index: int, -) -> _GroundedDPComponentResult: - """Edge-tracking DP for one connected component (grounded semantics). - - Instead of I/O/U labels, tracks which defeat edges are active in each - subworld. The grounded labelling is computed via fixpoint at forget - time. This guarantees exactly one labelling per subworld, matching - the brute-force enumeration. - - Per Popescu & Wallner (2024, Algorithms 1-3): processes a nice tree - decomposition bottom-up. Adapted for grounded: edge configurations - replace I/O/U partial labellings. - """ - args_list = sorted(af.arguments) - - if not args_list: - return _GroundedDPComponentResult({}, {}, {}, (), 0, 0, 0, 1.0) - - defeat_set: set[tuple[str, str]] = set(af.defeats) - - # Compute tree decomposition and nice TD. - td = compute_tree_decomposition(af) - ntd = to_nice_tree_decomposition(td) - - # Post-order traversal. - post_order: list[int] = [] - visit_stack: list[tuple[int, bool]] = [(ntd.root, False)] - while visit_stack: - nid, processed = visit_stack.pop() - if processed: - post_order.append(nid) - continue - visit_stack.append((nid, True)) - node = ntd.nodes[nid] - for child in reversed(node.children): - visit_stack.append((child, False)) - - # Assign edge ownership to prevent double-counting at joins. - # Each edge's P_D is factored at exactly one introduce node. - owned_edges: set[tuple[str, str]] = set() - introduce_owns_edges: dict[int, set[tuple[str, str]]] = {} - - for nid in post_order: - node = ntd.nodes[nid] - if node.node_type == "introduce": - v = node.introduced - assert v is not None - child_bag = node.bag - {v} - node_edges: set[tuple[str, str]] = set() - # Edges between v and existing bag members. - for edge in defeat_set: - src, tgt = edge - if src == v and tgt in child_bag and edge not in owned_edges: - node_edges.add(edge) - owned_edges.add(edge) - elif tgt == v and src in child_bag and edge not in owned_edges: - node_edges.add(edge) - owned_edges.add(edge) - elif src == v and tgt == v and edge not in owned_edges: - node_edges.add(edge) - owned_edges.add(edge) - introduce_owns_edges[nid] = node_edges - - # DP tables. - tables: dict[int, DPTable] = {} - table_summaries: list[DPTableSummary] = [] - - for nid in post_order: - node = ntd.nodes[nid] - - if node.node_type == "leaf": - tables[nid] = { - _make_key({}, frozenset(), frozenset()): 1.0 - } - - elif node.node_type == "introduce": - tables[nid] = _dp_introduce( - node, tables[node.children[0]], p_defeat, - introduce_owns_edges[nid], - ) - - elif node.node_type == "forget": - tables[nid] = _dp_forget( - node, tables[node.children[0]], p_arg, - ) - - elif node.node_type == "join": - tables[nid] = _dp_join( - node, tables[node.children[0]], tables[node.children[1]], - ) - - table = tables[nid] - table_summaries.append( - DPTableSummary( - component_index=component_index, - node_id=nid, - node_type=node.node_type, - bag=node.bag, - row_count=len(table), - probability_mass=sum(table.values()), - ) - ) - - # Free child tables. - for child in node.children: - if child in tables: - del tables[child] - - # At the root, compute grounded extensions and accumulate acceptance. - # Each row has present_forgotten (all present args) and active_edges. - # Run the grounded fixpoint on each configuration. - acceptance: dict[str, float] = {a: 0.0 for a in args_list} - status_totals: dict[str, dict[str, float]] = { - a: {"accepted": 0.0, "rejected": 0.0, "undecided": 0.0, "absent": 0.0} - for a in args_list - } - witness_rows: dict[str, dict[str, GroundedOutcomeWitness]] = { - a: {} for a in args_list - } - root_table = tables.get(ntd.root, {}) - root_probability_mass = sum(root_table.values()) - for (_, edges_fs, present_fs), prob in root_table.items(): - if prob < 1e-18: - continue - # Compute grounded extension for this subworld. - present = set(present_fs) - sub_attackers: dict[str, set[str]] = {a: set() for a in present} - for src, tgt in edges_fs: - if src in present and tgt in present: - sub_attackers[tgt].add(src) - # Fixpoint (Dung 1995, Definition 20). - labels: dict[str, str] = {a: "U" for a in present} - changed = True - while changed: - changed = False - for a in present: - if labels[a] != "U": - continue - atts = sub_attackers[a] - if all(labels[att] == "O" for att in atts): - labels[a] = "I" - changed = True - elif any(labels[att] == "I" for att in atts): - labels[a] = "O" - changed = True - for a in args_list: - if a not in present: - outcome = "absent" - elif labels[a] == "I": - outcome = "accepted" - acceptance[a] += prob - elif labels[a] == "O": - outcome = "rejected" - else: - outcome = "undecided" - - status_totals[a][outcome] += prob - if outcome not in witness_rows[a]: - witness_rows[a][outcome] = GroundedOutcomeWitness( - argument=a, - outcome=outcome, - present_arguments=frozenset(present), - active_defeats=edges_fs, - probability=prob, - ) - - status_probabilities = { - a: GroundedOutcomeProbabilities( - accepted=status_totals[a]["accepted"], - rejected=status_totals[a]["rejected"], - undecided=status_totals[a]["undecided"], - absent=status_totals[a]["absent"], - ) - for a in args_list - } - status_witnesses = { - a: GroundedOutcomeWitnesses( - accepted=witness_rows[a].get("accepted"), - rejected=witness_rows[a].get("rejected"), - undecided=witness_rows[a].get("undecided"), - absent=witness_rows[a].get("absent"), - ) - for a in args_list - } - - return _GroundedDPComponentResult( - acceptance_probs=acceptance, - status_probabilities=status_probabilities, - status_witnesses=status_witnesses, - table_summaries=tuple(table_summaries), - treewidth=td.width, - node_count=len(ntd.nodes), - root_table_rows=len(root_table), - root_probability_mass=root_probability_mass, - ) - - - -def _dp_introduce( - node: NiceTDNode, - child_table: DPTable, - p_defeat: dict[tuple[str, str], float], - owns_edges: set[tuple[str, str]], -) -> DPTable: - """Introduce v: add v to bag, branch on owned edge presence. - - For each child row, generate rows with v present or absent. - For v present, branch on each owned edge's presence/absence. - P_A is NOT applied here (deferred to forget time). - """ - v = node.introduced - assert v is not None - new_table: DPTable = {} - - # Owned edges involving v and current bag members. - owned_list = sorted(owns_edges) - n_owned = len(owned_list) - - for (state_tuple, edges_fs, present_forgotten), prob in child_table.items(): - if prob < 1e-18: - continue - bag_state = dict(state_tuple) - - # === v absent === - new_state = dict(bag_state) - new_state[v] = False - _add_to_table(new_table, new_state, edges_fs, present_forgotten, prob) - - # === v present === - # Branch on owned edges. - for edge_mask in range(1 << n_owned): - p_edges = 1.0 - new_edges = set(edges_fs) - for ei, edge in enumerate(owned_list): - if edge_mask & (1 << ei): - p_edges *= p_defeat[edge] - new_edges.add(edge) - else: - p_edges *= (1.0 - p_defeat[edge]) - - if p_edges < 1e-18: - continue - - new_state_p = dict(bag_state) - new_state_p[v] = True - _add_to_table( - new_table, new_state_p, frozenset(new_edges), - present_forgotten, prob * p_edges, - ) - - return new_table - - -def _dp_forget( - node: NiceTDNode, - child_table: DPTable, - p_arg: dict[str, float], -) -> DPTable: - """Forget v: apply P_A, move v from bag to forgotten set. - - Grounded label computation is deferred to the root. - """ - v = node.forgotten - assert v is not None - new_table: DPTable = {} - - for (state_tuple, edges_fs, present_forgotten), prob in child_table.items(): - if prob < 1e-18: - continue - bag_state = dict(state_tuple) - v_present = bag_state.get(v, False) - - # Apply P_A(v) — each argument forgotten exactly once. - pa_v = p_arg.get(v, 1.0) - if v_present: - adjusted_prob = prob * pa_v - else: - adjusted_prob = prob * (1.0 - pa_v) - - if adjusted_prob < 1e-18: - continue - - # Move v from bag to forgotten tracking. - new_state = {a: p for a, p in bag_state.items() if a != v} - new_present_forgotten = ( - present_forgotten | {v} if v_present else present_forgotten - ) - - _add_to_table( - new_table, new_state, edges_fs, - new_present_forgotten, adjusted_prob, - ) - - return new_table - - -def _dp_join( - node: NiceTDNode, - left_table: DPTable, - right_table: DPTable, -) -> DPTable: - """Join: combine rows with matching bag states. - - Per Popescu & Wallner (2024, p.6): compatible rows are combined. - Probabilities multiply, edge sets and accepted sets are unioned. - """ - new_table: DPTable = {} - - # Index right table by bag_state for fast lookup. - right_by_state: dict[ - tuple[tuple[str, bool], ...], - list[tuple[frozenset[tuple[str, str]], frozenset[str], float]], - ] = {} - for (state_tuple, edges_fs, pf), prob in right_table.items(): - if prob < 1e-18: - continue - right_by_state.setdefault(state_tuple, []).append( - (edges_fs, pf, prob) - ) - - for (left_state, left_edges, left_pf), left_prob in left_table.items(): - if left_prob < 1e-18: - continue - if left_state not in right_by_state: - continue - for right_edges, right_pf, right_prob in right_by_state[left_state]: - combined_prob = left_prob * right_prob - combined_edges = left_edges | right_edges - combined_pf = left_pf | right_pf - key = (left_state, combined_edges, combined_pf) - new_table[key] = new_table.get(key, 0.0) + combined_prob - - return new_table +"""Tree decomposition and exact grounded DP for probabilistic argumentation. + +This module reuses the tree-decomposition setup used by Popescu & Wallner +(2024), but the executable DP is currently an adapted grounded-semantics +edge-tracking backend, not their full I/O/U witness-table algorithm. + +Current native support is intentionally narrower than the paper: +grounded semantics on defeat-only probabilistic worlds where +`attacks == defeats` and there are no support relations. Richer worlds +are rejected by this backend. + +**Known limitation:** The tree decomposition DP currently tracks full edge sets +and forgotten arguments in table keys, giving row count O(2^|defeats| * 2^|args|). +This provides zero asymptotic improvement over brute-force enumeration. +Effective for AFs with treewidth <= ~15. A principled redesign would track +only local state per bag, achieving the theoretical O(2^tw) bound. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from itertools import product +from typing import TYPE_CHECKING + +from argumentation.core.dung import ArgumentationFramework + +if TYPE_CHECKING: + from argumentation.probabilistic.probabilistic import ProbabilisticAF + + +def supports_exact_dp( + praf: ProbabilisticAF, + semantics: str, +) -> bool: + """Return whether the current DP can evaluate this PrAF natively.""" + if semantics != "grounded": + return False + if getattr(praf, "supports", frozenset()): + return False + if praf.framework.attacks is not None and praf.framework.attacks != praf.framework.defeats: + return False + return True + + +# =================================================================== +# Data structures +# =================================================================== + +@dataclass +class TreeDecomposition: + """Tree decomposition of an AF's primal graph. + + Per Popescu & Wallner (2024, p.4-5): bags satisfy + - Every argument appears in at least one bag + - For every attack, some bag contains both endpoints + - Bags containing the same argument form a connected subtree + """ + + bags: dict[int, frozenset[str]] # node_id -> set of arguments + adj: dict[int, set[int]] # adjacency list for the tree + root: int + width: int # max bag size - 1 + + +@dataclass +class NiceTDNode: + """A node in a nice tree decomposition. + + Per Popescu & Wallner (2024, p.5): four node types. + """ + + bag: frozenset[str] + node_type: str # "leaf", "introduce", "forget", "join" + introduced: str | None = None # for introduce nodes + forgotten: str | None = None # for forget nodes + children: list[int] = field(default_factory=list) + + +@dataclass +class NiceTreeDecomposition: + """Nice TD with typed nodes. + + Per Popescu & Wallner (2024, p.5): leaf (empty bag), introduce (add one), + forget (remove one), join (two children with identical bags). + """ + + nodes: dict[int, NiceTDNode] + root: int + + +@dataclass(frozen=True) +class DPTableSummary: + """Summary of one dynamic-programming table built for a nice TD node.""" + + component_index: int + node_id: int + node_type: str + bag: frozenset[str] + row_count: int + probability_mass: float + + +@dataclass(frozen=True) +class GroundedOutcomeProbabilities: + """Probability mass for one argument's grounded status outcomes.""" + + accepted: float + rejected: float + undecided: float + absent: float + + +@dataclass(frozen=True) +class GroundedOutcomeWitness: + """One realized subworld witnessing an argument outcome.""" + + argument: str + outcome: str + present_arguments: frozenset[str] + active_defeats: frozenset[tuple[str, str]] + probability: float + + +@dataclass(frozen=True) +class GroundedOutcomeWitnesses: + """Example realized subworlds for each possible grounded outcome.""" + + accepted: GroundedOutcomeWitness | None = None + rejected: GroundedOutcomeWitness | None = None + undecided: GroundedOutcomeWitness | None = None + absent: GroundedOutcomeWitness | None = None + + +@dataclass(frozen=True) +class ExactDPDiagnostics: + """Acceptance probabilities plus auditable table-level DP diagnostics.""" + + acceptance_probs: dict[str, float] + status_probabilities: dict[str, GroundedOutcomeProbabilities] + status_witnesses: dict[str, GroundedOutcomeWitnesses] + table_summaries: tuple[DPTableSummary, ...] + treewidth: int + node_count: int + component_count: int + root_table_rows: int + root_probability_mass: float + + +@dataclass(frozen=True) +class PaperTDExactResult: + """Exact complete-extension probability from the paper-style TD evaluator.""" + + extension_probability: float + table_summaries: tuple[DPTableSummary, ...] + argument_witnesses: dict[str, PaperTDArgumentWitness] + treewidth: int + node_count: int + root_table_rows: int + root_probability_mass: float + backend: str = "popescu_wallner_iou_witness_td" + + +@dataclass(frozen=True) +class PaperTDArgumentWitness: + """Lifted label and witness metadata for one queried extension result.""" + + argument: str + label: PaperTDLabel + witnesses: frozenset[str] + + +class PaperTDLabel(Enum): + """The I/O/U labels used by Popescu and Wallner's TD tables.""" + + IN = "I" + OUT = "O" + UNDECIDED = "U" + + +@dataclass +class PaperTDRow: + """One row `(s, w, p)` in the paper-faithful TD dynamic program. + + Popescu and Wallner 2024, p.590 defines a row as a structure `s`, a + witness `w`, and a probability `p`. The structure is represented here by + the visible subframework components and its partial labelling. + """ + + present_arguments: frozenset[str] + active_defeats: frozenset[tuple[str, str]] + labels: dict[str, PaperTDLabel] + witnesses: dict[str, str] + probability: float + + +def paper_leaf_rows() -> tuple[PaperTDRow, ...]: + """Return the unit table for a nice-TD leaf node. + + Popescu and Wallner 2024, Algorithm 1 line 4 initializes a leaf table with + the empty structure, empty witness, and probability 1. + """ + return ( + PaperTDRow( + present_arguments=frozenset(), + active_defeats=frozenset(), + labels={}, + witnesses={}, + probability=1.0, + ), + ) + + +def paper_introduce_rows( + child_rows: tuple[PaperTDRow, ...], + *, + argument: str, + bag: frozenset[str], + all_defeats: frozenset[tuple[str, str]], + p_argument: float, + p_defeats: dict[tuple[str, str], float], + queried_in: frozenset[str], +) -> tuple[PaperTDRow, ...]: + """Apply the paper TD introduce transition for one argument. + + This implements the first narrow part of Popescu and Wallner 2024, + Algorithm 2: branch on whether the introduced argument is present, branch + on incident defeats when present, label resulting structures, update simple + OUT/UNDEC witnesses, and filter rows that violate required in-arguments. + """ + introduced_rows: list[PaperTDRow] = [] + for row in child_rows: + if p_argument != 1.0: + absent_row = PaperTDRow( + present_arguments=row.present_arguments, + active_defeats=row.active_defeats, + labels=dict(row.labels), + witnesses=dict(row.witnesses), + probability=row.probability * (1.0 - p_argument), + ) + if argument not in queried_in and _paper_td_accepts_required_in(absent_row, queried_in): + introduced_rows.append(absent_row) + + present_arguments = row.present_arguments | frozenset({argument}) + incident_defeats = tuple( + sorted( + defeat + for defeat in all_defeats + if argument in defeat + and defeat[0] in present_arguments + and defeat[1] in present_arguments + ) + ) + for selected in product((False, True), repeat=len(incident_defeats)): + active_defeats = set(row.active_defeats) + p_edges = 1.0 + for included, defeat in zip(selected, incident_defeats, strict=True): + probability = p_defeats.get(defeat, 1.0) + if included: + active_defeats.add(defeat) + p_edges *= probability + else: + p_edges *= 1.0 - probability + + if p_edges < 1e-18: + continue + + for labels in _paper_td_introduced_labelings( + argument, + frozenset(active_defeats), + prior_labels=row.labels, + queried_in=queried_in, + ): + witnesses = _paper_td_update_witnesses( + labels, + frozenset(active_defeats), + row.witnesses, + ) + present_row = PaperTDRow( + present_arguments=present_arguments, + active_defeats=frozenset(active_defeats), + labels=labels, + witnesses=witnesses, + probability=row.probability * p_argument * p_edges, + ) + if _paper_td_accepts_required_in(present_row, queried_in): + introduced_rows.append(present_row) + + return tuple( + sorted( + _paper_td_merge_rows(introduced_rows), + key=_paper_td_row_sort_key, + ) + ) + + +def paper_forget_rows( + child_rows: tuple[PaperTDRow, ...], + *, + argument: str, + exact_extension: frozenset[str] | None = None, +) -> tuple[PaperTDRow, ...]: + """Apply the paper TD forget transition for one argument. + + Popescu and Wallner 2024, Algorithm 3 filters rows whose forgotten + out/undecided label lacks a witness, then removes the forgotten argument, + incident defeats, and witness facts from the row state. + """ + forgotten_rows: list[PaperTDRow] = [] + for row in child_rows: + label = row.labels.get(argument) + if exact_extension is not None: + if argument in exact_extension and label is not PaperTDLabel.IN: + continue + if argument not in exact_extension and label is PaperTDLabel.IN: + continue + if not _paper_td_forget_accepts(row, argument): + continue + + labels = { + row_argument: row_label + for row_argument, row_label in row.labels.items() + if row_argument != argument + } + witnesses = { + row_argument: witness + for row_argument, witness in row.witnesses.items() + if row_argument != argument + } + forgotten_rows.append( + PaperTDRow( + present_arguments=row.present_arguments - frozenset({argument}), + active_defeats=frozenset( + defeat + for defeat in row.active_defeats + if argument not in defeat + ), + labels=labels, + witnesses=witnesses, + probability=row.probability, + ) + ) + + return tuple( + sorted( + _paper_td_merge_rows(forgotten_rows), + key=_paper_td_row_sort_key, + ) + ) + + +def paper_join_rows( + left_rows: tuple[PaperTDRow, ...], + right_rows: tuple[PaperTDRow, ...], + *, + bag: frozenset[str], + p_arguments: dict[str, float], + p_defeats: dict[tuple[str, str], float], + all_defeats: frozenset[tuple[str, str]], +) -> tuple[PaperTDRow, ...]: + """Apply the paper TD join transition for two child tables. + + Popescu and Wallner 2024, Algorithm 4 combines compatible rows and divides + out the probability mass common to both child tables for the current bag. + """ + joined_rows: list[PaperTDRow] = [] + right_by_structure: dict[ + tuple[ + frozenset[str], + frozenset[tuple[str, str]], + tuple[tuple[str, PaperTDLabel], ...], + ], + list[PaperTDRow], + ] = {} + for right in right_rows: + right_by_structure.setdefault(_paper_td_structure_key(right), []).append(right) + + for left in left_rows: + for right in right_by_structure.get(_paper_td_structure_key(left), ()): + witnesses = dict(left.witnesses) + for argument, witness in right.witnesses.items(): + witnesses.setdefault(argument, witness) + + common_probability = _paper_td_common_probability( + left, + bag=bag, + p_arguments=p_arguments, + p_defeats=p_defeats, + all_defeats=all_defeats, + ) + if common_probability < 1e-18: + continue + joined_rows.append( + PaperTDRow( + present_arguments=left.present_arguments, + active_defeats=left.active_defeats, + labels=dict(left.labels), + witnesses=witnesses, + probability=left.probability * right.probability / common_probability, + ) + ) + + return tuple( + sorted( + _paper_td_merge_rows(joined_rows), + key=_paper_td_row_sort_key, + ) + ) + + +def compute_paper_exact_extension_probability( + praf: ProbabilisticAF, + *, + queried_set: frozenset[str], + semantics: str = "complete", +) -> PaperTDExactResult: + """Compute exact `P-Ext` for complete semantics via paper-style TD rows. + + Popescu and Wallner 2024, Algorithm 1 evaluates a nice tree decomposition + bottom-up using I/O/U-labelled rows with witnesses. This public surface is + intentionally scoped to the paper's complete-semantics extension query. + """ + if semantics != "complete": + raise ValueError("paper TD exact extension probability currently supports complete semantics") + if getattr(praf, "supports", frozenset()): + raise ValueError("paper TD exact extension probability does not support support relations") + if praf.framework.attacks is not None and praf.framework.attacks != praf.framework.defeats: + raise ValueError("paper TD exact extension probability requires attacks == defeats") + + unknown = sorted(queried_set - praf.framework.arguments) + if unknown: + raise ValueError(f"queried_set contains unknown arguments: {unknown!r}") + + from argumentation.probabilistic.probabilistic import _expectation + + p_arguments = { + argument: _expectation(praf.p_args[argument]) + for argument in praf.framework.arguments + } + p_defeats = { + defeat: _expectation(praf.p_defeats[defeat]) + for defeat in praf.framework.defeats + } + + td = compute_tree_decomposition(praf.framework) + ntd = to_nice_tree_decomposition(td) + post_order = _nice_td_post_order(ntd) + tables: dict[int, tuple[PaperTDRow, ...]] = {} + summaries: list[DPTableSummary] = [] + + for node_id in post_order: + node = ntd.nodes[node_id] + if node.node_type == "leaf": + table = paper_leaf_rows() + elif node.node_type == "introduce": + assert node.introduced is not None + table = paper_introduce_rows( + tables[node.children[0]], + argument=node.introduced, + bag=node.bag, + all_defeats=praf.framework.defeats, + p_argument=p_arguments[node.introduced], + p_defeats=p_defeats, + queried_in=queried_set, + ) + elif node.node_type == "forget": + assert node.forgotten is not None + table = paper_forget_rows( + tables[node.children[0]], + argument=node.forgotten, + exact_extension=queried_set, + ) + elif node.node_type == "join": + table = paper_join_rows( + tables[node.children[0]], + tables[node.children[1]], + bag=node.bag, + p_arguments=p_arguments, + p_defeats=p_defeats, + all_defeats=praf.framework.defeats, + ) + else: + raise ValueError(f"Unknown nice TD node type: {node.node_type!r}") + + tables[node_id] = table + summaries.append( + DPTableSummary( + component_index=0, + node_id=node_id, + node_type=node.node_type, + bag=node.bag, + row_count=len(table), + probability_mass=sum(row.probability for row in table), + ) + ) + for child in node.children: + tables.pop(child, None) + + root_table = tables.get(ntd.root, ()) + root_probability_mass = sum(row.probability for row in root_table) + return PaperTDExactResult( + extension_probability=root_probability_mass, + table_summaries=tuple(summaries), + argument_witnesses=_paper_td_lift_argument_witnesses( + praf.framework, + queried_set, + ), + treewidth=td.width, + node_count=len(ntd.nodes), + root_table_rows=len(root_table), + root_probability_mass=root_probability_mass, + ) + + +def _paper_td_lift_argument_witnesses( + framework: ArgumentationFramework, + queried_set: frozenset[str], +) -> dict[str, PaperTDArgumentWitness]: + witnesses: dict[str, PaperTDArgumentWitness] = {} + for argument in sorted(framework.arguments): + if argument in queried_set: + witnesses[argument] = PaperTDArgumentWitness( + argument=argument, + label=PaperTDLabel.IN, + witnesses=frozenset(), + ) + continue + out_witnesses = frozenset( + attacker + for attacker, target in framework.defeats + if target == argument and attacker in queried_set + ) + if out_witnesses: + witnesses[argument] = PaperTDArgumentWitness( + argument=argument, + label=PaperTDLabel.OUT, + witnesses=out_witnesses, + ) + else: + witnesses[argument] = PaperTDArgumentWitness( + argument=argument, + label=PaperTDLabel.UNDECIDED, + witnesses=frozenset(), + ) + return witnesses + + +def _paper_td_accepts_required_in( + row: PaperTDRow, + queried_in: frozenset[str], +) -> bool: + return all( + argument not in row.labels or row.labels[argument] is PaperTDLabel.IN + for argument in queried_in + ) + + +def _paper_td_introduced_labelings( + argument: str, + active_defeats: frozenset[tuple[str, str]], + *, + prior_labels: dict[str, PaperTDLabel], + queried_in: frozenset[str], +) -> tuple[dict[str, PaperTDLabel], ...]: + choices = ( + (PaperTDLabel.IN,) + if argument in queried_in + else (PaperTDLabel.IN, PaperTDLabel.OUT, PaperTDLabel.UNDECIDED) + ) + rows: list[dict[str, PaperTDLabel]] = [] + for choice in choices: + labels = dict(prior_labels) + labels[argument] = choice + if choice is PaperTDLabel.IN and not _paper_td_conflict_free_in_label( + argument, + labels, + active_defeats, + ): + continue + rows.append(labels) + return tuple(rows) + + +def _paper_td_conflict_free_in_label( + argument: str, + labels: dict[str, PaperTDLabel], + active_defeats: frozenset[tuple[str, str]], +) -> bool: + for source, target in active_defeats: + if source == argument and labels.get(target) is PaperTDLabel.IN: + return False + if target == argument and labels.get(source) is PaperTDLabel.IN: + return False + return True + + +def _paper_td_forget_accepts(row: PaperTDRow, argument: str) -> bool: + label = row.labels.get(argument) + if label is None: + return True + if label is PaperTDLabel.IN: + return all( + labels_attacker is PaperTDLabel.OUT + for source, target in row.active_defeats + if target == argument + for labels_attacker in (row.labels.get(source),) + ) + if label is PaperTDLabel.OUT: + return argument in row.witnesses + return ( + argument in row.witnesses + and not any( + target == argument and row.labels.get(source) is PaperTDLabel.IN + for source, target in row.active_defeats + ) + ) + + +def _paper_td_update_witnesses( + labels: dict[str, PaperTDLabel], + active_defeats: frozenset[tuple[str, str]], + prior_witnesses: dict[str, str], +) -> dict[str, str]: + witnesses = dict(prior_witnesses) + for argument, label in sorted(labels.items()): + if label is PaperTDLabel.IN: + witnesses.pop(argument, None) + continue + if argument in witnesses: + continue + if label is PaperTDLabel.OUT: + attacker = next( + ( + source + for source, target in sorted(active_defeats) + if target == argument and labels.get(source) is PaperTDLabel.IN + ), + None, + ) + if attacker is not None: + witnesses[argument] = attacker + elif label is PaperTDLabel.UNDECIDED: + attacker = next( + ( + source + for source, target in sorted(active_defeats) + if target == argument and labels.get(source) is PaperTDLabel.UNDECIDED + ), + None, + ) + if attacker is not None: + witnesses[argument] = attacker + return witnesses + + +def _paper_td_structure_key( + row: PaperTDRow, +) -> tuple[ + frozenset[str], + frozenset[tuple[str, str]], + tuple[tuple[str, PaperTDLabel], ...], +]: + return ( + row.present_arguments, + row.active_defeats, + tuple(sorted(row.labels.items())), + ) + + +def _paper_td_common_probability( + row: PaperTDRow, + *, + bag: frozenset[str], + p_arguments: dict[str, float], + p_defeats: dict[tuple[str, str], float], + all_defeats: frozenset[tuple[str, str]], +) -> float: + probability = 1.0 + for argument in sorted(bag): + p_argument = p_arguments.get(argument, 1.0) + if argument in row.present_arguments: + probability *= p_argument + else: + probability *= 1.0 - p_argument + + bag_defeats = sorted( + defeat + for defeat in all_defeats + if defeat[0] in bag and defeat[1] in bag + ) + for defeat in bag_defeats: + p_defeat = p_defeats.get(defeat, 1.0) + if defeat in row.active_defeats: + probability *= p_defeat + else: + probability *= 1.0 - p_defeat + return probability + + +def _paper_td_merge_rows(rows: list[PaperTDRow]) -> tuple[PaperTDRow, ...]: + merged: dict[ + tuple[ + frozenset[str], + frozenset[tuple[str, str]], + tuple[tuple[str, PaperTDLabel], ...], + tuple[tuple[str, str], ...], + ], + PaperTDRow, + ] = {} + for row in rows: + key = ( + row.present_arguments, + row.active_defeats, + tuple(sorted(row.labels.items())), + tuple(sorted(row.witnesses.items())), + ) + if key in merged: + merged[key].probability += row.probability + else: + merged[key] = PaperTDRow( + present_arguments=row.present_arguments, + active_defeats=row.active_defeats, + labels=dict(row.labels), + witnesses=dict(row.witnesses), + probability=row.probability, + ) + return tuple(merged.values()) + + +def _paper_td_row_sort_key( + row: PaperTDRow, +) -> tuple[ + tuple[str, ...], + tuple[tuple[str, str], ...], + tuple[tuple[str, str], ...], + tuple[tuple[str, str], ...], +]: + return ( + tuple(sorted(row.present_arguments)), + tuple(sorted(row.active_defeats)), + tuple(sorted((argument, label.value) for argument, label in row.labels.items())), + tuple(sorted(row.witnesses.items())), + ) + + +# =================================================================== +# Treewidth estimation: min-degree heuristic +# =================================================================== + +def _build_primal_graph( + framework: ArgumentationFramework, +) -> dict[str, set[str]]: + """Build undirected primal graph from the semantic attack relation. + + Per Popescu & Wallner (2024, p.4): primal graph has arguments as + nodes, undirected edges between attack endpoints. + """ + adj: dict[str, set[str]] = {a: set() for a in framework.arguments} + relation = framework.attacks if framework.attacks is not None else framework.defeats + for src, tgt in relation: + if src == tgt: + continue + adj[src].add(tgt) + adj[tgt].add(src) + return adj + + +def estimate_treewidth(framework: ArgumentationFramework) -> int: + """Estimate treewidth using min-degree heuristic. + + Per Popescu & Wallner (2024, p.4): primal graph has arguments as + nodes, edges between attack endpoints. Min-degree heuristic gives + upper bound on treewidth. + + The min-degree heuristic repeatedly removes the vertex with minimum + degree, adding edges between its neighbors (fill-in). The maximum + degree at removal time is an upper bound on treewidth. + """ + if not framework.arguments: + return 0 + + adj = _build_primal_graph(framework) + + # Work on a mutable copy + remaining = set(adj.keys()) + neighbors: dict[str, set[str]] = {v: set(adj[v]) for v in remaining} + tw = 0 + + while remaining: + # Find vertex with minimum degree among remaining + min_v = min(remaining, key=lambda v: len(neighbors[v] & remaining)) + nbrs = neighbors[min_v] & remaining + deg = len(nbrs) + tw = max(tw, deg) + + # Add fill-in edges between neighbors (make them a clique) + nbrs_list = sorted(nbrs) + for i in range(len(nbrs_list)): + for j in range(i + 1, len(nbrs_list)): + u, w = nbrs_list[i], nbrs_list[j] + neighbors[u].add(w) + neighbors[w].add(u) + + # Remove the vertex + remaining.discard(min_v) + + return tw + + +# =================================================================== +# Tree decomposition computation +# =================================================================== + +def compute_tree_decomposition( + framework: ArgumentationFramework, +) -> TreeDecomposition: + """Compute tree decomposition via min-degree elimination ordering. + + Returns a tree where each node (bag) contains a subset of arguments. + Per Popescu & Wallner (2024, p.4-5): bags satisfy vertex coverage, + edge coverage, and running intersection (connectedness). + """ + if not framework.arguments: + return TreeDecomposition(bags={0: frozenset()}, adj={0: set()}, root=0, width=0) + + adj = _build_primal_graph(framework) + remaining = set(adj.keys()) + neighbors: dict[str, set[str]] = {v: set(adj[v]) for v in remaining} + + # Elimination ordering produces bags + bags: dict[int, frozenset[str]] = {} + bag_id = 0 + # Map: vertex -> bag_id where it was eliminated + vertex_bag: dict[str, int] = {} + bag_vertex: dict[int, str] = {} + width = 0 + + while remaining: + min_v = min(remaining, key=lambda v: len(neighbors[v] & remaining)) + nbrs = neighbors[min_v] & remaining + + # Bag = {min_v} ∪ neighbors in remaining + bag = frozenset({min_v}) | nbrs + bags[bag_id] = bag + vertex_bag[min_v] = bag_id + bag_vertex[bag_id] = min_v + width = max(width, len(bag) - 1) + + # Fill-in + nbrs_list = sorted(nbrs) + for i in range(len(nbrs_list)): + for j in range(i + 1, len(nbrs_list)): + u, w = nbrs_list[i], nbrs_list[j] + neighbors[u].add(w) + neighbors[w].add(u) + + remaining.discard(min_v) + bag_id += 1 + + # Build tree edges from the elimination ordering. For each bag B_i created + # when eliminating v_i, connect it to the bag of the earliest later-eliminated + # vertex still present in B_i \ {v_i}. This is the standard elimination-based + # reconstruction and ensures B_i \ {v_i} is contained in the parent bag. + tree_adj: dict[int, set[int]] = {i: set() for i in bags} + for current_bag_id, current_bag in bags.items(): + eliminated_vertex = bag_vertex[current_bag_id] + remaining_in_bag = current_bag - {eliminated_vertex} + if remaining_in_bag: + parent = min(vertex_bag[vertex] for vertex in remaining_in_bag) + tree_adj[current_bag_id].add(parent) + tree_adj[parent].add(current_bag_id) + + root = max(bags) if bags else 0 + td = TreeDecomposition(bags=bags, adj=tree_adj, root=root, width=width) + validate_tree_decomposition(td, framework) + return td + + +def validate_tree_decomposition( + td: TreeDecomposition, + framework: ArgumentationFramework | None = None, +) -> None: + """Validate that a TD is internally well-formed and optionally AF-complete.""" + bag_ids = set(td.bags) + if not bag_ids: + raise ValueError("tree decomposition must contain at least one bag") + if td.root not in bag_ids: + raise ValueError("tree decomposition root must reference an existing bag") + + for bag_id in bag_ids: + neighbors = td.adj.get(bag_id, set()) + for neighbor in neighbors: + if neighbor not in bag_ids: + raise ValueError("tree decomposition adjacency references an unknown bag") + if bag_id not in td.adj.get(neighbor, set()): + raise ValueError("tree decomposition adjacency must be symmetric") + + visited: set[int] = set() + stack = [td.root] + while stack: + node = stack.pop() + if node in visited: + continue + visited.add(node) + stack.extend(td.adj.get(node, set()) - visited) + + if visited != bag_ids: + raise ValueError("tree decomposition adjacency must form a connected tree") + + edge_count = sum(len(td.adj.get(bag_id, set())) for bag_id in bag_ids) // 2 + if edge_count != len(bag_ids) - 1: + raise ValueError("tree decomposition adjacency must form a connected tree") + + actual_width = max(len(bag) - 1 for bag in td.bags.values()) + if td.width != actual_width: + raise ValueError( + f"tree decomposition width mismatch: expected {actual_width}, got {td.width}" + ) + + covered_arguments = set().union(*td.bags.values()) + for argument in sorted(covered_arguments): + containing = { + bag_id + for bag_id, bag in td.bags.items() + if argument in bag + } + component: set[int] = set() + stack = [next(iter(containing))] + while stack: + node = stack.pop() + if node in component or node not in containing: + continue + component.add(node) + stack.extend(td.adj.get(node, set()) - component) + if component != containing: + raise ValueError( + f"tree decomposition running intersection violated for argument '{argument}'" + ) + + if framework is None: + return + + framework_arguments = set(framework.arguments) + extra_arguments = covered_arguments - framework_arguments + if extra_arguments: + raise ValueError( + f"tree decomposition contains arguments outside the framework: {sorted(extra_arguments)}" + ) + missing_arguments = framework_arguments - covered_arguments + if missing_arguments: + raise ValueError( + f"tree decomposition does not cover all arguments: {sorted(missing_arguments)}" + ) + + relation = framework.attacks if framework.attacks is not None else framework.defeats + for src, tgt in relation: + if not any(src in bag and tgt in bag for bag in td.bags.values()): + raise ValueError( + f"tree decomposition does not cover edge ({src}, {tgt})" + ) + + +# =================================================================== +# Nice tree decomposition conversion +# =================================================================== + +def to_nice_tree_decomposition( + td: TreeDecomposition, +) -> NiceTreeDecomposition: + """Convert to nice tree decomposition with 4 node types. + + Per Popescu & Wallner (2024, p.5): + - Leaf: empty bag, no children + - Introduce(v): adds argument v, one child + - Forget(v): removes argument v, one child + - Join: two children with identical bags + """ + validate_tree_decomposition(td) + nodes: dict[int, NiceTDNode] = {} + next_id = max(td.bags.keys()) + 1 if td.bags else 0 + + def _new_id() -> int: + nonlocal next_id + nid = next_id + next_id += 1 + return nid + + # BFS from root to determine parent-child relationships in the rooted tree + children_map: dict[int, list[int]] = {n: [] for n in td.bags} + visited = {td.root} + queue = [td.root] + while queue: + node = queue.pop(0) + for neighbor in td.adj.get(node, set()): + if neighbor not in visited: + visited.add(neighbor) + children_map[node].append(neighbor) + queue.append(neighbor) + + def _build_introduce_chain( + target_bag: frozenset[str], + start_bag: frozenset[str], + child_id: int, + ) -> int: + """Build a chain of introduce nodes from start_bag up to target_bag.""" + to_add = sorted(target_bag - start_bag) + current_bag = start_bag + current_child = child_id + for v in to_add: + nid = _new_id() + current_bag = current_bag | frozenset({v}) + nodes[nid] = NiceTDNode( + bag=current_bag, + node_type="introduce", + introduced=v, + children=[current_child], + ) + current_child = nid + return current_child + + def _build_forget_chain( + target_bag: frozenset[str], + start_bag: frozenset[str], + child_id: int, + ) -> int: + """Build a chain of forget nodes from start_bag down to target_bag.""" + to_remove = sorted(start_bag - target_bag) + current_bag = start_bag + current_child = child_id + for v in to_remove: + nid = _new_id() + current_bag = current_bag - frozenset({v}) + nodes[nid] = NiceTDNode( + bag=current_bag, + node_type="forget", + forgotten=v, + children=[current_child], + ) + current_child = nid + return current_child + + def _convert(td_node: int) -> int: + """Recursively convert a TD node to nice TD nodes. Returns the ID of the top node.""" + bag = td.bags[td_node] + kids = children_map[td_node] + + if not kids: + # Leaf case: build leaf (empty bag) then introduce chain up to bag + leaf_id = _new_id() + nodes[leaf_id] = NiceTDNode( + bag=frozenset(), + node_type="leaf", + children=[], + ) + if not bag: + return leaf_id + return _build_introduce_chain(bag, frozenset(), leaf_id) + + # Recursively convert children + converted_kids = [] + for kid in kids: + kid_top = _convert(kid) + # The child's top node has bag = td.bags[kid] (after introduces). + # We need to adapt it to match our bag for joining. + child_bag = td.bags[kid] + + # First forget extra vertices the child has that we don't + extra = child_bag - bag + if extra: + kid_top = _build_forget_chain(child_bag - extra, child_bag, kid_top) + + # Then introduce vertices we have that the child doesn't + missing = bag - child_bag + adapted_bag = child_bag - extra + if missing: + kid_top = _build_introduce_chain(bag, adapted_bag, kid_top) + + converted_kids.append(kid_top) + + if len(converted_kids) == 1: + return converted_kids[0] + + # Multiple children: build a binary join tree + while len(converted_kids) > 1: + left = converted_kids.pop(0) + right = converted_kids.pop(0) + join_id = _new_id() + nodes[join_id] = NiceTDNode( + bag=bag, + node_type="join", + children=[left, right], + ) + converted_kids.insert(0, join_id) + + return converted_kids[0] + + top = _convert(td.root) + + # Now add forget nodes at the top for the root bag -> empty bag + root_bag = nodes[top].bag if top in nodes else td.bags[td.root] + final_top = _build_forget_chain(frozenset(), root_bag, top) + + return NiceTreeDecomposition(nodes=nodes, root=final_top) + + +def _nice_td_post_order(ntd: NiceTreeDecomposition) -> list[int]: + post_order: list[int] = [] + visit_stack: list[tuple[int, bool]] = [(ntd.root, False)] + while visit_stack: + node_id, processed = visit_stack.pop() + if processed: + post_order.append(node_id) + continue + visit_stack.append((node_id, True)) + node = ntd.nodes[node_id] + for child in reversed(node.children): + visit_stack.append((child, False)) + return post_order + + +def compute_exact_dp( + praf: ProbabilisticAF, + semantics: str = "grounded", +) -> dict[str, float]: + """Exact grounded acceptance via the adapted edge-tracking TD backend. + + For grounded semantics, the current backend processes a nice tree + decomposition bottom-up while tracking realized edge configurations and + present forgotten arguments. It then computes the grounded fixpoint for + each root row. This is exact for the supported defeat-only constellation + PrAFs, but it is not the full Popescu & Wallner I/O/U witness-table DP. + + Unsupported semantics or relation structures are rejected. + + Known limitation: table keys include accumulated edge configurations and + forgotten arguments, so this implementation does not yet achieve the + paper's treewidth-sensitive asymptotic bound. + """ + if not supports_exact_dp(praf, semantics): + raise ValueError( + "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" + ) + + return _compute_grounded_dp(praf) + + +def compute_exact_dp_with_diagnostics( + praf: ProbabilisticAF, + semantics: str = "grounded", +) -> ExactDPDiagnostics: + """Exact grounded acceptance with diagnostics for the current DP tables.""" + if not supports_exact_dp(praf, semantics): + raise ValueError( + "exact_dp only supports grounded semantics on defeat-only probabilistic frameworks" + ) + + return _compute_grounded_dp_with_diagnostics(praf) + + +# =================================================================== +# Grounded-semantics tree-decomposition DP +# Reuses Popescu & Wallner-style nice tree decompositions, but the executable +# grounded backend below is edge-tracking rather than their I/O/U witness DP. +# +# Adapted for grounded semantics: instead of tracking I/O/U labels +# (which enumerate ALL complete labellings including non-grounded), +# we track the presence/absence of bag arguments and the active edge +# configuration. The grounded labelling is computed via fixpoint at +# forget time. This ensures exactly one labelling per subworld. +# +# Per research-popescu-pacc-report.md: P_ext = P_acc for grounded +# (unique extension per subframework). +# =================================================================== + +# Row key: (bag_state, active_edges, present_forgotten) → probability. +# bag_state: tuple of (arg, present:bool) pairs for args in bag. +# active_edges: frozenset of realized defeat edges (accumulated). +# present_forgotten: frozenset of forgotten args that were present. +_RowKey = tuple[ + tuple[tuple[str, bool], ...], # bag_state + frozenset[tuple[str, str]], # active_edges + frozenset[str], # present_forgotten +] +DPTable = dict[_RowKey, float] + + +@dataclass(frozen=True) +class _GroundedDPComponentResult: + acceptance_probs: dict[str, float] + status_probabilities: dict[str, GroundedOutcomeProbabilities] + status_witnesses: dict[str, GroundedOutcomeWitnesses] + table_summaries: tuple[DPTableSummary, ...] + treewidth: int + node_count: int + root_table_rows: int + root_probability_mass: float + + +def _make_key( + bag_state: dict[str, bool], + active_edges: frozenset[tuple[str, str]], + present_forgotten: frozenset[str], +) -> _RowKey: + """Build an immutable table key.""" + state_tuple = tuple(sorted(bag_state.items())) + return (state_tuple, active_edges, present_forgotten) + + +def _add_to_table( + table: DPTable, + bag_state: dict[str, bool], + active_edges: frozenset[tuple[str, str]], + present_forgotten: frozenset[str], + prob: float, +) -> None: + """Add probability to a table row, creating it if needed.""" + if prob < 1e-18: + return + key = _make_key(bag_state, active_edges, present_forgotten) + table[key] = table.get(key, 0.0) + prob + + +def _compute_grounded_dp(praf: ProbabilisticAF) -> dict[str, float]: + """Tree-decomposition DP for grounded semantics. + + Per Popescu & Wallner (2024, Algorithms 1-3): processes a nice tree + decomposition bottom-up with I/O/U labelling tables and witness + mechanism. + + Per Hunter & Thimm (2017, Prop 18): acceptance probability separates + over connected components. Each component is solved independently. + + For grounded semantics, each subframework has exactly one grounded + extension (Dung 1995, Theorem 25), so P_ext = P_acc. + """ + return _compute_grounded_dp_with_diagnostics(praf).acceptance_probs + + +def _compute_grounded_dp_with_diagnostics(praf: ProbabilisticAF) -> ExactDPDiagnostics: + af = praf.framework + args_list = sorted(af.arguments) + + if not args_list: + return ExactDPDiagnostics( + acceptance_probs={}, + status_probabilities={}, + status_witnesses={}, + table_summaries=(), + treewidth=0, + node_count=0, + component_count=0, + root_table_rows=0, + root_probability_mass=1.0, + ) + + from argumentation.probabilistic.probabilistic import _expectation + + p_arg: dict[str, float] = { + a: _expectation(praf.p_args[a]) for a in af.arguments + } + p_defeat: dict[tuple[str, str], float] = { + d: _expectation(praf.p_defeats[d]) for d in af.defeats + } + + from argumentation.probabilistic.probabilistic_components import connected_components + components = connected_components(praf) + + acceptance: dict[str, float] = {} + status_probabilities: dict[str, GroundedOutcomeProbabilities] = {} + status_witnesses: dict[str, GroundedOutcomeWitnesses] = {} + summaries: list[DPTableSummary] = [] + treewidth = 0 + node_count = 0 + root_table_rows = 0 + root_probability_mass = 1.0 + + for component_index, comp_args in enumerate(components): + comp_defeats = frozenset( + (f, t) for f, t in af.defeats + if f in comp_args and t in comp_args + ) + comp_af = ArgumentationFramework( + arguments=frozenset(comp_args), + defeats=comp_defeats, + attacks=( + frozenset( + (f, t) for f, t in af.attacks + if f in comp_args and t in comp_args + ) if af.attacks is not None else None + ), + ) + comp_result = _compute_grounded_dp_component_result( + comp_af, p_arg, p_defeat, component_index, + ) + acceptance.update(comp_result.acceptance_probs) + status_probabilities.update(comp_result.status_probabilities) + status_witnesses.update(comp_result.status_witnesses) + summaries.extend(comp_result.table_summaries) + treewidth = max(treewidth, comp_result.treewidth) + node_count += comp_result.node_count + root_table_rows += comp_result.root_table_rows + root_probability_mass *= comp_result.root_probability_mass + + return ExactDPDiagnostics( + acceptance_probs=acceptance, + status_probabilities=status_probabilities, + status_witnesses=status_witnesses, + table_summaries=tuple(summaries), + treewidth=treewidth, + node_count=node_count, + component_count=len(components), + root_table_rows=root_table_rows, + root_probability_mass=root_probability_mass, + ) + + +def _compute_grounded_dp_component( + af: ArgumentationFramework, + p_arg: dict[str, float], + p_defeat: dict[tuple[str, str], float], +) -> dict[str, float]: + return _compute_grounded_dp_component_result( + af, p_arg, p_defeat, component_index=0, + ).acceptance_probs + + +def _compute_grounded_dp_component_result( + af: ArgumentationFramework, + p_arg: dict[str, float], + p_defeat: dict[tuple[str, str], float], + component_index: int, +) -> _GroundedDPComponentResult: + """Edge-tracking DP for one connected component (grounded semantics). + + Instead of I/O/U labels, tracks which defeat edges are active in each + subworld. The grounded labelling is computed via fixpoint at forget + time. This guarantees exactly one labelling per subworld, matching + the brute-force enumeration. + + Per Popescu & Wallner (2024, Algorithms 1-3): processes a nice tree + decomposition bottom-up. Adapted for grounded: edge configurations + replace I/O/U partial labellings. + """ + args_list = sorted(af.arguments) + + if not args_list: + return _GroundedDPComponentResult({}, {}, {}, (), 0, 0, 0, 1.0) + + defeat_set: set[tuple[str, str]] = set(af.defeats) + + # Compute tree decomposition and nice TD. + td = compute_tree_decomposition(af) + ntd = to_nice_tree_decomposition(td) + + # Post-order traversal. + post_order: list[int] = [] + visit_stack: list[tuple[int, bool]] = [(ntd.root, False)] + while visit_stack: + nid, processed = visit_stack.pop() + if processed: + post_order.append(nid) + continue + visit_stack.append((nid, True)) + node = ntd.nodes[nid] + for child in reversed(node.children): + visit_stack.append((child, False)) + + # Assign edge ownership to prevent double-counting at joins. + # Each edge's P_D is factored at exactly one introduce node. + owned_edges: set[tuple[str, str]] = set() + introduce_owns_edges: dict[int, set[tuple[str, str]]] = {} + + for nid in post_order: + node = ntd.nodes[nid] + if node.node_type == "introduce": + v = node.introduced + assert v is not None + child_bag = node.bag - {v} + node_edges: set[tuple[str, str]] = set() + # Edges between v and existing bag members. + for edge in defeat_set: + src, tgt = edge + if src == v and tgt in child_bag and edge not in owned_edges: + node_edges.add(edge) + owned_edges.add(edge) + elif tgt == v and src in child_bag and edge not in owned_edges: + node_edges.add(edge) + owned_edges.add(edge) + elif src == v and tgt == v and edge not in owned_edges: + node_edges.add(edge) + owned_edges.add(edge) + introduce_owns_edges[nid] = node_edges + + # DP tables. + tables: dict[int, DPTable] = {} + table_summaries: list[DPTableSummary] = [] + + for nid in post_order: + node = ntd.nodes[nid] + + if node.node_type == "leaf": + tables[nid] = { + _make_key({}, frozenset(), frozenset()): 1.0 + } + + elif node.node_type == "introduce": + tables[nid] = _dp_introduce( + node, tables[node.children[0]], p_defeat, + introduce_owns_edges[nid], + ) + + elif node.node_type == "forget": + tables[nid] = _dp_forget( + node, tables[node.children[0]], p_arg, + ) + + elif node.node_type == "join": + tables[nid] = _dp_join( + node, tables[node.children[0]], tables[node.children[1]], + ) + + table = tables[nid] + table_summaries.append( + DPTableSummary( + component_index=component_index, + node_id=nid, + node_type=node.node_type, + bag=node.bag, + row_count=len(table), + probability_mass=sum(table.values()), + ) + ) + + # Free child tables. + for child in node.children: + if child in tables: + del tables[child] + + # At the root, compute grounded extensions and accumulate acceptance. + # Each row has present_forgotten (all present args) and active_edges. + # Run the grounded fixpoint on each configuration. + acceptance: dict[str, float] = {a: 0.0 for a in args_list} + status_totals: dict[str, dict[str, float]] = { + a: {"accepted": 0.0, "rejected": 0.0, "undecided": 0.0, "absent": 0.0} + for a in args_list + } + witness_rows: dict[str, dict[str, GroundedOutcomeWitness]] = { + a: {} for a in args_list + } + root_table = tables.get(ntd.root, {}) + root_probability_mass = sum(root_table.values()) + for (_, edges_fs, present_fs), prob in root_table.items(): + if prob < 1e-18: + continue + # Compute grounded extension for this subworld. + present = set(present_fs) + sub_attackers: dict[str, set[str]] = {a: set() for a in present} + for src, tgt in edges_fs: + if src in present and tgt in present: + sub_attackers[tgt].add(src) + # Fixpoint (Dung 1995, Definition 20). + labels: dict[str, str] = {a: "U" for a in present} + changed = True + while changed: + changed = False + for a in present: + if labels[a] != "U": + continue + atts = sub_attackers[a] + if all(labels[att] == "O" for att in atts): + labels[a] = "I" + changed = True + elif any(labels[att] == "I" for att in atts): + labels[a] = "O" + changed = True + for a in args_list: + if a not in present: + outcome = "absent" + elif labels[a] == "I": + outcome = "accepted" + acceptance[a] += prob + elif labels[a] == "O": + outcome = "rejected" + else: + outcome = "undecided" + + status_totals[a][outcome] += prob + if outcome not in witness_rows[a]: + witness_rows[a][outcome] = GroundedOutcomeWitness( + argument=a, + outcome=outcome, + present_arguments=frozenset(present), + active_defeats=edges_fs, + probability=prob, + ) + + status_probabilities = { + a: GroundedOutcomeProbabilities( + accepted=status_totals[a]["accepted"], + rejected=status_totals[a]["rejected"], + undecided=status_totals[a]["undecided"], + absent=status_totals[a]["absent"], + ) + for a in args_list + } + status_witnesses = { + a: GroundedOutcomeWitnesses( + accepted=witness_rows[a].get("accepted"), + rejected=witness_rows[a].get("rejected"), + undecided=witness_rows[a].get("undecided"), + absent=witness_rows[a].get("absent"), + ) + for a in args_list + } + + return _GroundedDPComponentResult( + acceptance_probs=acceptance, + status_probabilities=status_probabilities, + status_witnesses=status_witnesses, + table_summaries=tuple(table_summaries), + treewidth=td.width, + node_count=len(ntd.nodes), + root_table_rows=len(root_table), + root_probability_mass=root_probability_mass, + ) + + + +def _dp_introduce( + node: NiceTDNode, + child_table: DPTable, + p_defeat: dict[tuple[str, str], float], + owns_edges: set[tuple[str, str]], +) -> DPTable: + """Introduce v: add v to bag, branch on owned edge presence. + + For each child row, generate rows with v present or absent. + For v present, branch on each owned edge's presence/absence. + P_A is NOT applied here (deferred to forget time). + """ + v = node.introduced + assert v is not None + new_table: DPTable = {} + + # Owned edges involving v and current bag members. + owned_list = sorted(owns_edges) + n_owned = len(owned_list) + + for (state_tuple, edges_fs, present_forgotten), prob in child_table.items(): + if prob < 1e-18: + continue + bag_state = dict(state_tuple) + + # === v absent === + new_state = dict(bag_state) + new_state[v] = False + _add_to_table(new_table, new_state, edges_fs, present_forgotten, prob) + + # === v present === + # Branch on owned edges. + for edge_mask in range(1 << n_owned): + p_edges = 1.0 + new_edges = set(edges_fs) + for ei, edge in enumerate(owned_list): + if edge_mask & (1 << ei): + p_edges *= p_defeat[edge] + new_edges.add(edge) + else: + p_edges *= (1.0 - p_defeat[edge]) + + if p_edges < 1e-18: + continue + + new_state_p = dict(bag_state) + new_state_p[v] = True + _add_to_table( + new_table, new_state_p, frozenset(new_edges), + present_forgotten, prob * p_edges, + ) + + return new_table + + +def _dp_forget( + node: NiceTDNode, + child_table: DPTable, + p_arg: dict[str, float], +) -> DPTable: + """Forget v: apply P_A, move v from bag to forgotten set. + + Grounded label computation is deferred to the root. + """ + v = node.forgotten + assert v is not None + new_table: DPTable = {} + + for (state_tuple, edges_fs, present_forgotten), prob in child_table.items(): + if prob < 1e-18: + continue + bag_state = dict(state_tuple) + v_present = bag_state.get(v, False) + + # Apply P_A(v) — each argument forgotten exactly once. + pa_v = p_arg.get(v, 1.0) + if v_present: + adjusted_prob = prob * pa_v + else: + adjusted_prob = prob * (1.0 - pa_v) + + if adjusted_prob < 1e-18: + continue + + # Move v from bag to forgotten tracking. + new_state = {a: p for a, p in bag_state.items() if a != v} + new_present_forgotten = ( + present_forgotten | {v} if v_present else present_forgotten + ) + + _add_to_table( + new_table, new_state, edges_fs, + new_present_forgotten, adjusted_prob, + ) + + return new_table + + +def _dp_join( + node: NiceTDNode, + left_table: DPTable, + right_table: DPTable, +) -> DPTable: + """Join: combine rows with matching bag states. + + Per Popescu & Wallner (2024, p.6): compatible rows are combined. + Probabilities multiply, edge sets and accepted sets are unioned. + """ + new_table: DPTable = {} + + # Index right table by bag_state for fast lookup. + right_by_state: dict[ + tuple[tuple[str, bool], ...], + list[tuple[frozenset[tuple[str, str]], frozenset[str], float]], + ] = {} + for (state_tuple, edges_fs, pf), prob in right_table.items(): + if prob < 1e-18: + continue + right_by_state.setdefault(state_tuple, []).append( + (edges_fs, pf, prob) + ) + + for (left_state, left_edges, left_pf), left_prob in left_table.items(): + if left_prob < 1e-18: + continue + if left_state not in right_by_state: + continue + for right_edges, right_pf, right_prob in right_by_state[left_state]: + combined_prob = left_prob * right_prob + combined_edges = left_edges | right_edges + combined_pf = left_pf | right_pf + key = (left_state, combined_edges, combined_pf) + new_table[key] = new_table.get(key, 0.0) + combined_prob + + return new_table diff --git a/src/argumentation/ranking/__init__.py b/src/argumentation/ranking/__init__.py new file mode 100644 index 0000000..06feb96 --- /dev/null +++ b/src/argumentation/ranking/__init__.py @@ -0,0 +1 @@ +"""Ranking-based and weighted argumentation semantics.""" diff --git a/src/argumentation/matt_toni.py b/src/argumentation/ranking/matt_toni.py similarity index 95% rename from src/argumentation/matt_toni.py rename to src/argumentation/ranking/matt_toni.py index 881a950..cb207d7 100644 --- a/src/argumentation/matt_toni.py +++ b/src/argumentation/ranking/matt_toni.py @@ -1,233 +1,233 @@ -"""Matt-Toni game-theoretic argument strength.""" - -from __future__ import annotations - -from itertools import combinations - -from argumentation.dung import ArgumentationFramework, conflict_free - - -class MattToniIntractable(ValueError): - """Raised when exact strategy enumeration would exceed the configured cap.""" - - -def matt_toni_strength( - framework: ArgumentationFramework, - argument: str, - *, - max_arguments: int = 12, -) -> float: - """Return the value of the argumentation-strategy game for ``argument``. - - Matt and Toni 2008, JELIA, p. 291, Definition 6 defines strength as the - value of the zero-sum game; p. 289, Definition 5 defines the payoff. - """ - - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument!r}") - if len(framework.arguments) > max_arguments: - raise MattToniIntractable( - f"Matt-Toni exact strength is capped at {max_arguments} arguments" - ) - - proponent = [ - subset - for subset in _all_subsets(framework.arguments) - if argument in subset and conflict_free(subset, framework.defeats) - ] - if not proponent: - return 0.0 - - opponents = [ - subset - for subset in _all_subsets(framework.arguments) - if subset - ] - if not opponents: - return 1.0 - - matrix = [ - [_reward(framework, p_strategy, o_strategy) for o_strategy in opponents] - for p_strategy in proponent - ] - return _zero_sum_row_value(matrix) - - -def matt_toni_strengths( - framework: ArgumentationFramework, - *, - max_arguments: int = 12, -) -> dict[str, float]: - """Return Matt-Toni strengths for every argument in the framework.""" - - return { - argument: matt_toni_strength( - framework, - argument, - max_arguments=max_arguments, - ) - for argument in sorted(framework.arguments) - } - - -def _reward( - framework: ArgumentationFramework, - proponent: frozenset[str], - opponent: frozenset[str], -) -> float: - if not conflict_free(proponent, framework.defeats): - return 0.0 - attacks_o_to_p = _attack_count(framework, opponent, proponent) - if attacks_o_to_p == 0: - return 1.0 - attacks_p_to_o = _attack_count(framework, proponent, opponent) - return 0.5 * ( - 1.0 - + _normalised_attack_count(attacks_p_to_o) - - _normalised_attack_count(attacks_o_to_p) - ) - - -def _attack_count( - framework: ArgumentationFramework, - source: frozenset[str], - target: frozenset[str], -) -> int: - return sum( - 1 - for attacker, attacked in framework.defeats - if attacker in source and attacked in target - ) - - -def _normalised_attack_count(count: int) -> float: - return count / (count + 1.0) - - -def _zero_sum_row_value(matrix: list[list[float]]) -> float: - matrix = _dominance_reduced(matrix) - row_count = len(matrix) - column_count = len(matrix[0]) - best = 0.0 - max_support = min(row_count, column_count) - for size in range(1, max_support + 1): - for rows in combinations(range(row_count), size): - for columns in combinations(range(column_count), size): - solution = _solve_active_system(matrix, rows, columns) - if solution is None: - continue - probabilities, value = solution - if any(probability < -1e-9 for probability in probabilities): - continue - payoffs = [ - sum( - probabilities[index] * matrix[row][column] - for index, row in enumerate(rows) - ) - for column in range(column_count) - ] - if min(payoffs) + 1e-8 >= value: - best = max(best, value) - pure = max(min(row) for row in matrix) - return min(1.0, max(0.0, max(best, pure))) - - -def _dominance_reduced(matrix: list[list[float]]) -> list[list[float]]: - reduced = [row[:] for row in matrix] - changed = True - while changed: - changed = False - row_count = len(reduced) - column_count = len(reduced[0]) - dominated_rows = { - row - for row in range(row_count) - if any( - row != other - and all(reduced[other][column] >= reduced[row][column] for column in range(column_count)) - and any(reduced[other][column] > reduced[row][column] for column in range(column_count)) - for other in range(row_count) - ) - } - if dominated_rows and len(dominated_rows) < row_count: - reduced = [ - values - for row, values in enumerate(reduced) - if row not in dominated_rows - ] - changed = True - - row_count = len(reduced) - column_count = len(reduced[0]) - dominated_columns = { - column - for column in range(column_count) - if any( - column != other - and all(reduced[row][other] <= reduced[row][column] for row in range(row_count)) - and any(reduced[row][other] < reduced[row][column] for row in range(row_count)) - for other in range(column_count) - ) - } - if dominated_columns and len(dominated_columns) < column_count: - reduced = [ - [ - value - for column, value in enumerate(row) - if column not in dominated_columns - ] - for row in reduced - ] - changed = True - return reduced - - -def _solve_active_system( - matrix: list[list[float]], - rows: tuple[int, ...], - columns: tuple[int, ...], -) -> tuple[list[float], float] | None: - size = len(rows) - equations: list[list[float]] = [] - rhs: list[float] = [] - equations.append([1.0] * size + [0.0]) - rhs.append(1.0) - for column in columns: - equations.append([matrix[row][column] for row in rows] + [-1.0]) - rhs.append(0.0) - solved = _gaussian_elimination(equations, rhs) - if solved is None: - return None - return solved[:-1], solved[-1] - - -def _gaussian_elimination( - matrix: list[list[float]], - rhs: list[float], -) -> list[float] | None: - n = len(rhs) - rows = [row[:] + [value] for row, value in zip(matrix, rhs, strict=True)] - for column in range(n): - pivot = max(range(column, n), key=lambda row: abs(rows[row][column])) - if abs(rows[pivot][column]) < 1e-12: - return None - rows[column], rows[pivot] = rows[pivot], rows[column] - divisor = rows[column][column] - rows[column] = [value / divisor for value in rows[column]] - for row in range(n): - if row == column: - continue - factor = rows[row][column] - rows[row] = [ - current - factor * pivot_value - for current, pivot_value in zip(rows[row], rows[column], strict=True) - ] - return [rows[row][-1] for row in range(n)] - - -def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = tuple(sorted(arguments)) - return [ - frozenset(ordered[index] for index in range(len(ordered)) if mask & (1 << index)) - for mask in range(1 << len(ordered)) - ] +"""Matt-Toni game-theoretic argument strength.""" + +from __future__ import annotations + +from itertools import combinations + +from argumentation.core.dung import ArgumentationFramework, conflict_free + + +class MattToniIntractable(ValueError): + """Raised when exact strategy enumeration would exceed the configured cap.""" + + +def matt_toni_strength( + framework: ArgumentationFramework, + argument: str, + *, + max_arguments: int = 12, +) -> float: + """Return the value of the argumentation-strategy game for ``argument``. + + Matt and Toni 2008, JELIA, p. 291, Definition 6 defines strength as the + value of the zero-sum game; p. 289, Definition 5 defines the payoff. + """ + + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument!r}") + if len(framework.arguments) > max_arguments: + raise MattToniIntractable( + f"Matt-Toni exact strength is capped at {max_arguments} arguments" + ) + + proponent = [ + subset + for subset in _all_subsets(framework.arguments) + if argument in subset and conflict_free(subset, framework.defeats) + ] + if not proponent: + return 0.0 + + opponents = [ + subset + for subset in _all_subsets(framework.arguments) + if subset + ] + if not opponents: + return 1.0 + + matrix = [ + [_reward(framework, p_strategy, o_strategy) for o_strategy in opponents] + for p_strategy in proponent + ] + return _zero_sum_row_value(matrix) + + +def matt_toni_strengths( + framework: ArgumentationFramework, + *, + max_arguments: int = 12, +) -> dict[str, float]: + """Return Matt-Toni strengths for every argument in the framework.""" + + return { + argument: matt_toni_strength( + framework, + argument, + max_arguments=max_arguments, + ) + for argument in sorted(framework.arguments) + } + + +def _reward( + framework: ArgumentationFramework, + proponent: frozenset[str], + opponent: frozenset[str], +) -> float: + if not conflict_free(proponent, framework.defeats): + return 0.0 + attacks_o_to_p = _attack_count(framework, opponent, proponent) + if attacks_o_to_p == 0: + return 1.0 + attacks_p_to_o = _attack_count(framework, proponent, opponent) + return 0.5 * ( + 1.0 + + _normalised_attack_count(attacks_p_to_o) + - _normalised_attack_count(attacks_o_to_p) + ) + + +def _attack_count( + framework: ArgumentationFramework, + source: frozenset[str], + target: frozenset[str], +) -> int: + return sum( + 1 + for attacker, attacked in framework.defeats + if attacker in source and attacked in target + ) + + +def _normalised_attack_count(count: int) -> float: + return count / (count + 1.0) + + +def _zero_sum_row_value(matrix: list[list[float]]) -> float: + matrix = _dominance_reduced(matrix) + row_count = len(matrix) + column_count = len(matrix[0]) + best = 0.0 + max_support = min(row_count, column_count) + for size in range(1, max_support + 1): + for rows in combinations(range(row_count), size): + for columns in combinations(range(column_count), size): + solution = _solve_active_system(matrix, rows, columns) + if solution is None: + continue + probabilities, value = solution + if any(probability < -1e-9 for probability in probabilities): + continue + payoffs = [ + sum( + probabilities[index] * matrix[row][column] + for index, row in enumerate(rows) + ) + for column in range(column_count) + ] + if min(payoffs) + 1e-8 >= value: + best = max(best, value) + pure = max(min(row) for row in matrix) + return min(1.0, max(0.0, max(best, pure))) + + +def _dominance_reduced(matrix: list[list[float]]) -> list[list[float]]: + reduced = [row[:] for row in matrix] + changed = True + while changed: + changed = False + row_count = len(reduced) + column_count = len(reduced[0]) + dominated_rows = { + row + for row in range(row_count) + if any( + row != other + and all(reduced[other][column] >= reduced[row][column] for column in range(column_count)) + and any(reduced[other][column] > reduced[row][column] for column in range(column_count)) + for other in range(row_count) + ) + } + if dominated_rows and len(dominated_rows) < row_count: + reduced = [ + values + for row, values in enumerate(reduced) + if row not in dominated_rows + ] + changed = True + + row_count = len(reduced) + column_count = len(reduced[0]) + dominated_columns = { + column + for column in range(column_count) + if any( + column != other + and all(reduced[row][other] <= reduced[row][column] for row in range(row_count)) + and any(reduced[row][other] < reduced[row][column] for row in range(row_count)) + for other in range(column_count) + ) + } + if dominated_columns and len(dominated_columns) < column_count: + reduced = [ + [ + value + for column, value in enumerate(row) + if column not in dominated_columns + ] + for row in reduced + ] + changed = True + return reduced + + +def _solve_active_system( + matrix: list[list[float]], + rows: tuple[int, ...], + columns: tuple[int, ...], +) -> tuple[list[float], float] | None: + size = len(rows) + equations: list[list[float]] = [] + rhs: list[float] = [] + equations.append([1.0] * size + [0.0]) + rhs.append(1.0) + for column in columns: + equations.append([matrix[row][column] for row in rows] + [-1.0]) + rhs.append(0.0) + solved = _gaussian_elimination(equations, rhs) + if solved is None: + return None + return solved[:-1], solved[-1] + + +def _gaussian_elimination( + matrix: list[list[float]], + rhs: list[float], +) -> list[float] | None: + n = len(rhs) + rows = [row[:] + [value] for row, value in zip(matrix, rhs, strict=True)] + for column in range(n): + pivot = max(range(column, n), key=lambda row: abs(rows[row][column])) + if abs(rows[pivot][column]) < 1e-12: + return None + rows[column], rows[pivot] = rows[pivot], rows[column] + divisor = rows[column][column] + rows[column] = [value / divisor for value in rows[column]] + for row in range(n): + if row == column: + continue + factor = rows[row][column] + rows[row] = [ + current - factor * pivot_value + for current, pivot_value in zip(rows[row], rows[column], strict=True) + ] + return [rows[row][-1] for row in range(n)] + + +def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = tuple(sorted(arguments)) + return [ + frozenset(ordered[index] for index in range(len(ordered)) if mask & (1 << index)) + for mask in range(1 << len(ordered)) + ] diff --git a/src/argumentation/ranking.py b/src/argumentation/ranking/ranking.py similarity index 96% rename from src/argumentation/ranking.py rename to src/argumentation/ranking/ranking.py index 89a418f..b6abf4b 100644 --- a/src/argumentation/ranking.py +++ b/src/argumentation/ranking/ranking.py @@ -1,399 +1,399 @@ -"""Ranking-based semantics for abstract argumentation frameworks.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -from argumentation.dung import ArgumentationFramework - - -@dataclass(frozen=True) -class RankingResult: - """A ranking-semantics result. - - ``ranking`` is a total preorder represented best-to-worst as tiers. - ``converged=False`` is data, not an exception. - """ - - scores: dict[str, float] - ranking: tuple[frozenset[str], ...] - converged: bool - iterations: int - semantics: str - - def rank_index(self, argument: str) -> int: - for index, tier in enumerate(self.ranking): - if argument in tier: - return index - raise KeyError(argument) - - def strictly_prefers(self, left: str, right: str) -> bool: - return self.rank_index(left) < self.rank_index(right) - - def equivalent(self, left: str, right: str) -> bool: - return self.rank_index(left) == self.rank_index(right) - - -def categoriser_scores( - framework: ArgumentationFramework, - *, - tolerance: float = 1e-9, - max_iterations: int = 10_000, -) -> RankingResult: - """Compute Besnard-Hunter Categoriser scores. - - Bonzon et al. 2016, Definition 9: unattacked arguments receive 1; otherwise - an argument receives ``1 / (1 + sum(Cat(attacker)))``. - """ - - _validate_iteration_parameters(tolerance, max_iterations) - attackers = _attackers(framework) - scores = {argument: 1.0 for argument in framework.arguments} - - for iteration in range(1, max_iterations + 1): - updated = { - argument: ( - 1.0 - if not attackers[argument] - else 1.0 / (1.0 + sum(scores[attacker] for attacker in attackers[argument])) - ) - for argument in framework.arguments - } - delta = max( - (abs(updated[argument] - scores[argument]) for argument in framework.arguments), - default=0.0, - ) - scores = updated - if delta <= tolerance: - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=iteration, - semantics="categoriser", - ) - - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=False, - iterations=max_iterations, - semantics="categoriser", - ) - - -def categoriser_ranking( - framework: ArgumentationFramework, - *, - tolerance: float = 1e-9, - max_iterations: int = 10_000, -) -> RankingResult: - return categoriser_scores( - framework, - tolerance=tolerance, - max_iterations=max_iterations, - ) - - -def burden_numbers( - framework: ArgumentationFramework, - *, - iterations: int, - tolerance: float = 1e-9, -) -> RankingResult: - """Compute final Burden numbers. - - Bonzon et al. 2016, Definitions 15-16 use ``Bur_0(a)=1`` and - ``Bur_i(a)=1+sum(1/Bur_{i-1}(attacker))`` for later steps. Lower burden is - more acceptable. - """ - - if iterations < 0: - raise ValueError("iterations must be non-negative") - if tolerance <= 0.0: - raise ValueError("tolerance must be positive") - - attackers = _attackers(framework) - scores = {argument: 1.0 for argument in framework.arguments} - - for _ in range(iterations): - scores = { - argument: 1.0 + sum(1.0 / scores[attacker] for attacker in attackers[argument]) - for argument in framework.arguments - } - - return _result( - scores, - higher_is_better=False, - tolerance=tolerance, - converged=True, - iterations=iterations, - semantics="burden", - ) - - -def burden_ranking( - framework: ArgumentationFramework, - *, - iterations: int, - tolerance: float = 1e-9, -) -> RankingResult: - return burden_numbers(framework, iterations=iterations, tolerance=tolerance) - - -def discussion_based_ranking( - framework: ArgumentationFramework, - *, - max_depth: int | None = None, - tolerance: float = 1e-9, -) -> RankingResult: - """Compute a discussion-count ranking. - - Amgoud and Ben-Naim 2013 count alternating defence/attack discussions. - Here even-depth defenders increase acceptability and odd-depth attackers - decrease it, with diminishing depth weight. - """ - - attackers = _attackers(framework) - depth = max_depth if max_depth is not None else max(len(framework.arguments), 1) - scores: dict[str, float] = {} - for argument in framework.arguments: - total = 1.0 - frontier = {argument} - for level in range(1, depth + 1): - next_frontier = {attacker for target in frontier for attacker in attackers[target]} - if not next_frontier: - break - weight = 1.0 / (level + 1) - total += weight * len(next_frontier) * (1 if level % 2 == 0 else -1) - frontier = next_frontier - scores[argument] = total - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=depth, - semantics="discussion_based", - ) - - -def counting_ranking( - framework: ArgumentationFramework, - *, - damping: float = 0.9, - tolerance: float = 1e-9, - max_iterations: int = 10_000, -) -> RankingResult: - """Compute damped counting scores.""" - - if not 0.0 < damping < 1.0: - raise ValueError("damping must be between 0 and 1") - _validate_iteration_parameters(tolerance, max_iterations) - attackers = _attackers(framework) - scores = {argument: 1.0 for argument in framework.arguments} - for iteration in range(1, max_iterations + 1): - updated = { - argument: 1.0 / (1.0 + damping * sum(scores[attacker] for attacker in attackers[argument])) - for argument in framework.arguments - } - delta = max( - (abs(updated[argument] - scores[argument]) for argument in framework.arguments), - default=0.0, - ) - scores = updated - if delta <= tolerance: - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=iteration, - semantics="counting", - ) - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=False, - iterations=max_iterations, - semantics="counting", - ) - - -def tuples_ranking( - framework: ArgumentationFramework, - *, - max_depth: int | None = None, - tolerance: float = 1e-9, -) -> RankingResult: - """Compute tuple-style path rankings.""" - - attackers = _attackers(framework) - depth = max_depth if max_depth is not None else max(len(framework.arguments), 1) - scores: dict[str, float] = {} - for argument in framework.arguments: - total = 0.0 - frontier = {argument} - for level in range(1, depth + 1): - next_frontier = {attacker for target in frontier for attacker in attackers[target]} - if not next_frontier: - break - sign = -1.0 if level % 2 == 1 else 1.0 - total += sign * len(next_frontier) / (10.0**level) - frontier = next_frontier - scores[argument] = total - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=depth, - semantics="tuples", - ) - - -def h_categoriser_ranking( - framework: ArgumentationFramework, - *, - tolerance: float = 1e-9, - max_iterations: int = 10_000, -) -> RankingResult: - """Compute h-Categoriser scores with capped attacker aggregation.""" - - _validate_iteration_parameters(tolerance, max_iterations) - attackers = _attackers(framework) - scores = {argument: 1.0 for argument in framework.arguments} - for iteration in range(1, max_iterations + 1): - updated = { - argument: 1.0 / (1.0 + min(1.0, sum(scores[attacker] for attacker in attackers[argument]))) - for argument in framework.arguments - } - delta = max( - (abs(updated[argument] - scores[argument]) for argument in framework.arguments), - default=0.0, - ) - scores = updated - if delta <= tolerance: - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=iteration, - semantics="h_categoriser", - ) - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=False, - iterations=max_iterations, - semantics="h_categoriser", - ) - - -def iterated_graded_ranking( - framework: ArgumentationFramework, - *, - max_threshold: int | None = None, - tolerance: float = 1e-9, -) -> RankingResult: - """Compute an iterated graded score from defended attacker thresholds.""" - - attackers = _attackers(framework) - threshold = max_threshold if max_threshold is not None else max(len(framework.arguments), 1) - scores: dict[str, float] = {} - for argument in framework.arguments: - attacker_count = len(attackers[argument]) - defender_count = sum( - 1 - for defender, target in _attack_relation(framework) - if target in attackers[argument] and defender != argument - ) - score = 0.0 - for grade in range(1, threshold + 1): - if attacker_count < grade: - score += 1.0 - if defender_count >= grade: - score += 0.5 - scores[argument] = score - return _result( - scores, - higher_is_better=True, - tolerance=tolerance, - converged=True, - iterations=threshold, - semantics="iterated_graded", - ) - - -def _attack_relation( - framework: ArgumentationFramework, -) -> frozenset[tuple[str, str]]: - return framework.attacks if framework.attacks is not None else framework.defeats - - -def _attackers(framework: ArgumentationFramework) -> dict[str, frozenset[str]]: - attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in _attack_relation(framework): - attackers[target].add(attacker) - return { - argument: frozenset(values) - for argument, values in attackers.items() - } - - -def _result( - scores: dict[str, float], - *, - higher_is_better: bool, - tolerance: float, - converged: bool, - iterations: int, - semantics: str, -) -> RankingResult: - ranking = _ranking_from_scores( - scores, - higher_is_better=higher_is_better, - tolerance=tolerance, - ) - return RankingResult( - scores=dict(sorted(scores.items())), - ranking=ranking, - converged=converged, - iterations=iterations, - semantics=semantics, - ) - - -def _ranking_from_scores( - scores: dict[str, float], - *, - higher_is_better: bool, - tolerance: float, -) -> tuple[frozenset[str], ...]: - direction: Literal[-1, 1] = -1 if higher_is_better else 1 - ordered = sorted(scores, key=lambda argument: (direction * scores[argument], argument)) - tiers: list[frozenset[str]] = [] - tier_values: list[float] = [] - - for argument in ordered: - value = scores[argument] - if tier_values and abs(value - tier_values[-1]) <= tolerance: - tiers[-1] = frozenset(set(tiers[-1]) | {argument}) - continue - tiers.append(frozenset({argument})) - tier_values.append(value) - - return tuple(tiers) - - -def _validate_iteration_parameters(tolerance: float, max_iterations: int) -> None: - if tolerance <= 0.0: - raise ValueError("tolerance must be positive") - if max_iterations <= 0: - raise ValueError("max_iterations must be positive") +"""Ranking-based semantics for abstract argumentation frameworks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from argumentation.core.dung import ArgumentationFramework + + +@dataclass(frozen=True) +class RankingResult: + """A ranking-semantics result. + + ``ranking`` is a total preorder represented best-to-worst as tiers. + ``converged=False`` is data, not an exception. + """ + + scores: dict[str, float] + ranking: tuple[frozenset[str], ...] + converged: bool + iterations: int + semantics: str + + def rank_index(self, argument: str) -> int: + for index, tier in enumerate(self.ranking): + if argument in tier: + return index + raise KeyError(argument) + + def strictly_prefers(self, left: str, right: str) -> bool: + return self.rank_index(left) < self.rank_index(right) + + def equivalent(self, left: str, right: str) -> bool: + return self.rank_index(left) == self.rank_index(right) + + +def categoriser_scores( + framework: ArgumentationFramework, + *, + tolerance: float = 1e-9, + max_iterations: int = 10_000, +) -> RankingResult: + """Compute Besnard-Hunter Categoriser scores. + + Bonzon et al. 2016, Definition 9: unattacked arguments receive 1; otherwise + an argument receives ``1 / (1 + sum(Cat(attacker)))``. + """ + + _validate_iteration_parameters(tolerance, max_iterations) + attackers = _attackers(framework) + scores = {argument: 1.0 for argument in framework.arguments} + + for iteration in range(1, max_iterations + 1): + updated = { + argument: ( + 1.0 + if not attackers[argument] + else 1.0 / (1.0 + sum(scores[attacker] for attacker in attackers[argument])) + ) + for argument in framework.arguments + } + delta = max( + (abs(updated[argument] - scores[argument]) for argument in framework.arguments), + default=0.0, + ) + scores = updated + if delta <= tolerance: + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=iteration, + semantics="categoriser", + ) + + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=False, + iterations=max_iterations, + semantics="categoriser", + ) + + +def categoriser_ranking( + framework: ArgumentationFramework, + *, + tolerance: float = 1e-9, + max_iterations: int = 10_000, +) -> RankingResult: + return categoriser_scores( + framework, + tolerance=tolerance, + max_iterations=max_iterations, + ) + + +def burden_numbers( + framework: ArgumentationFramework, + *, + iterations: int, + tolerance: float = 1e-9, +) -> RankingResult: + """Compute final Burden numbers. + + Bonzon et al. 2016, Definitions 15-16 use ``Bur_0(a)=1`` and + ``Bur_i(a)=1+sum(1/Bur_{i-1}(attacker))`` for later steps. Lower burden is + more acceptable. + """ + + if iterations < 0: + raise ValueError("iterations must be non-negative") + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + + attackers = _attackers(framework) + scores = {argument: 1.0 for argument in framework.arguments} + + for _ in range(iterations): + scores = { + argument: 1.0 + sum(1.0 / scores[attacker] for attacker in attackers[argument]) + for argument in framework.arguments + } + + return _result( + scores, + higher_is_better=False, + tolerance=tolerance, + converged=True, + iterations=iterations, + semantics="burden", + ) + + +def burden_ranking( + framework: ArgumentationFramework, + *, + iterations: int, + tolerance: float = 1e-9, +) -> RankingResult: + return burden_numbers(framework, iterations=iterations, tolerance=tolerance) + + +def discussion_based_ranking( + framework: ArgumentationFramework, + *, + max_depth: int | None = None, + tolerance: float = 1e-9, +) -> RankingResult: + """Compute a discussion-count ranking. + + Amgoud and Ben-Naim 2013 count alternating defence/attack discussions. + Here even-depth defenders increase acceptability and odd-depth attackers + decrease it, with diminishing depth weight. + """ + + attackers = _attackers(framework) + depth = max_depth if max_depth is not None else max(len(framework.arguments), 1) + scores: dict[str, float] = {} + for argument in framework.arguments: + total = 1.0 + frontier = {argument} + for level in range(1, depth + 1): + next_frontier = {attacker for target in frontier for attacker in attackers[target]} + if not next_frontier: + break + weight = 1.0 / (level + 1) + total += weight * len(next_frontier) * (1 if level % 2 == 0 else -1) + frontier = next_frontier + scores[argument] = total + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=depth, + semantics="discussion_based", + ) + + +def counting_ranking( + framework: ArgumentationFramework, + *, + damping: float = 0.9, + tolerance: float = 1e-9, + max_iterations: int = 10_000, +) -> RankingResult: + """Compute damped counting scores.""" + + if not 0.0 < damping < 1.0: + raise ValueError("damping must be between 0 and 1") + _validate_iteration_parameters(tolerance, max_iterations) + attackers = _attackers(framework) + scores = {argument: 1.0 for argument in framework.arguments} + for iteration in range(1, max_iterations + 1): + updated = { + argument: 1.0 / (1.0 + damping * sum(scores[attacker] for attacker in attackers[argument])) + for argument in framework.arguments + } + delta = max( + (abs(updated[argument] - scores[argument]) for argument in framework.arguments), + default=0.0, + ) + scores = updated + if delta <= tolerance: + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=iteration, + semantics="counting", + ) + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=False, + iterations=max_iterations, + semantics="counting", + ) + + +def tuples_ranking( + framework: ArgumentationFramework, + *, + max_depth: int | None = None, + tolerance: float = 1e-9, +) -> RankingResult: + """Compute tuple-style path rankings.""" + + attackers = _attackers(framework) + depth = max_depth if max_depth is not None else max(len(framework.arguments), 1) + scores: dict[str, float] = {} + for argument in framework.arguments: + total = 0.0 + frontier = {argument} + for level in range(1, depth + 1): + next_frontier = {attacker for target in frontier for attacker in attackers[target]} + if not next_frontier: + break + sign = -1.0 if level % 2 == 1 else 1.0 + total += sign * len(next_frontier) / (10.0**level) + frontier = next_frontier + scores[argument] = total + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=depth, + semantics="tuples", + ) + + +def h_categoriser_ranking( + framework: ArgumentationFramework, + *, + tolerance: float = 1e-9, + max_iterations: int = 10_000, +) -> RankingResult: + """Compute h-Categoriser scores with capped attacker aggregation.""" + + _validate_iteration_parameters(tolerance, max_iterations) + attackers = _attackers(framework) + scores = {argument: 1.0 for argument in framework.arguments} + for iteration in range(1, max_iterations + 1): + updated = { + argument: 1.0 / (1.0 + min(1.0, sum(scores[attacker] for attacker in attackers[argument]))) + for argument in framework.arguments + } + delta = max( + (abs(updated[argument] - scores[argument]) for argument in framework.arguments), + default=0.0, + ) + scores = updated + if delta <= tolerance: + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=iteration, + semantics="h_categoriser", + ) + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=False, + iterations=max_iterations, + semantics="h_categoriser", + ) + + +def iterated_graded_ranking( + framework: ArgumentationFramework, + *, + max_threshold: int | None = None, + tolerance: float = 1e-9, +) -> RankingResult: + """Compute an iterated graded score from defended attacker thresholds.""" + + attackers = _attackers(framework) + threshold = max_threshold if max_threshold is not None else max(len(framework.arguments), 1) + scores: dict[str, float] = {} + for argument in framework.arguments: + attacker_count = len(attackers[argument]) + defender_count = sum( + 1 + for defender, target in _attack_relation(framework) + if target in attackers[argument] and defender != argument + ) + score = 0.0 + for grade in range(1, threshold + 1): + if attacker_count < grade: + score += 1.0 + if defender_count >= grade: + score += 0.5 + scores[argument] = score + return _result( + scores, + higher_is_better=True, + tolerance=tolerance, + converged=True, + iterations=threshold, + semantics="iterated_graded", + ) + + +def _attack_relation( + framework: ArgumentationFramework, +) -> frozenset[tuple[str, str]]: + return framework.attacks if framework.attacks is not None else framework.defeats + + +def _attackers(framework: ArgumentationFramework) -> dict[str, frozenset[str]]: + attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in _attack_relation(framework): + attackers[target].add(attacker) + return { + argument: frozenset(values) + for argument, values in attackers.items() + } + + +def _result( + scores: dict[str, float], + *, + higher_is_better: bool, + tolerance: float, + converged: bool, + iterations: int, + semantics: str, +) -> RankingResult: + ranking = _ranking_from_scores( + scores, + higher_is_better=higher_is_better, + tolerance=tolerance, + ) + return RankingResult( + scores=dict(sorted(scores.items())), + ranking=ranking, + converged=converged, + iterations=iterations, + semantics=semantics, + ) + + +def _ranking_from_scores( + scores: dict[str, float], + *, + higher_is_better: bool, + tolerance: float, +) -> tuple[frozenset[str], ...]: + direction: Literal[-1, 1] = -1 if higher_is_better else 1 + ordered = sorted(scores, key=lambda argument: (direction * scores[argument], argument)) + tiers: list[frozenset[str]] = [] + tier_values: list[float] = [] + + for argument in ordered: + value = scores[argument] + if tier_values and abs(value - tier_values[-1]) <= tolerance: + tiers[-1] = frozenset(set(tiers[-1]) | {argument}) + continue + tiers.append(frozenset({argument})) + tier_values.append(value) + + return tuple(tiers) + + +def _validate_iteration_parameters(tolerance: float, max_iterations: int) -> None: + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + if max_iterations <= 0: + raise ValueError("max_iterations must be positive") diff --git a/src/argumentation/ranking_axioms.py b/src/argumentation/ranking/ranking_axioms.py similarity index 96% rename from src/argumentation/ranking_axioms.py rename to src/argumentation/ranking/ranking_axioms.py index fec8b0a..b94f836 100644 --- a/src/argumentation/ranking_axioms.py +++ b/src/argumentation/ranking/ranking_axioms.py @@ -1,443 +1,443 @@ -"""Executable checks for common ranking-semantics postulates. - -The predicates in this module materialize the ranking-postulate vocabulary from -Amgoud and Ben-Naim 2013 pp. 3-8 and Bonzon et al. 2016 pp. 1-2. They check a -single finite framework/result pair; callers that need universal claims should -run them across generated or enumerated framework families. -""" - -from __future__ import annotations - -from collections.abc import Callable - -from argumentation.dung import ArgumentationFramework -from argumentation.ranking import RankingResult - -RankingSemantics = Callable[[ArgumentationFramework], RankingResult] - - -def strict_preference_transitive(result: RankingResult) -> bool: - """Return whether strict preference induced by the ranking is transitive.""" - - arguments = frozenset(result.scores) - return all( - not (result.strictly_prefers(left, middle) and result.strictly_prefers(middle, right)) - or result.strictly_prefers(left, right) - for left in arguments - for middle in arguments - for right in arguments - ) - - -def abstraction( - semantics: RankingSemantics, - framework: ArgumentationFramework, -) -> bool: - """Check one canonical isomorphism witness for abstraction. - - Amgoud and Ben-Naim 2013 p. 3: rankings must be invariant under argument - renaming. The predicate renames every argument deterministically and checks - that every pairwise comparison is preserved. - """ - - renaming = { - argument: f"iso_{index}" - for index, argument in enumerate(sorted(framework.arguments)) - } - renamed = ArgumentationFramework( - arguments=frozenset(renaming.values()), - defeats=frozenset((renaming[left], renaming[right]) for left, right in framework.defeats), - attacks=( - None - if framework.attacks is None - else frozenset((renaming[left], renaming[right]) for left, right in framework.attacks) - ), - ) - original_result = semantics(framework) - renamed_result = semantics(renamed) - - return all( - _same_pair_order( - original_result, - left, - right, - renamed_result, - renaming[left], - renaming[right], - ) - for left in framework.arguments - for right in framework.arguments - ) - - -def independence( - semantics: RankingSemantics, - framework: ArgumentationFramework, -) -> bool: - """Check weak-component independence for the supplied framework. - - Amgoud and Ben-Naim 2013 p. 4: rankings inside one disconnected component - must not change because another component is present. - """ - - full_result = semantics(framework) - for component in _weak_components(framework): - if len(component) < 2: - continue - component_framework = _induced_framework(framework, component) - component_result = semantics(component_framework) - for left in component: - for right in component: - if not _same_pair_order(full_result, left, right, component_result, left, right): - return False - return True - - -def void_precedence( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check that unattacked arguments outrank attacked arguments. - - Amgoud and Ben-Naim 2013 pp. 4-5 and Bonzon et al. 2016 p. 1: every - unattacked argument must be strictly above every attacked argument. - """ - - attackers = _attackers(framework) - unattacked = {argument for argument, values in attackers.items() if not values} - attacked = set(framework.arguments) - unattacked - return all( - result.strictly_prefers(left, right) - for left in unattacked - for right in attacked - ) - - -def self_contradiction( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Bonzon et al. 2016 p. 1 self-contradiction precedence.""" - - self_attacking = {argument for argument in framework.arguments if (argument, argument) in _attack_relation(framework)} - clean = set(framework.arguments) - self_attacking - return all( - not result.strictly_prefers(self_attacker, other) - for self_attacker in self_attacking - for other in clean - ) - - -def defense_precedence( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Amgoud and Ben-Naim 2013 p. 5 defense precedence.""" - - attackers = _attackers(framework) - for defended in framework.arguments: - defended_attackers = attackers[defended] - if not defended_attackers or not _every_attacker_is_attacked(defended_attackers, framework): - continue - for undefended in framework.arguments: - undefended_attackers = attackers[undefended] - if ( - len(defended_attackers) == len(undefended_attackers) - and undefended_attackers - and not _any_attacker_is_attacked(undefended_attackers, framework) - and not result.strictly_prefers(defended, undefended) - ): - return False - return True - - -def counter_transitivity( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Amgoud and Ben-Naim 2013 p. 6 counter-transitivity.""" - - attackers = _attackers(framework) - for left in framework.arguments: - for right in framework.arguments: - if _group_at_least_as_acceptable( - attackers[right], - attackers[left], - result, - strict=False, - ) and not _at_least_as_acceptable(left, right, result): - return False - return True - - -def strict_counter_transitivity( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Amgoud and Ben-Naim 2013 p. 6 strict counter-transitivity.""" - - attackers = _attackers(framework) - for left in framework.arguments: - for right in framework.arguments: - if _group_at_least_as_acceptable( - attackers[right], - attackers[left], - result, - strict=True, - ) and not result.strictly_prefers(left, right): - return False - return True - - -def cardinality_precedence( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check the fewer-unattacked-attackers postulate where applicable. - - Amgoud and Ben-Naim 2013 p. 8 and Bonzon et al. 2016 p. 1: when direct - attackers are all unattacked, fewer attackers strictly improves rank. - """ - - attackers = _attackers(framework) - unattacked = {argument for argument, values in attackers.items() if not values} - for left in framework.arguments: - left_attackers = attackers[left] - if not left_attackers or not left_attackers <= unattacked: - continue - for right in framework.arguments: - right_attackers = attackers[right] - if ( - len(left_attackers) < len(right_attackers) - and right_attackers - and right_attackers <= unattacked - and not result.strictly_prefers(left, right) - ): - return False - return True - - -def quality_precedence( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Bonzon et al. 2016 p. 1 quality precedence for singleton attacks.""" - - attackers = _attackers(framework) - for left in framework.arguments: - if len(attackers[left]) != 1: - continue - left_attacker = next(iter(attackers[left])) - for right in framework.arguments: - if len(attackers[right]) != 1: - continue - right_attacker = next(iter(attackers[right])) - if ( - result.strictly_prefers(right_attacker, left_attacker) - and not result.strictly_prefers(left, right) - ): - return False - return True - - -def distributed_defense_precedence( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check Amgoud and Ben-Naim 2013 p. 8 distributed defense precedence.""" - - for left in framework.arguments: - left_profile = _defense_profile(framework, left) - if not left_profile["simple"] or not left_profile["distributed"]: - continue - for right in framework.arguments: - right_profile = _defense_profile(framework, right) - if ( - right_profile["simple"] - and not right_profile["distributed"] - and left_profile["attacker_count"] == right_profile["attacker_count"] - and left_profile["defender_count"] == right_profile["defender_count"] - and not result.strictly_prefers(left, right) - ): - return False - return True - - -def strict_addition_of_defense_branch( - framework: ArgumentationFramework, - result: RankingResult, -) -> bool: - """Check the strict addition-of-defense-branch pattern. - - Bonzon et al. 2016 p. 5 lists ``+AB`` among ranking properties. On one - framework/result pair, the checkable local shape is: with equal direct - attacker count, an argument whose attackers receive at least one defender is - strictly above the corresponding no-defense shape. - """ - - attackers = _attackers(framework) - for left in framework.arguments: - left_attackers = attackers[left] - if not left_attackers or not _any_attacker_is_attacked(left_attackers, framework): - continue - for right in framework.arguments: - right_attackers = attackers[right] - if ( - len(left_attackers) == len(right_attackers) - and right_attackers - and not _any_attacker_is_attacked(right_attackers, framework) - and not result.strictly_prefers(left, right) - ): - return False - return True - - -def _attack_relation( - framework: ArgumentationFramework, -) -> frozenset[tuple[str, str]]: - return framework.attacks if framework.attacks is not None else framework.defeats - - -def _attackers(framework: ArgumentationFramework) -> dict[str, frozenset[str]]: - attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in _attack_relation(framework): - attackers[target].add(attacker) - return {argument: frozenset(values) for argument, values in attackers.items()} - - -def _same_pair_order( - left_result: RankingResult, - left_a: str, - left_b: str, - right_result: RankingResult, - right_a: str, - right_b: str, -) -> bool: - return ( - left_result.strictly_prefers(left_a, left_b) - == right_result.strictly_prefers(right_a, right_b) - and left_result.equivalent(left_a, left_b) - == right_result.equivalent(right_a, right_b) - ) - - -def _weak_components(framework: ArgumentationFramework) -> list[frozenset[str]]: - neighbors: dict[str, set[str]] = {argument: set() for argument in framework.arguments} - for attacker, target in _attack_relation(framework): - neighbors[attacker].add(target) - neighbors[target].add(attacker) - - unseen = set(framework.arguments) - components: list[frozenset[str]] = [] - while unseen: - seed = unseen.pop() - component = {seed} - stack = [seed] - while stack: - current = stack.pop() - for neighbor in neighbors[current] & unseen: - unseen.remove(neighbor) - component.add(neighbor) - stack.append(neighbor) - components.append(frozenset(component)) - return components - - -def _induced_framework( - framework: ArgumentationFramework, - arguments: frozenset[str], -) -> ArgumentationFramework: - return ArgumentationFramework( - arguments=arguments, - defeats=frozenset( - (left, right) - for left, right in framework.defeats - if left in arguments and right in arguments - ), - attacks=( - None - if framework.attacks is None - else frozenset( - (left, right) - for left, right in framework.attacks - if left in arguments and right in arguments - ) - ), - ) - - -def _at_least_as_acceptable(left: str, right: str, result: RankingResult) -> bool: - return result.strictly_prefers(left, right) or result.equivalent(left, right) - - -def _group_at_least_as_acceptable( - better_group: frozenset[str], - worse_group: frozenset[str], - result: RankingResult, - *, - strict: bool, -) -> bool: - if len(better_group) < len(worse_group): - return False - if not worse_group: - return bool(better_group) if strict else True - - better = tuple(sorted(better_group)) - worse = tuple(sorted(worse_group)) - used: set[str] = set() - saw_strict = len(better_group) > len(worse_group) - - for weaker in worse: - match = next( - ( - candidate - for candidate in better - if candidate not in used and _at_least_as_acceptable(candidate, weaker, result) - ), - None, - ) - if match is None: - return False - used.add(match) - saw_strict = saw_strict or result.strictly_prefers(match, weaker) - return saw_strict if strict else True - - -def _every_attacker_is_attacked( - attackers: frozenset[str], - framework: ArgumentationFramework, -) -> bool: - relation = _attack_relation(framework) - return all(any(target == attacker for _, target in relation) for attacker in attackers) - - -def _any_attacker_is_attacked( - attackers: frozenset[str], - framework: ArgumentationFramework, -) -> bool: - relation = _attack_relation(framework) - return any(target in attackers for _, target in relation) - - -def _defense_profile( - framework: ArgumentationFramework, - argument: str, -) -> dict[str, bool | int]: - attackers = _attackers(framework)[argument] - relation = _attack_relation(framework) - defender_targets: dict[str, set[str]] = {} - target_defenders: dict[str, set[str]] = {attacker: set() for attacker in attackers} - for defender, target in relation: - if target in attackers: - defender_targets.setdefault(defender, set()).add(target) - target_defenders[target].add(defender) - - defenders = set(defender_targets) - simple = bool(defenders) and all(len(targets) == 1 for targets in defender_targets.values()) - distributed = simple and all(len(values) <= 1 for values in target_defenders.values()) - return { - "attacker_count": len(attackers), - "defender_count": len(defenders), - "simple": simple, - "distributed": distributed, - } +"""Executable checks for common ranking-semantics postulates. + +The predicates in this module materialize the ranking-postulate vocabulary from +Amgoud and Ben-Naim 2013 pp. 3-8 and Bonzon et al. 2016 pp. 1-2. They check a +single finite framework/result pair; callers that need universal claims should +run them across generated or enumerated framework families. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.ranking import RankingResult + +RankingSemantics = Callable[[ArgumentationFramework], RankingResult] + + +def strict_preference_transitive(result: RankingResult) -> bool: + """Return whether strict preference induced by the ranking is transitive.""" + + arguments = frozenset(result.scores) + return all( + not (result.strictly_prefers(left, middle) and result.strictly_prefers(middle, right)) + or result.strictly_prefers(left, right) + for left in arguments + for middle in arguments + for right in arguments + ) + + +def abstraction( + semantics: RankingSemantics, + framework: ArgumentationFramework, +) -> bool: + """Check one canonical isomorphism witness for abstraction. + + Amgoud and Ben-Naim 2013 p. 3: rankings must be invariant under argument + renaming. The predicate renames every argument deterministically and checks + that every pairwise comparison is preserved. + """ + + renaming = { + argument: f"iso_{index}" + for index, argument in enumerate(sorted(framework.arguments)) + } + renamed = ArgumentationFramework( + arguments=frozenset(renaming.values()), + defeats=frozenset((renaming[left], renaming[right]) for left, right in framework.defeats), + attacks=( + None + if framework.attacks is None + else frozenset((renaming[left], renaming[right]) for left, right in framework.attacks) + ), + ) + original_result = semantics(framework) + renamed_result = semantics(renamed) + + return all( + _same_pair_order( + original_result, + left, + right, + renamed_result, + renaming[left], + renaming[right], + ) + for left in framework.arguments + for right in framework.arguments + ) + + +def independence( + semantics: RankingSemantics, + framework: ArgumentationFramework, +) -> bool: + """Check weak-component independence for the supplied framework. + + Amgoud and Ben-Naim 2013 p. 4: rankings inside one disconnected component + must not change because another component is present. + """ + + full_result = semantics(framework) + for component in _weak_components(framework): + if len(component) < 2: + continue + component_framework = _induced_framework(framework, component) + component_result = semantics(component_framework) + for left in component: + for right in component: + if not _same_pair_order(full_result, left, right, component_result, left, right): + return False + return True + + +def void_precedence( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check that unattacked arguments outrank attacked arguments. + + Amgoud and Ben-Naim 2013 pp. 4-5 and Bonzon et al. 2016 p. 1: every + unattacked argument must be strictly above every attacked argument. + """ + + attackers = _attackers(framework) + unattacked = {argument for argument, values in attackers.items() if not values} + attacked = set(framework.arguments) - unattacked + return all( + result.strictly_prefers(left, right) + for left in unattacked + for right in attacked + ) + + +def self_contradiction( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Bonzon et al. 2016 p. 1 self-contradiction precedence.""" + + self_attacking = {argument for argument in framework.arguments if (argument, argument) in _attack_relation(framework)} + clean = set(framework.arguments) - self_attacking + return all( + not result.strictly_prefers(self_attacker, other) + for self_attacker in self_attacking + for other in clean + ) + + +def defense_precedence( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Amgoud and Ben-Naim 2013 p. 5 defense precedence.""" + + attackers = _attackers(framework) + for defended in framework.arguments: + defended_attackers = attackers[defended] + if not defended_attackers or not _every_attacker_is_attacked(defended_attackers, framework): + continue + for undefended in framework.arguments: + undefended_attackers = attackers[undefended] + if ( + len(defended_attackers) == len(undefended_attackers) + and undefended_attackers + and not _any_attacker_is_attacked(undefended_attackers, framework) + and not result.strictly_prefers(defended, undefended) + ): + return False + return True + + +def counter_transitivity( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Amgoud and Ben-Naim 2013 p. 6 counter-transitivity.""" + + attackers = _attackers(framework) + for left in framework.arguments: + for right in framework.arguments: + if _group_at_least_as_acceptable( + attackers[right], + attackers[left], + result, + strict=False, + ) and not _at_least_as_acceptable(left, right, result): + return False + return True + + +def strict_counter_transitivity( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Amgoud and Ben-Naim 2013 p. 6 strict counter-transitivity.""" + + attackers = _attackers(framework) + for left in framework.arguments: + for right in framework.arguments: + if _group_at_least_as_acceptable( + attackers[right], + attackers[left], + result, + strict=True, + ) and not result.strictly_prefers(left, right): + return False + return True + + +def cardinality_precedence( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check the fewer-unattacked-attackers postulate where applicable. + + Amgoud and Ben-Naim 2013 p. 8 and Bonzon et al. 2016 p. 1: when direct + attackers are all unattacked, fewer attackers strictly improves rank. + """ + + attackers = _attackers(framework) + unattacked = {argument for argument, values in attackers.items() if not values} + for left in framework.arguments: + left_attackers = attackers[left] + if not left_attackers or not left_attackers <= unattacked: + continue + for right in framework.arguments: + right_attackers = attackers[right] + if ( + len(left_attackers) < len(right_attackers) + and right_attackers + and right_attackers <= unattacked + and not result.strictly_prefers(left, right) + ): + return False + return True + + +def quality_precedence( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Bonzon et al. 2016 p. 1 quality precedence for singleton attacks.""" + + attackers = _attackers(framework) + for left in framework.arguments: + if len(attackers[left]) != 1: + continue + left_attacker = next(iter(attackers[left])) + for right in framework.arguments: + if len(attackers[right]) != 1: + continue + right_attacker = next(iter(attackers[right])) + if ( + result.strictly_prefers(right_attacker, left_attacker) + and not result.strictly_prefers(left, right) + ): + return False + return True + + +def distributed_defense_precedence( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check Amgoud and Ben-Naim 2013 p. 8 distributed defense precedence.""" + + for left in framework.arguments: + left_profile = _defense_profile(framework, left) + if not left_profile["simple"] or not left_profile["distributed"]: + continue + for right in framework.arguments: + right_profile = _defense_profile(framework, right) + if ( + right_profile["simple"] + and not right_profile["distributed"] + and left_profile["attacker_count"] == right_profile["attacker_count"] + and left_profile["defender_count"] == right_profile["defender_count"] + and not result.strictly_prefers(left, right) + ): + return False + return True + + +def strict_addition_of_defense_branch( + framework: ArgumentationFramework, + result: RankingResult, +) -> bool: + """Check the strict addition-of-defense-branch pattern. + + Bonzon et al. 2016 p. 5 lists ``+AB`` among ranking properties. On one + framework/result pair, the checkable local shape is: with equal direct + attacker count, an argument whose attackers receive at least one defender is + strictly above the corresponding no-defense shape. + """ + + attackers = _attackers(framework) + for left in framework.arguments: + left_attackers = attackers[left] + if not left_attackers or not _any_attacker_is_attacked(left_attackers, framework): + continue + for right in framework.arguments: + right_attackers = attackers[right] + if ( + len(left_attackers) == len(right_attackers) + and right_attackers + and not _any_attacker_is_attacked(right_attackers, framework) + and not result.strictly_prefers(left, right) + ): + return False + return True + + +def _attack_relation( + framework: ArgumentationFramework, +) -> frozenset[tuple[str, str]]: + return framework.attacks if framework.attacks is not None else framework.defeats + + +def _attackers(framework: ArgumentationFramework) -> dict[str, frozenset[str]]: + attackers: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in _attack_relation(framework): + attackers[target].add(attacker) + return {argument: frozenset(values) for argument, values in attackers.items()} + + +def _same_pair_order( + left_result: RankingResult, + left_a: str, + left_b: str, + right_result: RankingResult, + right_a: str, + right_b: str, +) -> bool: + return ( + left_result.strictly_prefers(left_a, left_b) + == right_result.strictly_prefers(right_a, right_b) + and left_result.equivalent(left_a, left_b) + == right_result.equivalent(right_a, right_b) + ) + + +def _weak_components(framework: ArgumentationFramework) -> list[frozenset[str]]: + neighbors: dict[str, set[str]] = {argument: set() for argument in framework.arguments} + for attacker, target in _attack_relation(framework): + neighbors[attacker].add(target) + neighbors[target].add(attacker) + + unseen = set(framework.arguments) + components: list[frozenset[str]] = [] + while unseen: + seed = unseen.pop() + component = {seed} + stack = [seed] + while stack: + current = stack.pop() + for neighbor in neighbors[current] & unseen: + unseen.remove(neighbor) + component.add(neighbor) + stack.append(neighbor) + components.append(frozenset(component)) + return components + + +def _induced_framework( + framework: ArgumentationFramework, + arguments: frozenset[str], +) -> ArgumentationFramework: + return ArgumentationFramework( + arguments=arguments, + defeats=frozenset( + (left, right) + for left, right in framework.defeats + if left in arguments and right in arguments + ), + attacks=( + None + if framework.attacks is None + else frozenset( + (left, right) + for left, right in framework.attacks + if left in arguments and right in arguments + ) + ), + ) + + +def _at_least_as_acceptable(left: str, right: str, result: RankingResult) -> bool: + return result.strictly_prefers(left, right) or result.equivalent(left, right) + + +def _group_at_least_as_acceptable( + better_group: frozenset[str], + worse_group: frozenset[str], + result: RankingResult, + *, + strict: bool, +) -> bool: + if len(better_group) < len(worse_group): + return False + if not worse_group: + return bool(better_group) if strict else True + + better = tuple(sorted(better_group)) + worse = tuple(sorted(worse_group)) + used: set[str] = set() + saw_strict = len(better_group) > len(worse_group) + + for weaker in worse: + match = next( + ( + candidate + for candidate in better + if candidate not in used and _at_least_as_acceptable(candidate, weaker, result) + ), + None, + ) + if match is None: + return False + used.add(match) + saw_strict = saw_strict or result.strictly_prefers(match, weaker) + return saw_strict if strict else True + + +def _every_attacker_is_attacked( + attackers: frozenset[str], + framework: ArgumentationFramework, +) -> bool: + relation = _attack_relation(framework) + return all(any(target == attacker for _, target in relation) for attacker in attackers) + + +def _any_attacker_is_attacked( + attackers: frozenset[str], + framework: ArgumentationFramework, +) -> bool: + relation = _attack_relation(framework) + return any(target in attackers for _, target in relation) + + +def _defense_profile( + framework: ArgumentationFramework, + argument: str, +) -> dict[str, bool | int]: + attackers = _attackers(framework)[argument] + relation = _attack_relation(framework) + defender_targets: dict[str, set[str]] = {} + target_defenders: dict[str, set[str]] = {attacker: set() for attacker in attackers} + for defender, target in relation: + if target in attackers: + defender_targets.setdefault(defender, set()).add(target) + target_defenders[target].add(defender) + + defenders = set(defender_targets) + simple = bool(defenders) and all(len(targets) == 1 for targets in defender_targets.values()) + distributed = simple and all(len(values) <= 1 for values in target_defenders.values()) + return { + "attacker_count": len(attackers), + "defender_count": len(defenders), + "simple": simple, + "distributed": distributed, + } diff --git a/src/argumentation/weighted.py b/src/argumentation/ranking/weighted.py similarity index 95% rename from src/argumentation/weighted.py rename to src/argumentation/ranking/weighted.py index 90e70aa..03c05f1 100644 --- a/src/argumentation/weighted.py +++ b/src/argumentation/ranking/weighted.py @@ -1,157 +1,157 @@ -"""Weighted argument systems with inconsistency budgets.""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import combinations -from math import inf -from typing import Mapping - -from argumentation.dung import ArgumentationFramework, grounded_extension - - -@dataclass(frozen=True) -class WeightedArgumentationFramework: - """A Dung AF with positive weights on attacks. - - Dunne et al. 2011, Definitions 4-6: a weighted argument system assigns a - positive real-valued weight to each attack and uses a non-negative budget to - bound which attacks may be disregarded. - """ - - arguments: frozenset[str] - attacks: frozenset[tuple[str, str]] - weights: Mapping[tuple[str, str], float] - - def __post_init__(self) -> None: - arguments = frozenset(str(argument) for argument in self.arguments) - attacks = frozenset((str(source), str(target)) for source, target in self.attacks) - - unknown = sorted( - (source, target) - for source, target in attacks - if source not in arguments or target not in arguments - ) - if unknown: - raise ValueError(f"attacks must only reference declared arguments: {unknown!r}") - - normalized_weights = { - (str(source), str(target)): float(weight) - for (source, target), weight in self.weights.items() - } - if set(normalized_weights) != set(attacks): - raise ValueError("weights must cover exactly the attack relation") - non_positive = sorted( - attack for attack, weight in normalized_weights.items() - if weight <= 0.0 - ) - if non_positive: - raise ValueError(f"attack weights must be positive: {non_positive!r}") - - object.__setattr__(self, "arguments", arguments) - object.__setattr__(self, "attacks", attacks) - object.__setattr__(self, "weights", normalized_weights) - - def as_dung_framework( - self, - *, - deleted_attacks: frozenset[tuple[str, str]] = frozenset(), - ) -> ArgumentationFramework: - return ArgumentationFramework( - arguments=self.arguments, - defeats=self.attacks - deleted_attacks, - ) - - def deleted_weight(self, deleted_attacks: frozenset[tuple[str, str]]) -> float: - unknown = deleted_attacks - self.attacks - if unknown: - raise ValueError(f"deleted_attacks contains undeclared attacks: {sorted(unknown)!r}") - return sum(self.weights[attack] for attack in deleted_attacks) - - -@dataclass(frozen=True) -class WeightedGroundedExtension: - """A grounded extension plus the deleted-attack witness that realizes it.""" - - extension: frozenset[str] - deleted_attacks: frozenset[tuple[str, str]] - deleted_weight: float - - -def weighted_grounded_extensions( - framework: WeightedArgumentationFramework, - *, - budget: float, -) -> list[WeightedGroundedExtension]: - """Return beta-grounded extensions with minimum-cost witnesses. - - This is the direct executable definition: enumerate ``R`` such that - ``wt(R,w) <= beta`` and compute ordinary grounded semantics over - ``A \\ R``. If several deleted-attack sets realize the same extension, the - cheapest deterministic witness is retained. - """ - if budget < 0.0: - raise ValueError("budget must be non-negative") - - best_by_extension: dict[frozenset[str], WeightedGroundedExtension] = {} - for deleted_attacks in _attack_subsets(framework.attacks): - cost = framework.deleted_weight(deleted_attacks) - if cost > budget: - continue - extension = grounded_extension( - framework.as_dung_framework(deleted_attacks=deleted_attacks) - ) - candidate = WeightedGroundedExtension( - extension=extension, - deleted_attacks=deleted_attacks, - deleted_weight=cost, - ) - previous = best_by_extension.get(extension) - if previous is None or _witness_sort_key(candidate) < _witness_sort_key(previous): - best_by_extension[extension] = candidate - - return sorted(best_by_extension.values(), key=_witness_sort_key) - - -def minimum_budget_for_grounded_acceptance( - framework: WeightedArgumentationFramework, - argument: str, -) -> float: - """Return the cheapest budget that makes ``argument`` grounded-accepted.""" - if argument not in framework.arguments: - raise ValueError(f"unknown argument: {argument!r}") - - best = inf - for deleted_attacks in _attack_subsets(framework.attacks): - cost = framework.deleted_weight(deleted_attacks) - if cost >= best: - continue - extension = grounded_extension( - framework.as_dung_framework(deleted_attacks=deleted_attacks) - ) - if argument in extension: - best = cost - - return best - - -def _attack_subsets( - attacks: frozenset[tuple[str, str]], -) -> list[frozenset[tuple[str, str]]]: - ordered = sorted(attacks) - subsets: list[frozenset[tuple[str, str]]] = [] - for size in range(len(ordered) + 1): - for subset in combinations(ordered, size): - subsets.append(frozenset(subset)) - return subsets - - -def _witness_sort_key( - result: WeightedGroundedExtension, -) -> tuple[float, int, tuple[str, ...], tuple[tuple[str, str], ...]]: - return ( - result.deleted_weight, - len(result.extension), - tuple(sorted(result.extension)), - tuple(sorted(result.deleted_attacks)), - ) +"""Weighted argument systems with inconsistency budgets.""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations +from math import inf +from typing import Mapping + +from argumentation.core.dung import ArgumentationFramework, grounded_extension + + +@dataclass(frozen=True) +class WeightedArgumentationFramework: + """A Dung AF with positive weights on attacks. + + Dunne et al. 2011, Definitions 4-6: a weighted argument system assigns a + positive real-valued weight to each attack and uses a non-negative budget to + bound which attacks may be disregarded. + """ + + arguments: frozenset[str] + attacks: frozenset[tuple[str, str]] + weights: Mapping[tuple[str, str], float] + + def __post_init__(self) -> None: + arguments = frozenset(str(argument) for argument in self.arguments) + attacks = frozenset((str(source), str(target)) for source, target in self.attacks) + + unknown = sorted( + (source, target) + for source, target in attacks + if source not in arguments or target not in arguments + ) + if unknown: + raise ValueError(f"attacks must only reference declared arguments: {unknown!r}") + + normalized_weights = { + (str(source), str(target)): float(weight) + for (source, target), weight in self.weights.items() + } + if set(normalized_weights) != set(attacks): + raise ValueError("weights must cover exactly the attack relation") + non_positive = sorted( + attack for attack, weight in normalized_weights.items() + if weight <= 0.0 + ) + if non_positive: + raise ValueError(f"attack weights must be positive: {non_positive!r}") + + object.__setattr__(self, "arguments", arguments) + object.__setattr__(self, "attacks", attacks) + object.__setattr__(self, "weights", normalized_weights) + + def as_dung_framework( + self, + *, + deleted_attacks: frozenset[tuple[str, str]] = frozenset(), + ) -> ArgumentationFramework: + return ArgumentationFramework( + arguments=self.arguments, + defeats=self.attacks - deleted_attacks, + ) + + def deleted_weight(self, deleted_attacks: frozenset[tuple[str, str]]) -> float: + unknown = deleted_attacks - self.attacks + if unknown: + raise ValueError(f"deleted_attacks contains undeclared attacks: {sorted(unknown)!r}") + return sum(self.weights[attack] for attack in deleted_attacks) + + +@dataclass(frozen=True) +class WeightedGroundedExtension: + """A grounded extension plus the deleted-attack witness that realizes it.""" + + extension: frozenset[str] + deleted_attacks: frozenset[tuple[str, str]] + deleted_weight: float + + +def weighted_grounded_extensions( + framework: WeightedArgumentationFramework, + *, + budget: float, +) -> list[WeightedGroundedExtension]: + """Return beta-grounded extensions with minimum-cost witnesses. + + This is the direct executable definition: enumerate ``R`` such that + ``wt(R,w) <= beta`` and compute ordinary grounded semantics over + ``A \\ R``. If several deleted-attack sets realize the same extension, the + cheapest deterministic witness is retained. + """ + if budget < 0.0: + raise ValueError("budget must be non-negative") + + best_by_extension: dict[frozenset[str], WeightedGroundedExtension] = {} + for deleted_attacks in _attack_subsets(framework.attacks): + cost = framework.deleted_weight(deleted_attacks) + if cost > budget: + continue + extension = grounded_extension( + framework.as_dung_framework(deleted_attacks=deleted_attacks) + ) + candidate = WeightedGroundedExtension( + extension=extension, + deleted_attacks=deleted_attacks, + deleted_weight=cost, + ) + previous = best_by_extension.get(extension) + if previous is None or _witness_sort_key(candidate) < _witness_sort_key(previous): + best_by_extension[extension] = candidate + + return sorted(best_by_extension.values(), key=_witness_sort_key) + + +def minimum_budget_for_grounded_acceptance( + framework: WeightedArgumentationFramework, + argument: str, +) -> float: + """Return the cheapest budget that makes ``argument`` grounded-accepted.""" + if argument not in framework.arguments: + raise ValueError(f"unknown argument: {argument!r}") + + best = inf + for deleted_attacks in _attack_subsets(framework.attacks): + cost = framework.deleted_weight(deleted_attacks) + if cost >= best: + continue + extension = grounded_extension( + framework.as_dung_framework(deleted_attacks=deleted_attacks) + ) + if argument in extension: + best = cost + + return best + + +def _attack_subsets( + attacks: frozenset[tuple[str, str]], +) -> list[frozenset[tuple[str, str]]]: + ordered = sorted(attacks) + subsets: list[frozenset[tuple[str, str]]] = [] + for size in range(len(ordered) + 1): + for subset in combinations(ordered, size): + subsets.append(frozenset(subset)) + return subsets + + +def _witness_sort_key( + result: WeightedGroundedExtension, +) -> tuple[float, int, tuple[str, ...], tuple[tuple[str, str], ...]]: + return ( + result.deleted_weight, + len(result.extension), + tuple(sorted(result.extension)), + tuple(sorted(result.deleted_attacks)), + ) diff --git a/src/argumentation/semantics.py b/src/argumentation/semantics.py index 7490964..24070b0 100644 --- a/src/argumentation/semantics.py +++ b/src/argumentation/semantics.py @@ -1,193 +1,193 @@ -"""Generic extension and acceptance dispatch for formal argumentation kernels.""" - -from __future__ import annotations - -from typing import Final - -from argumentation.bipolar import ( - BipolarArgumentationFramework, - bipolar_complete_extensions, - bipolar_grounded_extension, - c_preferred_extensions, - d_preferred_extensions, - s_preferred_extensions, - stable_extensions as bipolar_stable_extensions, -) -from argumentation.dung import ( - ArgumentationFramework, - cf2_extensions, - complete_extensions, - grounded_extension, - ideal_extension, - preferred_extensions, - prudent_grounded_extension, - prudent_preferred_extensions, - semi_stable_extensions, - stage2_extensions, - stage_extensions, - stable_extensions, -) -from argumentation.partial_af import ( - PartialArgumentationFramework, - enumerate_completions, -) - - -class SemanticsUndefinedType: - """Sentinel for acceptance queries with no extension family.""" - - __slots__ = () - - def __repr__(self) -> str: - return "SemanticsUndefined" - - -SemanticsUndefined: Final = SemanticsUndefinedType() - - -def _sorted_extensions( - values: list[frozenset[str]] | tuple[frozenset[str], ...], -) -> tuple[frozenset[str], ...]: - return tuple(sorted( - (frozenset(value) for value in values), - key=lambda extension: (len(extension), tuple(sorted(extension))), - )) - - -def _unique_sorted_extensions( - values: list[frozenset[str]] | tuple[frozenset[str], ...], -) -> tuple[frozenset[str], ...]: - return _sorted_extensions(tuple(dict.fromkeys(frozenset(value) for value in values))) - - -def _dung_extensions( - framework: ArgumentationFramework, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - if semantics == "grounded": - return (grounded_extension(framework),) - if semantics == "ideal": - return (ideal_extension(framework),) - if semantics == "complete": - return _sorted_extensions(tuple(complete_extensions(framework))) - if semantics == "preferred": - return _sorted_extensions(tuple(preferred_extensions(framework))) - if semantics == "semi-stable": - return _sorted_extensions(tuple(semi_stable_extensions(framework))) - if semantics == "stage": - return _sorted_extensions(tuple(stage_extensions(framework))) - if semantics == "stage2": - return _sorted_extensions(tuple(stage2_extensions(framework))) - if semantics == "cf2": - return _sorted_extensions(tuple(cf2_extensions(framework))) - if semantics == "prudent-grounded": - return (prudent_grounded_extension(framework),) - if semantics == "prudent-preferred": - return _sorted_extensions(tuple(prudent_preferred_extensions(framework))) - if semantics == "stable": - return _sorted_extensions(tuple(stable_extensions(framework))) - raise ValueError(f"Unknown Dung semantics: {semantics}") - - -def _bipolar_extensions( - framework: BipolarArgumentationFramework, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - if semantics == "d-preferred": - return _sorted_extensions(tuple(d_preferred_extensions(framework))) - if semantics == "s-preferred": - return _sorted_extensions(tuple(s_preferred_extensions(framework))) - if semantics == "c-preferred": - return _sorted_extensions(tuple(c_preferred_extensions(framework))) - if semantics == "bipolar-stable": - return _sorted_extensions(tuple(bipolar_stable_extensions(framework))) - if semantics == "bipolar-grounded": - return (bipolar_grounded_extension(framework),) - if semantics == "bipolar-complete": - return _sorted_extensions(tuple(bipolar_complete_extensions(framework))) - raise ValueError(f"Unknown bipolar semantics: {semantics}") - - -def _partial_extensions( - framework: PartialArgumentationFramework, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - if semantics not in {"grounded", "preferred", "stable"}: - raise ValueError(f"Unknown partial-AF semantics: {semantics}") - - completion_extensions: list[frozenset[str]] = [] - for completion in enumerate_completions(framework): - completion_extensions.extend( - _dung_extensions(completion, semantics=semantics) - ) - return _unique_sorted_extensions(tuple(completion_extensions)) - - -def extensions( - framework: object, - *, - semantics: str, -) -> tuple[frozenset[str], ...]: - """Return extensions for an argumentation-owned framework dataclass.""" - if isinstance(framework, PartialArgumentationFramework): - return _partial_extensions(framework, semantics=semantics) - if isinstance(framework, ArgumentationFramework): - return _dung_extensions(framework, semantics=semantics) - if isinstance(framework, BipolarArgumentationFramework): - return _bipolar_extensions(framework, semantics=semantics) - raise TypeError(f"Unsupported framework type: {type(framework)!r}") - - -def accepted_arguments( - framework: object, - *, - semantics: str, - mode: str = "credulous", -) -> frozenset[str] | SemanticsUndefinedType: - """Return credulously or skeptically accepted arguments.""" - if mode not in {"credulous", "skeptical", "necessary_skeptical", "possible_skeptical"}: - raise ValueError( - "mode must be 'credulous', 'skeptical', " - "'necessary_skeptical', or 'possible_skeptical'" - ) - - if isinstance(framework, PartialArgumentationFramework): - if mode == "skeptical": - raise ValueError( - "mode='skeptical' is ambiguous for partial AFs; use " - "'necessary_skeptical' or 'possible_skeptical'" - ) - if mode == "possible_skeptical": - accepted: set[str] = set() - for completion in enumerate_completions(framework): - completion_extensions = _dung_extensions(completion, semantics=semantics) - if not completion_extensions: - continue - skeptical = set(completion_extensions[0]) - for extension in completion_extensions[1:]: - skeptical.intersection_update(extension) - accepted.update(skeptical) - return frozenset(accepted) - if mode == "necessary_skeptical": - mode = "skeptical" - - extension_sets = extensions(framework, semantics=semantics) - if not extension_sets: - return SemanticsUndefined - - if mode == "credulous": - accepted: set[str] = set() - for extension in extension_sets: - accepted.update(extension) - return frozenset(accepted) - - skeptical = set(extension_sets[0]) - for extension in extension_sets[1:]: - skeptical.intersection_update(extension) - return frozenset(skeptical) - - -__all__ = ["SemanticsUndefined", "accepted_arguments", "extensions"] +"""Generic extension and acceptance dispatch for formal argumentation kernels.""" + +from __future__ import annotations + +from typing import Final + +from argumentation.core.bipolar import ( + BipolarArgumentationFramework, + bipolar_complete_extensions, + bipolar_grounded_extension, + c_preferred_extensions, + d_preferred_extensions, + s_preferred_extensions, + stable_extensions as bipolar_stable_extensions, +) +from argumentation.core.dung import ( + ArgumentationFramework, + cf2_extensions, + complete_extensions, + grounded_extension, + ideal_extension, + preferred_extensions, + prudent_grounded_extension, + prudent_preferred_extensions, + semi_stable_extensions, + stage2_extensions, + stage_extensions, + stable_extensions, +) +from argumentation.frameworks.partial_af import ( + PartialArgumentationFramework, + enumerate_completions, +) + + +class SemanticsUndefinedType: + """Sentinel for acceptance queries with no extension family.""" + + __slots__ = () + + def __repr__(self) -> str: + return "SemanticsUndefined" + + +SemanticsUndefined: Final = SemanticsUndefinedType() + + +def _sorted_extensions( + values: list[frozenset[str]] | tuple[frozenset[str], ...], +) -> tuple[frozenset[str], ...]: + return tuple(sorted( + (frozenset(value) for value in values), + key=lambda extension: (len(extension), tuple(sorted(extension))), + )) + + +def _unique_sorted_extensions( + values: list[frozenset[str]] | tuple[frozenset[str], ...], +) -> tuple[frozenset[str], ...]: + return _sorted_extensions(tuple(dict.fromkeys(frozenset(value) for value in values))) + + +def _dung_extensions( + framework: ArgumentationFramework, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + if semantics == "grounded": + return (grounded_extension(framework),) + if semantics == "ideal": + return (ideal_extension(framework),) + if semantics == "complete": + return _sorted_extensions(tuple(complete_extensions(framework))) + if semantics == "preferred": + return _sorted_extensions(tuple(preferred_extensions(framework))) + if semantics == "semi-stable": + return _sorted_extensions(tuple(semi_stable_extensions(framework))) + if semantics == "stage": + return _sorted_extensions(tuple(stage_extensions(framework))) + if semantics == "stage2": + return _sorted_extensions(tuple(stage2_extensions(framework))) + if semantics == "cf2": + return _sorted_extensions(tuple(cf2_extensions(framework))) + if semantics == "prudent-grounded": + return (prudent_grounded_extension(framework),) + if semantics == "prudent-preferred": + return _sorted_extensions(tuple(prudent_preferred_extensions(framework))) + if semantics == "stable": + return _sorted_extensions(tuple(stable_extensions(framework))) + raise ValueError(f"Unknown Dung semantics: {semantics}") + + +def _bipolar_extensions( + framework: BipolarArgumentationFramework, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + if semantics == "d-preferred": + return _sorted_extensions(tuple(d_preferred_extensions(framework))) + if semantics == "s-preferred": + return _sorted_extensions(tuple(s_preferred_extensions(framework))) + if semantics == "c-preferred": + return _sorted_extensions(tuple(c_preferred_extensions(framework))) + if semantics == "bipolar-stable": + return _sorted_extensions(tuple(bipolar_stable_extensions(framework))) + if semantics == "bipolar-grounded": + return (bipolar_grounded_extension(framework),) + if semantics == "bipolar-complete": + return _sorted_extensions(tuple(bipolar_complete_extensions(framework))) + raise ValueError(f"Unknown bipolar semantics: {semantics}") + + +def _partial_extensions( + framework: PartialArgumentationFramework, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + if semantics not in {"grounded", "preferred", "stable"}: + raise ValueError(f"Unknown partial-AF semantics: {semantics}") + + completion_extensions: list[frozenset[str]] = [] + for completion in enumerate_completions(framework): + completion_extensions.extend( + _dung_extensions(completion, semantics=semantics) + ) + return _unique_sorted_extensions(tuple(completion_extensions)) + + +def extensions( + framework: object, + *, + semantics: str, +) -> tuple[frozenset[str], ...]: + """Return extensions for an argumentation-owned framework dataclass.""" + if isinstance(framework, PartialArgumentationFramework): + return _partial_extensions(framework, semantics=semantics) + if isinstance(framework, ArgumentationFramework): + return _dung_extensions(framework, semantics=semantics) + if isinstance(framework, BipolarArgumentationFramework): + return _bipolar_extensions(framework, semantics=semantics) + raise TypeError(f"Unsupported framework type: {type(framework)!r}") + + +def accepted_arguments( + framework: object, + *, + semantics: str, + mode: str = "credulous", +) -> frozenset[str] | SemanticsUndefinedType: + """Return credulously or skeptically accepted arguments.""" + if mode not in {"credulous", "skeptical", "necessary_skeptical", "possible_skeptical"}: + raise ValueError( + "mode must be 'credulous', 'skeptical', " + "'necessary_skeptical', or 'possible_skeptical'" + ) + + if isinstance(framework, PartialArgumentationFramework): + if mode == "skeptical": + raise ValueError( + "mode='skeptical' is ambiguous for partial AFs; use " + "'necessary_skeptical' or 'possible_skeptical'" + ) + if mode == "possible_skeptical": + accepted: set[str] = set() + for completion in enumerate_completions(framework): + completion_extensions = _dung_extensions(completion, semantics=semantics) + if not completion_extensions: + continue + skeptical = set(completion_extensions[0]) + for extension in completion_extensions[1:]: + skeptical.intersection_update(extension) + accepted.update(skeptical) + return frozenset(accepted) + if mode == "necessary_skeptical": + mode = "skeptical" + + extension_sets = extensions(framework, semantics=semantics) + if not extension_sets: + return SemanticsUndefined + + if mode == "credulous": + accepted: set[str] = set() + for extension in extension_sets: + accepted.update(extension) + return frozenset(accepted) + + skeptical = set(extension_sets[0]) + for extension in extension_sets[1:]: + skeptical.intersection_update(extension) + return frozenset(skeptical) + + +__all__ = ["SemanticsUndefined", "accepted_arguments", "extensions"] diff --git a/src/argumentation/solver_adapters/clingo.py b/src/argumentation/solver_adapters/clingo.py index 1e0e738..34ff15b 100644 --- a/src/argumentation/solver_adapters/clingo.py +++ b/src/argumentation/solver_adapters/clingo.py @@ -1,333 +1,333 @@ -"""Subprocess helper for clingo-backed ASP-style solver protocols.""" - -from __future__ import annotations - -from dataclasses import dataclass -from importlib import resources -import importlib.util -from pathlib import Path -import re -import shutil -import subprocess -import sys -import tempfile - -from argumentation.solver_results import ( - SolverProcessError, - SolverProtocolError, - SolverUnavailable, -) - - -_ACCEPTED_ARG_RE = re.compile(r"^accepted_arg\((?P[A-Za-z_][A-Za-z0-9_]*)\)$") -_ACCEPTED_LIT_RE = re.compile(r"^accepted_lit\((?P[A-Za-z_][A-Za-z0-9_]*)\)$") -_CLINGO_CONTROL_TOKENS = { - "Answer:", - "SATISFIABLE", - "UNSATISFIABLE", - "UNKNOWN", - "OPTIMUM", - "FOUND", - "Models", - "Calls", - "Time", - "CPU", -} - - -@dataclass(frozen=True) -class ClingoAnswerSetSuccess: - backend: str - accepted_argument_ids: frozenset[str] - accepted_literal_ids: frozenset[str] - stdout: str - - -@dataclass(frozen=True) -class ClingoExtensionEnumerationSuccess: - backend: str - extensions: tuple[frozenset[str], ...] - extension_literal_ids: tuple[frozenset[str], ...] - stdout: str - - -ClingoUnavailable = SolverUnavailable -ClingoProcessError = SolverProcessError -ClingoProtocolError = SolverProtocolError - - -ClingoResult = ( - ClingoAnswerSetSuccess - | ClingoExtensionEnumerationSuccess - | ClingoUnavailable - | ClingoProcessError - | ClingoProtocolError -) -ClingoExtensionEnumerationResult = ( - ClingoExtensionEnumerationSuccess - | ClingoUnavailable - | ClingoProcessError - | ClingoProtocolError -) -ClingoAnswerSetResult = ( - ClingoAnswerSetSuccess - | ClingoUnavailable - | ClingoProcessError - | ClingoProtocolError -) - - -def run_extension_enumeration_protocol( - *, - facts: tuple[str, ...], - encoding_modules: tuple[str, ...], - known_argument_ids: frozenset[str], - known_literal_ids: frozenset[str] = frozenset(), - binary: str, - timeout_seconds: float = 30.0, - problem: str = "ASP-EXT", -) -> ClingoExtensionEnumerationResult: - """Run clingo over facts plus packaged modules and parse all extensions.""" - command_prefix = _resolve_command(binary) - if command_prefix is None: - return ClingoUnavailable( - backend=binary, - reason="binary not found on PATH", - install_hint="Install clingo or pass binary=... to the backend solver.", - ) - - try: - modules = tuple(_read_encoding_module(module) for module in encoding_modules) - except FileNotFoundError as exc: - return ClingoProtocolError( - backend=binary, - problem=problem, - message=f"packaged encoding module not found: {exc.filename}", - stderr="", - stdout="", - ) - - path = _write_temp_program(facts, modules=modules) - try: - completed = subprocess.run( - [*command_prefix, str(path), "0"], - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) - finally: - path.unlink(missing_ok=True) - - if completed.returncode != 0: - return ClingoProcessError( - backend=binary, - problem=problem, - returncode=completed.returncode, - stderr=completed.stderr, - stdout=completed.stdout, - ) - - try: - extensions, extension_literal_ids = _parse_extension_answer_sets( - completed.stdout, - known_argument_ids=known_argument_ids, - known_literal_ids=known_literal_ids, - ) - except ValueError as exc: - return ClingoProtocolError( - backend=binary, - problem=problem, - message=str(exc), - stderr=completed.stderr, - stdout=completed.stdout, - ) - - return ClingoExtensionEnumerationSuccess( - backend=binary, - extensions=extensions, - extension_literal_ids=extension_literal_ids, - stdout=completed.stdout, - ) - - -def run_aspic_grounded_protocol( - *, - facts: tuple[str, ...], - known_literal_ids: frozenset[str], - binary: str, - timeout_seconds: float = 30.0, -) -> ClingoAnswerSetResult: - """Run clingo and parse ASPIC grounded accepted-atom protocol output.""" - command_prefix = _resolve_command(binary) - if command_prefix is None: - return ClingoUnavailable( - backend=binary, - reason="binary not found on PATH", - install_hint="Install clingo or pass binary=... to solve_aspic_with_backend.", - ) - - path = _write_temp_program(facts) - try: - completed = subprocess.run( - [*command_prefix, str(path)], - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) - finally: - path.unlink(missing_ok=True) - - if completed.returncode != 0: - return ClingoProcessError( - backend=binary, - problem="ASPIC-GR", - returncode=completed.returncode, - stderr=completed.stderr, - stdout=completed.stdout, - ) - - try: - accepted_argument_ids, accepted_literal_ids = _parse_grounded_answer_set( - completed.stdout, - known_literal_ids=known_literal_ids, - ) - except ValueError as exc: - return ClingoProtocolError( - backend=binary, - problem="ASPIC-GR", - message=str(exc), - stderr=completed.stderr, - stdout=completed.stdout, - ) - - return ClingoAnswerSetSuccess( - backend=binary, - accepted_argument_ids=accepted_argument_ids, - accepted_literal_ids=accepted_literal_ids, - stdout=completed.stdout, - ) - - -def _parse_grounded_answer_set( - stdout: str, - *, - known_literal_ids: frozenset[str], -) -> tuple[frozenset[str], frozenset[str]]: - accepted_argument_ids: set[str] = set() - accepted_literal_ids: set[str] = set() - for token in stdout.split(): - if token.isdigit() or token in _CLINGO_CONTROL_TOKENS: - continue - arg_match = _ACCEPTED_ARG_RE.fullmatch(token) - if arg_match is not None: - accepted_argument_ids.add(arg_match.group("id")) - continue - lit_match = _ACCEPTED_LIT_RE.fullmatch(token) - if lit_match is not None: - literal_id = lit_match.group("id") - if literal_id not in known_literal_ids: - raise ValueError("accepted literal id is not in the ASPIC encoding") - accepted_literal_ids.add(literal_id) - continue - return frozenset(accepted_argument_ids), frozenset(accepted_literal_ids) - - -def _parse_extension_answer_sets( - stdout: str, - *, - known_argument_ids: frozenset[str], - known_literal_ids: frozenset[str], -) -> tuple[tuple[frozenset[str], ...], tuple[frozenset[str], ...]]: - answer_sets: list[tuple[frozenset[str], frozenset[str]]] = [] - current_args: set[str] | None = None - current_lits: set[str] | None = None - - for line in stdout.splitlines(): - stripped = line.strip() - if stripped.startswith("Answer:"): - if current_args is not None and current_lits is not None: - answer_sets.append((frozenset(current_args), frozenset(current_lits))) - current_args = set() - current_lits = set() - continue - if current_args is None or current_lits is None: - continue - if not stripped: - continue - if stripped in _CLINGO_CONTROL_TOKENS or stripped.startswith("Optimization:"): - answer_sets.append((frozenset(current_args), frozenset(current_lits))) - current_args = None - current_lits = None - continue - for token in stripped.split(): - arg_match = _ACCEPTED_ARG_RE.fullmatch(token) - if arg_match is not None: - argument_id = arg_match.group("id") - if argument_id not in known_argument_ids: - raise ValueError("accepted argument id is not in the encoding") - current_args.add(argument_id) - continue - lit_match = _ACCEPTED_LIT_RE.fullmatch(token) - if lit_match is not None: - literal_id = lit_match.group("id") - if known_literal_ids and literal_id not in known_literal_ids: - raise ValueError("accepted literal id is not in the encoding") - current_lits.add(literal_id) - continue - if token not in _CLINGO_CONTROL_TOKENS and not token.isdigit(): - raise ValueError(f"unexpected clingo output token: {token}") - - if current_args is not None and current_lits is not None: - answer_sets.append((frozenset(current_args), frozenset(current_lits))) - - answer_sets = sorted( - set(answer_sets), - key=lambda item: (len(item[0]), tuple(sorted(item[0])), tuple(sorted(item[1]))), - ) - return ( - tuple(extension for extension, _literal_ids in answer_sets), - tuple(literal_ids for _extension, literal_ids in answer_sets), - ) - - -def _resolve_command(binary: str) -> list[str] | None: - path = Path(binary) - if path.exists(): - return [str(path)] - resolved = shutil.which(binary) - if resolved is not None: - return [resolved] - if binary == "clingo" and importlib.util.find_spec("clingo") is not None: - return [sys.executable, "-m", "clingo"] - return None - - -def _read_encoding_module(name: str) -> str: - return ( - resources.files("argumentation") - .joinpath("encodings", name) - .read_text(encoding="utf-8") - ) - - -def _write_temp_program( - facts: tuple[str, ...], - *, - modules: tuple[str, ...] = (), -) -> Path: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - suffix=".lp", - delete=False, - ) as handle: - for module in modules: - handle.write(module) - if not module.endswith("\n"): - handle.write("\n") - for fact in facts: - handle.write(fact) - handle.write("\n") - return Path(handle.name) +"""Subprocess helper for clingo-backed ASP-style solver protocols.""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import resources +import importlib.util +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +from argumentation.core.solver_results import ( + SolverProcessError, + SolverProtocolError, + SolverUnavailable, +) + + +_ACCEPTED_ARG_RE = re.compile(r"^accepted_arg\((?P[A-Za-z_][A-Za-z0-9_]*)\)$") +_ACCEPTED_LIT_RE = re.compile(r"^accepted_lit\((?P[A-Za-z_][A-Za-z0-9_]*)\)$") +_CLINGO_CONTROL_TOKENS = { + "Answer:", + "SATISFIABLE", + "UNSATISFIABLE", + "UNKNOWN", + "OPTIMUM", + "FOUND", + "Models", + "Calls", + "Time", + "CPU", +} + + +@dataclass(frozen=True) +class ClingoAnswerSetSuccess: + backend: str + accepted_argument_ids: frozenset[str] + accepted_literal_ids: frozenset[str] + stdout: str + + +@dataclass(frozen=True) +class ClingoExtensionEnumerationSuccess: + backend: str + extensions: tuple[frozenset[str], ...] + extension_literal_ids: tuple[frozenset[str], ...] + stdout: str + + +ClingoUnavailable = SolverUnavailable +ClingoProcessError = SolverProcessError +ClingoProtocolError = SolverProtocolError + + +ClingoResult = ( + ClingoAnswerSetSuccess + | ClingoExtensionEnumerationSuccess + | ClingoUnavailable + | ClingoProcessError + | ClingoProtocolError +) +ClingoExtensionEnumerationResult = ( + ClingoExtensionEnumerationSuccess + | ClingoUnavailable + | ClingoProcessError + | ClingoProtocolError +) +ClingoAnswerSetResult = ( + ClingoAnswerSetSuccess + | ClingoUnavailable + | ClingoProcessError + | ClingoProtocolError +) + + +def run_extension_enumeration_protocol( + *, + facts: tuple[str, ...], + encoding_modules: tuple[str, ...], + known_argument_ids: frozenset[str], + known_literal_ids: frozenset[str] = frozenset(), + binary: str, + timeout_seconds: float = 30.0, + problem: str = "ASP-EXT", +) -> ClingoExtensionEnumerationResult: + """Run clingo over facts plus packaged modules and parse all extensions.""" + command_prefix = _resolve_command(binary) + if command_prefix is None: + return ClingoUnavailable( + backend=binary, + reason="binary not found on PATH", + install_hint="Install clingo or pass binary=... to the backend solver.", + ) + + try: + modules = tuple(_read_encoding_module(module) for module in encoding_modules) + except FileNotFoundError as exc: + return ClingoProtocolError( + backend=binary, + problem=problem, + message=f"packaged encoding module not found: {exc.filename}", + stderr="", + stdout="", + ) + + path = _write_temp_program(facts, modules=modules) + try: + completed = subprocess.run( + [*command_prefix, str(path), "0"], + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + finally: + path.unlink(missing_ok=True) + + if completed.returncode != 0: + return ClingoProcessError( + backend=binary, + problem=problem, + returncode=completed.returncode, + stderr=completed.stderr, + stdout=completed.stdout, + ) + + try: + extensions, extension_literal_ids = _parse_extension_answer_sets( + completed.stdout, + known_argument_ids=known_argument_ids, + known_literal_ids=known_literal_ids, + ) + except ValueError as exc: + return ClingoProtocolError( + backend=binary, + problem=problem, + message=str(exc), + stderr=completed.stderr, + stdout=completed.stdout, + ) + + return ClingoExtensionEnumerationSuccess( + backend=binary, + extensions=extensions, + extension_literal_ids=extension_literal_ids, + stdout=completed.stdout, + ) + + +def run_aspic_grounded_protocol( + *, + facts: tuple[str, ...], + known_literal_ids: frozenset[str], + binary: str, + timeout_seconds: float = 30.0, +) -> ClingoAnswerSetResult: + """Run clingo and parse ASPIC grounded accepted-atom protocol output.""" + command_prefix = _resolve_command(binary) + if command_prefix is None: + return ClingoUnavailable( + backend=binary, + reason="binary not found on PATH", + install_hint="Install clingo or pass binary=... to solve_aspic_with_backend.", + ) + + path = _write_temp_program(facts) + try: + completed = subprocess.run( + [*command_prefix, str(path)], + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + finally: + path.unlink(missing_ok=True) + + if completed.returncode != 0: + return ClingoProcessError( + backend=binary, + problem="ASPIC-GR", + returncode=completed.returncode, + stderr=completed.stderr, + stdout=completed.stdout, + ) + + try: + accepted_argument_ids, accepted_literal_ids = _parse_grounded_answer_set( + completed.stdout, + known_literal_ids=known_literal_ids, + ) + except ValueError as exc: + return ClingoProtocolError( + backend=binary, + problem="ASPIC-GR", + message=str(exc), + stderr=completed.stderr, + stdout=completed.stdout, + ) + + return ClingoAnswerSetSuccess( + backend=binary, + accepted_argument_ids=accepted_argument_ids, + accepted_literal_ids=accepted_literal_ids, + stdout=completed.stdout, + ) + + +def _parse_grounded_answer_set( + stdout: str, + *, + known_literal_ids: frozenset[str], +) -> tuple[frozenset[str], frozenset[str]]: + accepted_argument_ids: set[str] = set() + accepted_literal_ids: set[str] = set() + for token in stdout.split(): + if token.isdigit() or token in _CLINGO_CONTROL_TOKENS: + continue + arg_match = _ACCEPTED_ARG_RE.fullmatch(token) + if arg_match is not None: + accepted_argument_ids.add(arg_match.group("id")) + continue + lit_match = _ACCEPTED_LIT_RE.fullmatch(token) + if lit_match is not None: + literal_id = lit_match.group("id") + if literal_id not in known_literal_ids: + raise ValueError("accepted literal id is not in the ASPIC encoding") + accepted_literal_ids.add(literal_id) + continue + return frozenset(accepted_argument_ids), frozenset(accepted_literal_ids) + + +def _parse_extension_answer_sets( + stdout: str, + *, + known_argument_ids: frozenset[str], + known_literal_ids: frozenset[str], +) -> tuple[tuple[frozenset[str], ...], tuple[frozenset[str], ...]]: + answer_sets: list[tuple[frozenset[str], frozenset[str]]] = [] + current_args: set[str] | None = None + current_lits: set[str] | None = None + + for line in stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("Answer:"): + if current_args is not None and current_lits is not None: + answer_sets.append((frozenset(current_args), frozenset(current_lits))) + current_args = set() + current_lits = set() + continue + if current_args is None or current_lits is None: + continue + if not stripped: + continue + if stripped in _CLINGO_CONTROL_TOKENS or stripped.startswith("Optimization:"): + answer_sets.append((frozenset(current_args), frozenset(current_lits))) + current_args = None + current_lits = None + continue + for token in stripped.split(): + arg_match = _ACCEPTED_ARG_RE.fullmatch(token) + if arg_match is not None: + argument_id = arg_match.group("id") + if argument_id not in known_argument_ids: + raise ValueError("accepted argument id is not in the encoding") + current_args.add(argument_id) + continue + lit_match = _ACCEPTED_LIT_RE.fullmatch(token) + if lit_match is not None: + literal_id = lit_match.group("id") + if known_literal_ids and literal_id not in known_literal_ids: + raise ValueError("accepted literal id is not in the encoding") + current_lits.add(literal_id) + continue + if token not in _CLINGO_CONTROL_TOKENS and not token.isdigit(): + raise ValueError(f"unexpected clingo output token: {token}") + + if current_args is not None and current_lits is not None: + answer_sets.append((frozenset(current_args), frozenset(current_lits))) + + answer_sets = sorted( + set(answer_sets), + key=lambda item: (len(item[0]), tuple(sorted(item[0])), tuple(sorted(item[1]))), + ) + return ( + tuple(extension for extension, _literal_ids in answer_sets), + tuple(literal_ids for _extension, literal_ids in answer_sets), + ) + + +def _resolve_command(binary: str) -> list[str] | None: + path = Path(binary) + if path.exists(): + return [str(path)] + resolved = shutil.which(binary) + if resolved is not None: + return [resolved] + if binary == "clingo" and importlib.util.find_spec("clingo") is not None: + return [sys.executable, "-m", "clingo"] + return None + + +def _read_encoding_module(name: str) -> str: + return ( + resources.files("argumentation") + .joinpath("encodings", name) + .read_text(encoding="utf-8") + ) + + +def _write_temp_program( + facts: tuple[str, ...], + *, + modules: tuple[str, ...] = (), +) -> Path: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + suffix=".lp", + delete=False, + ) as handle: + for module in modules: + handle.write(module) + if not module.endswith("\n"): + handle.write("\n") + for fact in facts: + handle.write(fact) + handle.write("\n") + return Path(handle.name) diff --git a/src/argumentation/solver_adapters/iccma_aba.py b/src/argumentation/solver_adapters/iccma_aba.py index 2f58eaf..6c2d177 100644 --- a/src/argumentation/solver_adapters/iccma_aba.py +++ b/src/argumentation/solver_adapters/iccma_aba.py @@ -1,401 +1,401 @@ -"""Subprocess adapter for ICCMA-style flat-ABA solvers.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -import os -from pathlib import Path -import shlex -import shutil -import subprocess -import tempfile - -from argumentation.aba import ABAFramework -from argumentation.aspic import Literal -from argumentation.iccma import write_numeric_aba -from argumentation.solver_results import ( - SolverProcessError, - SolverProtocolError, - SolverUnavailable, -) - - -ACCEPTANCE_TASK_TO_PREFIX = { - "credulous": "DC", - "skeptical": "DS", -} - -SEMANTICS_TO_CODE = { - "complete": "CO", - "preferred": "PR", - "stable": "ST", -} - -SUPPORTED_ABA_PROBLEMS = frozenset( - { - "DC-CO", - "DC-ST", - "DS-PR", - "DS-ST", - "SE-PR", - "SE-ST", - } -) - - -class ICCMAABAOutputKind(Enum): - """Kinds of ICCMA 2023 ABA solver output supported by this adapter.""" - - DECISION = "decision" - SINGLE_EXTENSION = "single_extension" - - -class ICCMAABAOutputParseError(ValueError): - """Raised when ABA solver stdout does not match the selected ICCMA task.""" - - -@dataclass(frozen=True) -class ICCMAABAOutput: - problem: str - kind: ICCMAABAOutputKind - raw_stdout: str - answer: bool | None = None - witness: frozenset[Literal] | None = None - extensions: tuple[frozenset[Literal], ...] = () - no_extension: bool = False - - -@dataclass(frozen=True) -class ICCMAABASolverSuccess: - backend: str - problem: str - output: ICCMAABAOutput - stdout: str - - @property - def answer(self) -> bool | None: - return self.output.answer - - @property - def witness(self) -> frozenset[Literal] | None: - return self.output.witness - - @property - def extensions(self) -> tuple[frozenset[Literal], ...]: - return self.output.extensions - - -ICCMAABASolverUnavailable = SolverUnavailable -ICCMAABASolverError = SolverProcessError -ICCMAABASolverProtocolError = SolverProtocolError - - -ICCMAABASolverResult = ( - ICCMAABASolverSuccess - | ICCMAABASolverUnavailable - | ICCMAABASolverError - | ICCMAABASolverProtocolError -) - - -def parse_iccma_aba_output( - problem: str, - stdout: str, - *, - framework: ABAFramework, - query: Literal | None = None, -) -> ICCMAABAOutput: - """Parse ICCMA 2023 ABA solver stdout for DC, DS, and SE tasks.""" - prefix = _problem_prefix(problem) - lines = _semantic_lines(stdout) - if prefix == "SE": - return _parse_single_extension_output(problem, stdout, lines, framework) - if prefix in {"DC", "DS"}: - if query is None: - raise ICCMAABAOutputParseError(f"{problem} output parsing requires a query") - return _parse_decision_output(problem, stdout, lines) - raise ICCMAABAOutputParseError(f"unsupported ICCMA ABA problem: {problem}") - - -def solve_aba_extensions( - framework: ABAFramework, - *, - semantics: str, - binary: str, - timeout_seconds: float = 30.0, -) -> ICCMAABASolverResult: - """Invoke an ICCMA ABA solver for a single-extension query.""" - problem = _problem("SE", semantics) - if not supports_aba_problem("SE", semantics): - return _unsupported_problem(binary, problem) - return _run_iccma_aba_solver( - framework, - problem=problem, - binary=binary, - timeout_seconds=timeout_seconds, - ) - - -def solve_aba_acceptance( - framework: ABAFramework, - *, - semantics: str, - task: str, - query: Literal, - binary: str, - timeout_seconds: float = 30.0, -) -> ICCMAABASolverResult: - """Invoke an ICCMA ABA solver for a credulous or skeptical query.""" - prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task) - if prefix is None: - raise ValueError(f"unsupported ICCMA ABA acceptance task: {task}") - if query not in framework.language: - raise ValueError(f"query literal is not in framework language: {query!r}") - problem = _problem(prefix, semantics) - if not supports_aba_problem(prefix, semantics): - return _unsupported_problem(binary, problem) - return _run_iccma_aba_solver( - framework, - problem=problem, - binary=binary, - timeout_seconds=timeout_seconds, - query=query, - ) - - -def supports_aba_problem(task: str, semantics: str) -> bool: - prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task, task) - semantics_code = SEMANTICS_TO_CODE.get(semantics) - return semantics_code is not None and f"{prefix}-{semantics_code}" in SUPPORTED_ABA_PROBLEMS - - -def _run_iccma_aba_solver( - framework: ABAFramework, - *, - problem: str, - binary: str, - timeout_seconds: float, - query: Literal | None = None, -) -> ICCMAABASolverResult: - resolved = _resolve_command(binary) - if resolved is None: - return ICCMAABASolverUnavailable( - backend=binary, - reason="command executable not found on PATH", - install_hint=( - "Install an ICCMA-protocol ABA solver and pass its binary path or " - "command line." - ), - ) - - path = _write_temp_aba(framework) - try: - completed = subprocess.run( - _command(resolved, problem, path, framework, query), - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) - except subprocess.TimeoutExpired as exc: - return ICCMAABASolverError( - backend=binary, - problem=problem, - returncode=-1, - stderr=_timeout_stream(exc.stderr), - stdout=_timeout_stream(exc.stdout), - ) - finally: - path.unlink(missing_ok=True) - - if completed.returncode != 0: - return ICCMAABASolverError( - backend=binary, - problem=problem, - returncode=completed.returncode, - stderr=completed.stderr, - stdout=completed.stdout, - ) - - try: - output = parse_iccma_aba_output( - problem, - completed.stdout, - framework=framework, - query=query, - ) - except ICCMAABAOutputParseError as exc: - return ICCMAABASolverProtocolError( - backend=binary, - problem=problem, - message=str(exc), - stderr=completed.stderr, - stdout=completed.stdout, - ) - - return ICCMAABASolverSuccess( - backend=binary, - problem=problem, - output=output, - stdout=completed.stdout, - ) - - -def _parse_single_extension_output( - problem: str, - stdout: str, - lines: list[str], - framework: ABAFramework, -) -> ICCMAABAOutput: - if lines == ["NO"]: - return ICCMAABAOutput( - problem=problem, - kind=ICCMAABAOutputKind.SINGLE_EXTENSION, - raw_stdout=stdout, - no_extension=True, - ) - if len(lines) != 1: - raise ICCMAABAOutputParseError("SE output must be one witness line or NO") - witness = _parse_witness_line(lines[0], framework) - return ICCMAABAOutput( - problem=problem, - kind=ICCMAABAOutputKind.SINGLE_EXTENSION, - raw_stdout=stdout, - witness=witness, - extensions=(witness,), - ) - - -def _parse_decision_output( - problem: str, - stdout: str, - lines: list[str], -) -> ICCMAABAOutput: - if len(lines) != 1 or lines[0] not in {"YES", "NO"}: - raise ICCMAABAOutputParseError("ABA decision output must be exactly YES or NO") - return ICCMAABAOutput( - problem=problem, - kind=ICCMAABAOutputKind.DECISION, - raw_stdout=stdout, - answer=lines[0] == "YES", - ) - - -def _parse_witness_line(line: str, framework: ABAFramework) -> frozenset[Literal]: - parts = line.split() - if not parts or parts[0] != "w": - raise ICCMAABAOutputParseError(f"invalid witness line: {line!r}") - witness: set[Literal] = set() - by_id = _literal_by_id(framework) - for atom_id in parts[1:]: - if not atom_id.isdigit() or atom_id not in by_id: - raise ICCMAABAOutputParseError(f"invalid witness atom in line: {line!r}") - literal = by_id[atom_id] - if literal not in framework.assumptions: - raise ICCMAABAOutputParseError("SE witness must contain only assumptions") - witness.add(literal) - return frozenset(witness) - - -def _command( - resolved: list[str], - problem: str, - path: Path, - framework: ABAFramework, - query: Literal | None, -) -> list[str]: - command = [*resolved, "-p", problem, "-f", str(path)] - if query is not None: - command.extend(["-a", _literal_id(framework, query)]) - return command - - -def _problem(prefix: str, semantics: str) -> str: - semantics_code = SEMANTICS_TO_CODE.get(semantics) - if semantics_code is None: - raise ValueError(f"unsupported ICCMA ABA semantics: {semantics}") - return f"{prefix}-{semantics_code}" - - -def _unsupported_problem(binary: str, problem: str) -> ICCMAABASolverUnavailable: - return ICCMAABASolverUnavailable( - backend=binary, - reason=f"unsupported ICCMA 2023 ABA problem: {problem}", - install_hint="Use an ICCMA 2023 ABA subtrack problem.", - ) - - -def _resolve_command(binary: str) -> list[str] | None: - path = Path(binary) - if path.exists(): - return [str(path)] - parts = _split_command(binary) - if not parts: - return None - executable = parts[0] - executable_path = Path(executable) - resolved = str(executable_path) if executable_path.exists() else shutil.which(executable) - if resolved is None: - return None - return [resolved, *parts[1:]] - - -def _split_command(command: str) -> list[str]: - try: - parts = shlex.split(command, posix=os.name != "nt") - except ValueError: - return [] - return [_strip_outer_quotes(part) for part in parts] - - -def _strip_outer_quotes(value: str) -> str: - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - return value[1:-1] - return value - - -def _timeout_stream(value: str | bytes | None) -> str: - if value is None: - return "" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace") - return value - - -def _write_temp_aba(framework: ABAFramework) -> Path: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - suffix=".aba", - delete=False, - ) as handle: - handle.write(write_numeric_aba(framework)) - return Path(handle.name) - - -def _literal_id(framework: ABAFramework, literal: Literal) -> str: - ids = {value: atom_id for atom_id, value in _literal_by_id(framework).items()} - try: - return ids[literal] - except KeyError as exc: - raise ValueError(f"literal is not in framework language: {literal!r}") from exc - - -def _literal_by_id(framework: ABAFramework) -> dict[str, Literal]: - return { - str(index): literal - for index, literal in enumerate(sorted(framework.language, key=repr), start=1) - } - - -def _problem_prefix(problem: str) -> str: - return problem.split("-", maxsplit=1)[0] - - -def _semantic_lines(stdout: str) -> list[str]: - return [ - line.strip() - for line in stdout.splitlines() - if line.strip() and not line.strip().startswith("#") - ] +"""Subprocess adapter for ICCMA-style flat-ABA solvers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile + +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import Literal +from argumentation.interop.iccma import write_numeric_aba +from argumentation.core.solver_results import ( + SolverProcessError, + SolverProtocolError, + SolverUnavailable, +) + + +ACCEPTANCE_TASK_TO_PREFIX = { + "credulous": "DC", + "skeptical": "DS", +} + +SEMANTICS_TO_CODE = { + "complete": "CO", + "preferred": "PR", + "stable": "ST", +} + +SUPPORTED_ABA_PROBLEMS = frozenset( + { + "DC-CO", + "DC-ST", + "DS-PR", + "DS-ST", + "SE-PR", + "SE-ST", + } +) + + +class ICCMAABAOutputKind(Enum): + """Kinds of ICCMA 2023 ABA solver output supported by this adapter.""" + + DECISION = "decision" + SINGLE_EXTENSION = "single_extension" + + +class ICCMAABAOutputParseError(ValueError): + """Raised when ABA solver stdout does not match the selected ICCMA task.""" + + +@dataclass(frozen=True) +class ICCMAABAOutput: + problem: str + kind: ICCMAABAOutputKind + raw_stdout: str + answer: bool | None = None + witness: frozenset[Literal] | None = None + extensions: tuple[frozenset[Literal], ...] = () + no_extension: bool = False + + +@dataclass(frozen=True) +class ICCMAABASolverSuccess: + backend: str + problem: str + output: ICCMAABAOutput + stdout: str + + @property + def answer(self) -> bool | None: + return self.output.answer + + @property + def witness(self) -> frozenset[Literal] | None: + return self.output.witness + + @property + def extensions(self) -> tuple[frozenset[Literal], ...]: + return self.output.extensions + + +ICCMAABASolverUnavailable = SolverUnavailable +ICCMAABASolverError = SolverProcessError +ICCMAABASolverProtocolError = SolverProtocolError + + +ICCMAABASolverResult = ( + ICCMAABASolverSuccess + | ICCMAABASolverUnavailable + | ICCMAABASolverError + | ICCMAABASolverProtocolError +) + + +def parse_iccma_aba_output( + problem: str, + stdout: str, + *, + framework: ABAFramework, + query: Literal | None = None, +) -> ICCMAABAOutput: + """Parse ICCMA 2023 ABA solver stdout for DC, DS, and SE tasks.""" + prefix = _problem_prefix(problem) + lines = _semantic_lines(stdout) + if prefix == "SE": + return _parse_single_extension_output(problem, stdout, lines, framework) + if prefix in {"DC", "DS"}: + if query is None: + raise ICCMAABAOutputParseError(f"{problem} output parsing requires a query") + return _parse_decision_output(problem, stdout, lines) + raise ICCMAABAOutputParseError(f"unsupported ICCMA ABA problem: {problem}") + + +def solve_aba_extensions( + framework: ABAFramework, + *, + semantics: str, + binary: str, + timeout_seconds: float = 30.0, +) -> ICCMAABASolverResult: + """Invoke an ICCMA ABA solver for a single-extension query.""" + problem = _problem("SE", semantics) + if not supports_aba_problem("SE", semantics): + return _unsupported_problem(binary, problem) + return _run_iccma_aba_solver( + framework, + problem=problem, + binary=binary, + timeout_seconds=timeout_seconds, + ) + + +def solve_aba_acceptance( + framework: ABAFramework, + *, + semantics: str, + task: str, + query: Literal, + binary: str, + timeout_seconds: float = 30.0, +) -> ICCMAABASolverResult: + """Invoke an ICCMA ABA solver for a credulous or skeptical query.""" + prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task) + if prefix is None: + raise ValueError(f"unsupported ICCMA ABA acceptance task: {task}") + if query not in framework.language: + raise ValueError(f"query literal is not in framework language: {query!r}") + problem = _problem(prefix, semantics) + if not supports_aba_problem(prefix, semantics): + return _unsupported_problem(binary, problem) + return _run_iccma_aba_solver( + framework, + problem=problem, + binary=binary, + timeout_seconds=timeout_seconds, + query=query, + ) + + +def supports_aba_problem(task: str, semantics: str) -> bool: + prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task, task) + semantics_code = SEMANTICS_TO_CODE.get(semantics) + return semantics_code is not None and f"{prefix}-{semantics_code}" in SUPPORTED_ABA_PROBLEMS + + +def _run_iccma_aba_solver( + framework: ABAFramework, + *, + problem: str, + binary: str, + timeout_seconds: float, + query: Literal | None = None, +) -> ICCMAABASolverResult: + resolved = _resolve_command(binary) + if resolved is None: + return ICCMAABASolverUnavailable( + backend=binary, + reason="command executable not found on PATH", + install_hint=( + "Install an ICCMA-protocol ABA solver and pass its binary path or " + "command line." + ), + ) + + path = _write_temp_aba(framework) + try: + completed = subprocess.run( + _command(resolved, problem, path, framework, query), + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return ICCMAABASolverError( + backend=binary, + problem=problem, + returncode=-1, + stderr=_timeout_stream(exc.stderr), + stdout=_timeout_stream(exc.stdout), + ) + finally: + path.unlink(missing_ok=True) + + if completed.returncode != 0: + return ICCMAABASolverError( + backend=binary, + problem=problem, + returncode=completed.returncode, + stderr=completed.stderr, + stdout=completed.stdout, + ) + + try: + output = parse_iccma_aba_output( + problem, + completed.stdout, + framework=framework, + query=query, + ) + except ICCMAABAOutputParseError as exc: + return ICCMAABASolverProtocolError( + backend=binary, + problem=problem, + message=str(exc), + stderr=completed.stderr, + stdout=completed.stdout, + ) + + return ICCMAABASolverSuccess( + backend=binary, + problem=problem, + output=output, + stdout=completed.stdout, + ) + + +def _parse_single_extension_output( + problem: str, + stdout: str, + lines: list[str], + framework: ABAFramework, +) -> ICCMAABAOutput: + if lines == ["NO"]: + return ICCMAABAOutput( + problem=problem, + kind=ICCMAABAOutputKind.SINGLE_EXTENSION, + raw_stdout=stdout, + no_extension=True, + ) + if len(lines) != 1: + raise ICCMAABAOutputParseError("SE output must be one witness line or NO") + witness = _parse_witness_line(lines[0], framework) + return ICCMAABAOutput( + problem=problem, + kind=ICCMAABAOutputKind.SINGLE_EXTENSION, + raw_stdout=stdout, + witness=witness, + extensions=(witness,), + ) + + +def _parse_decision_output( + problem: str, + stdout: str, + lines: list[str], +) -> ICCMAABAOutput: + if len(lines) != 1 or lines[0] not in {"YES", "NO"}: + raise ICCMAABAOutputParseError("ABA decision output must be exactly YES or NO") + return ICCMAABAOutput( + problem=problem, + kind=ICCMAABAOutputKind.DECISION, + raw_stdout=stdout, + answer=lines[0] == "YES", + ) + + +def _parse_witness_line(line: str, framework: ABAFramework) -> frozenset[Literal]: + parts = line.split() + if not parts or parts[0] != "w": + raise ICCMAABAOutputParseError(f"invalid witness line: {line!r}") + witness: set[Literal] = set() + by_id = _literal_by_id(framework) + for atom_id in parts[1:]: + if not atom_id.isdigit() or atom_id not in by_id: + raise ICCMAABAOutputParseError(f"invalid witness atom in line: {line!r}") + literal = by_id[atom_id] + if literal not in framework.assumptions: + raise ICCMAABAOutputParseError("SE witness must contain only assumptions") + witness.add(literal) + return frozenset(witness) + + +def _command( + resolved: list[str], + problem: str, + path: Path, + framework: ABAFramework, + query: Literal | None, +) -> list[str]: + command = [*resolved, "-p", problem, "-f", str(path)] + if query is not None: + command.extend(["-a", _literal_id(framework, query)]) + return command + + +def _problem(prefix: str, semantics: str) -> str: + semantics_code = SEMANTICS_TO_CODE.get(semantics) + if semantics_code is None: + raise ValueError(f"unsupported ICCMA ABA semantics: {semantics}") + return f"{prefix}-{semantics_code}" + + +def _unsupported_problem(binary: str, problem: str) -> ICCMAABASolverUnavailable: + return ICCMAABASolverUnavailable( + backend=binary, + reason=f"unsupported ICCMA 2023 ABA problem: {problem}", + install_hint="Use an ICCMA 2023 ABA subtrack problem.", + ) + + +def _resolve_command(binary: str) -> list[str] | None: + path = Path(binary) + if path.exists(): + return [str(path)] + parts = _split_command(binary) + if not parts: + return None + executable = parts[0] + executable_path = Path(executable) + resolved = str(executable_path) if executable_path.exists() else shutil.which(executable) + if resolved is None: + return None + return [resolved, *parts[1:]] + + +def _split_command(command: str) -> list[str]: + try: + parts = shlex.split(command, posix=os.name != "nt") + except ValueError: + return [] + return [_strip_outer_quotes(part) for part in parts] + + +def _strip_outer_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _timeout_stream(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def _write_temp_aba(framework: ABAFramework) -> Path: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + suffix=".aba", + delete=False, + ) as handle: + handle.write(write_numeric_aba(framework)) + return Path(handle.name) + + +def _literal_id(framework: ABAFramework, literal: Literal) -> str: + ids = {value: atom_id for atom_id, value in _literal_by_id(framework).items()} + try: + return ids[literal] + except KeyError as exc: + raise ValueError(f"literal is not in framework language: {literal!r}") from exc + + +def _literal_by_id(framework: ABAFramework) -> dict[str, Literal]: + return { + str(index): literal + for index, literal in enumerate(sorted(framework.language, key=repr), start=1) + } + + +def _problem_prefix(problem: str) -> str: + return problem.split("-", maxsplit=1)[0] + + +def _semantic_lines(stdout: str) -> list[str]: + return [ + line.strip() + for line in stdout.splitlines() + if line.strip() and not line.strip().startswith("#") + ] diff --git a/src/argumentation/solver_adapters/iccma_af.py b/src/argumentation/solver_adapters/iccma_af.py index ebf61a6..db7e1bf 100644 --- a/src/argumentation/solver_adapters/iccma_af.py +++ b/src/argumentation/solver_adapters/iccma_af.py @@ -1,585 +1,585 @@ -"""Subprocess adapter for ICCMA-style abstract-AF solvers.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -import os -from pathlib import Path -import shlex -import shutil -import subprocess -import tempfile - -from argumentation.dung import ( - ArgumentationFramework, - admissible, - characteristic_fn, - conflict_free, - grounded_extension, - range_of, -) -from argumentation.iccma import write_af -from argumentation.solver_results import ( - SolverProcessError, - SolverProtocolError, - SolverUnavailable, -) - - -SEMANTICS_TO_PROBLEM = { - "complete": "SE-CO", - "grounded": "SE-GR", - "preferred": "SE-PR", - "stable": "SE-ST", - "semi-stable": "SE-SST", - "stage": "SE-STG", - "ideal": "SE-ID", -} - -ACCEPTANCE_TASK_TO_PREFIX = { - "credulous": "DC", - "skeptical": "DS", -} - -SEMANTICS_TO_CODE = { - "complete": "CO", - "grounded": "GR", - "preferred": "PR", - "stable": "ST", - "semi-stable": "SST", - "stage": "STG", - "ideal": "ID", -} - -SUPPORTED_AF_PROBLEMS = frozenset( - { - "DC-CO", - "DC-ST", - "DC-SST", - "DC-STG", - "DS-PR", - "DS-ST", - "DS-SST", - "DS-STG", - "SE-PR", - "SE-ST", - "SE-SST", - "SE-STG", - "SE-ID", - } -) - - -class ICCMAOutputKind(Enum): - """Kinds of ICCMA 2023 AF solver output supported by this adapter.""" - - DECISION = "decision" - SINGLE_EXTENSION = "single_extension" - - -class ICCMAOutputParseError(ValueError): - """Raised when solver stdout does not match the selected ICCMA task.""" - - -@dataclass(frozen=True) -class ICCMAOutput: - problem: str - kind: ICCMAOutputKind - raw_stdout: str - answer: bool | None = None - witness: frozenset[str] | None = None - extensions: tuple[frozenset[str], ...] = () - no_extension: bool = False - - -@dataclass(frozen=True) -class ICCMASolverSuccess: - backend: str - problem: str - output: ICCMAOutput - stdout: str - - @property - def answer(self) -> bool | None: - return self.output.answer - - @property - def witness(self) -> frozenset[str] | None: - return self.output.witness - - @property - def extensions(self) -> tuple[frozenset[str], ...]: - return self.output.extensions - - -ICCMASolverUnavailable = SolverUnavailable -ICCMASolverError = SolverProcessError -ICCMASolverProtocolError = SolverProtocolError - - -ICCMASolverResult = ( - ICCMASolverSuccess - | ICCMASolverUnavailable - | ICCMASolverError - | ICCMASolverProtocolError -) - - -def parse_extension_witnesses(output: str) -> tuple[frozenset[str], ...]: - """Parse ICCMA witness lines such as ``w 1 3``.""" - extensions: list[frozenset[str]] = [] - for raw_line in output.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line == "[]": - extensions.append(frozenset()) - continue - if line.startswith("w"): - parts = line.split() - extensions.append(frozenset(parts[1:])) - return tuple(extensions) - - -def parse_iccma_output( - problem: str, - stdout: str, - *, - query: str | None = None, - certificate_required: bool = True, -) -> ICCMAOutput: - """Parse ICCMA 2023 AF solver stdout for DC, DS, and SE tasks.""" - prefix = _problem_prefix(problem) - lines = _semantic_lines(stdout) - if prefix == "SE": - return _parse_single_extension_output(problem, stdout, lines) - if prefix in {"DC", "DS"}: - if query is None: - raise ICCMAOutputParseError(f"{problem} output parsing requires a query") - return _parse_decision_output( - problem, - stdout, - lines, - query=query, - certificate_required=certificate_required, - ) - raise ICCMAOutputParseError(f"unsupported ICCMA AF problem: {problem}") - - -def solve_af_extensions( - framework: ArgumentationFramework, - *, - semantics: str, - binary: str, - timeout_seconds: float = 30.0, -) -> ICCMASolverResult: - """Invoke an ICCMA AF solver for a single-extension query.""" - problem = SEMANTICS_TO_PROBLEM.get(semantics) - if problem is None: - raise ValueError(f"unsupported ICCMA AF semantics: {semantics}") - if not supports_af_problem("SE", semantics): - return _unsupported_problem(binary, problem) - - return _run_iccma_af_solver( - framework, - problem=problem, - binary=binary, - timeout_seconds=timeout_seconds, - ) - - -def solve_af_acceptance( - framework: ArgumentationFramework, - *, - semantics: str, - task: str, - query: str, - binary: str, - timeout_seconds: float = 30.0, - certificate_required: bool = True, -) -> ICCMASolverResult: - """Invoke an ICCMA AF solver for a credulous or skeptical query.""" - prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task) - if prefix is None: - raise ValueError(f"unsupported ICCMA AF acceptance task: {task}") - semantics_code = SEMANTICS_TO_CODE.get(semantics) - if semantics_code is None: - raise ValueError(f"unsupported ICCMA AF semantics: {semantics}") - if query not in framework.arguments: - raise ValueError(f"query argument is not in framework: {query!r}") - problem = f"{prefix}-{semantics_code}" - if not supports_af_problem(prefix, semantics): - return _unsupported_problem(binary, problem) - - return _run_iccma_af_solver( - framework, - problem=problem, - binary=binary, - timeout_seconds=timeout_seconds, - query=query, - certificate_required=certificate_required, - ) - - -def supports_af_problem(task: str, semantics: str) -> bool: - prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task, task) - semantics_code = SEMANTICS_TO_CODE.get(semantics) - return semantics_code is not None and f"{prefix}-{semantics_code}" in SUPPORTED_AF_PROBLEMS - - -def _unsupported_problem(binary: str, problem: str) -> ICCMASolverUnavailable: - return ICCMASolverUnavailable( - backend=binary, - reason=f"unsupported ICCMA 2023 AF problem: {problem}", - install_hint="Use an ICCMA 2023 Main/No-Limits AF subtrack problem.", - ) - - -def _run_iccma_af_solver( - framework: ArgumentationFramework, - *, - problem: str, - binary: str, - timeout_seconds: float, - query: str | None = None, - certificate_required: bool = True, -) -> ICCMASolverResult: - resolved = _resolve_command(binary) - if resolved is None: - return ICCMASolverUnavailable( - backend=binary, - reason="command executable not found on PATH", - install_hint=( - "Install an ICCMA-protocol AF solver and pass its binary path or " - "command line." - ), - ) - - path = _write_temp_af(framework) - try: - completed = subprocess.run( - _command(resolved, problem, path, query), - capture_output=True, - text=True, - timeout=timeout_seconds, - check=False, - ) - finally: - path.unlink(missing_ok=True) - - if completed.returncode != 0: - return ICCMASolverError( - backend=binary, - problem=problem, - returncode=completed.returncode, - stderr=completed.stderr, - stdout=completed.stdout, - ) - - try: - output = parse_iccma_output( - problem, - completed.stdout, - query=query, - certificate_required=certificate_required, - ) - _validate_iccma_certificate(framework, problem, output) - except ICCMAOutputParseError as exc: - return ICCMASolverProtocolError( - backend=binary, - problem=problem, - message=str(exc), - stderr=completed.stderr, - stdout=completed.stdout, - ) - - return ICCMASolverSuccess( - backend=binary, - problem=problem, - output=output, - stdout=completed.stdout, - ) - - -def _validate_iccma_certificate( - framework: ArgumentationFramework, - problem: str, - output: ICCMAOutput, -) -> None: - prefix = _problem_prefix(problem) - if prefix == "SE": - if output.no_extension: - return - _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) - return - - if prefix == "DC": - if output.answer is True: - _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) - return - if prefix == "DS": - if output.answer is False: - _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) - return - raise ICCMAOutputParseError(f"unsupported ICCMA AF problem: {problem}") - - -def _problem_semantics(problem: str) -> str: - code = problem.split("-", maxsplit=1)[1] - for semantics, semantics_code in SEMANTICS_TO_CODE.items(): - if semantics_code == code: - return semantics - raise ICCMAOutputParseError(f"unsupported ICCMA AF semantics code: {code}") - - -def _validate_witness_certificate( - framework: ArgumentationFramework, - semantics: str, - witness: frozenset[str] | None, -) -> None: - witness = _required_witness(witness) - unknown = sorted(witness - framework.arguments) - if unknown: - raise ICCMAOutputParseError(f"witness references unknown arguments: {unknown!r}") - - cf_relation = framework.attacks if framework.attacks is not None else framework.defeats - if not conflict_free(witness, cf_relation): - raise ICCMAOutputParseError("witness is not conflict-free") - - if semantics == "stable": - if range_of(witness, framework.defeats) != framework.arguments: - raise ICCMAOutputParseError("stable witness does not attack every outsider") - return - if semantics == "complete": - if not _is_complete_certificate(framework, witness): - raise ICCMAOutputParseError("complete witness is not a complete extension") - return - if semantics == "grounded": - if witness != grounded_extension(framework): - raise ICCMAOutputParseError("grounded witness is not the grounded extension") - return - if semantics in {"preferred", "ideal"}: - if not admissible( - witness, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - ): - raise ICCMAOutputParseError(f"{semantics} witness is not admissible") - return - if semantics == "semi-stable": - if not _is_complete_certificate(framework, witness): - raise ICCMAOutputParseError("semi-stable witness is not a complete extension") - return - if semantics == "stage": - return - raise ICCMAOutputParseError(f"unsupported ICCMA AF semantics: {semantics}") - - -def _required_witness(witness: frozenset[str] | None) -> frozenset[str]: - if witness is None: - raise ICCMAOutputParseError("certificate output is missing a witness") - return witness - - -def _is_complete_certificate( - framework: ArgumentationFramework, - witness: frozenset[str], -) -> bool: - return admissible( - witness, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - ) and characteristic_fn( - witness, - framework.arguments, - framework.defeats, - ) == witness - - -def _command( - resolved: list[str], - problem: str, - path: Path, - query: str | None, -) -> list[str]: - command = [*resolved, "-p", problem, "-f", str(path)] - if query is not None: - command.extend(["-a", query]) - return command - - -def _resolve_command(binary: str) -> list[str] | None: - path = Path(binary) - if path.exists(): - return [str(path)] - parts = _split_command(binary) - if not parts: - return None - executable = parts[0] - executable_path = Path(executable) - resolved = str(executable_path) if executable_path.exists() else shutil.which(executable) - if resolved is None: - return None - return [resolved, *parts[1:]] - - -def _split_command(command: str) -> list[str]: - try: - parts = shlex.split(command, posix=os.name != "nt") - except ValueError: - return [] - return [_strip_outer_quotes(part) for part in parts] - - -def _strip_outer_quotes(value: str) -> str: - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - return value[1:-1] - return value - - -def _write_temp_af(framework: ArgumentationFramework) -> Path: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - suffix=".af", - delete=False, - ) as handle: - handle.write(write_af(framework)) - return Path(handle.name) - - -def _problem_prefix(problem: str) -> str: - return problem.split("-", maxsplit=1)[0] - - -def _semantic_lines(stdout: str) -> list[str]: - return [ - line.strip() - for line in stdout.splitlines() - if line.strip() and not line.strip().startswith("#") - ] - - -def _parse_single_extension_output( - problem: str, - stdout: str, - lines: list[str], -) -> ICCMAOutput: - if lines == ["NO"]: - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.SINGLE_EXTENSION, - raw_stdout=stdout, - no_extension=True, - ) - if len(lines) != 1: - raise ICCMAOutputParseError("SE output must be one witness line or NO") - witness = _parse_witness_line(lines[0]) - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.SINGLE_EXTENSION, - raw_stdout=stdout, - witness=witness, - extensions=(witness,), - ) - - -def _parse_decision_output( - problem: str, - stdout: str, - lines: list[str], - *, - query: str, - certificate_required: bool, -) -> ICCMAOutput: - if not lines or lines[0] not in {"YES", "NO"}: - raise ICCMAOutputParseError("decision output must start with YES or NO") - if not certificate_required: - if len(lines) != 1: - raise ICCMAOutputParseError("non-certificate decision output must be YES or NO") - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.DECISION, - raw_stdout=stdout, - answer=lines[0] == "YES", - ) - - prefix = _problem_prefix(problem) - if prefix == "DC": - return _parse_credulous_decision(problem, stdout, lines, query) - if prefix == "DS": - return _parse_skeptical_decision(problem, stdout, lines, query) - raise ICCMAOutputParseError(f"unsupported decision problem: {problem}") - - -def _parse_credulous_decision( - problem: str, - stdout: str, - lines: list[str], - query: str, -) -> ICCMAOutput: - if lines[0] == "NO": - if len(lines) != 1: - raise ICCMAOutputParseError("DC NO output must not include a witness") - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.DECISION, - raw_stdout=stdout, - answer=False, - ) - if len(lines) != 2: - raise ICCMAOutputParseError("DC YES output must include one witness") - witness = _parse_witness_line(lines[1]) - if query not in witness: - raise ICCMAOutputParseError("DC YES witness must contain query") - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.DECISION, - raw_stdout=stdout, - answer=True, - witness=witness, - extensions=(witness,), - ) - - -def _parse_skeptical_decision( - problem: str, - stdout: str, - lines: list[str], - query: str, -) -> ICCMAOutput: - if lines[0] == "YES": - if len(lines) != 1: - raise ICCMAOutputParseError("DS YES output must not include a witness") - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.DECISION, - raw_stdout=stdout, - answer=True, - ) - if len(lines) != 2: - raise ICCMAOutputParseError("DS NO output must include one counterexample") - witness = _parse_witness_line(lines[1]) - if query in witness: - raise ICCMAOutputParseError("DS NO counterexample must omit query") - return ICCMAOutput( - problem=problem, - kind=ICCMAOutputKind.DECISION, - raw_stdout=stdout, - answer=False, - witness=witness, - extensions=(witness,), - ) - - -def _parse_witness_line(line: str) -> frozenset[str]: - parts = line.split() - if not parts or parts[0] != "w": - raise ICCMAOutputParseError(f"invalid witness line: {line!r}") - witness = parts[1:] - if not all(argument.isdigit() for argument in witness): - raise ICCMAOutputParseError(f"invalid witness argument in line: {line!r}") - return frozenset(witness) +"""Subprocess adapter for ICCMA-style abstract-AF solvers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile + +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + characteristic_fn, + conflict_free, + grounded_extension, + range_of, +) +from argumentation.interop.iccma import write_af +from argumentation.core.solver_results import ( + SolverProcessError, + SolverProtocolError, + SolverUnavailable, +) + + +SEMANTICS_TO_PROBLEM = { + "complete": "SE-CO", + "grounded": "SE-GR", + "preferred": "SE-PR", + "stable": "SE-ST", + "semi-stable": "SE-SST", + "stage": "SE-STG", + "ideal": "SE-ID", +} + +ACCEPTANCE_TASK_TO_PREFIX = { + "credulous": "DC", + "skeptical": "DS", +} + +SEMANTICS_TO_CODE = { + "complete": "CO", + "grounded": "GR", + "preferred": "PR", + "stable": "ST", + "semi-stable": "SST", + "stage": "STG", + "ideal": "ID", +} + +SUPPORTED_AF_PROBLEMS = frozenset( + { + "DC-CO", + "DC-ST", + "DC-SST", + "DC-STG", + "DS-PR", + "DS-ST", + "DS-SST", + "DS-STG", + "SE-PR", + "SE-ST", + "SE-SST", + "SE-STG", + "SE-ID", + } +) + + +class ICCMAOutputKind(Enum): + """Kinds of ICCMA 2023 AF solver output supported by this adapter.""" + + DECISION = "decision" + SINGLE_EXTENSION = "single_extension" + + +class ICCMAOutputParseError(ValueError): + """Raised when solver stdout does not match the selected ICCMA task.""" + + +@dataclass(frozen=True) +class ICCMAOutput: + problem: str + kind: ICCMAOutputKind + raw_stdout: str + answer: bool | None = None + witness: frozenset[str] | None = None + extensions: tuple[frozenset[str], ...] = () + no_extension: bool = False + + +@dataclass(frozen=True) +class ICCMASolverSuccess: + backend: str + problem: str + output: ICCMAOutput + stdout: str + + @property + def answer(self) -> bool | None: + return self.output.answer + + @property + def witness(self) -> frozenset[str] | None: + return self.output.witness + + @property + def extensions(self) -> tuple[frozenset[str], ...]: + return self.output.extensions + + +ICCMASolverUnavailable = SolverUnavailable +ICCMASolverError = SolverProcessError +ICCMASolverProtocolError = SolverProtocolError + + +ICCMASolverResult = ( + ICCMASolverSuccess + | ICCMASolverUnavailable + | ICCMASolverError + | ICCMASolverProtocolError +) + + +def parse_extension_witnesses(output: str) -> tuple[frozenset[str], ...]: + """Parse ICCMA witness lines such as ``w 1 3``.""" + extensions: list[frozenset[str]] = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line == "[]": + extensions.append(frozenset()) + continue + if line.startswith("w"): + parts = line.split() + extensions.append(frozenset(parts[1:])) + return tuple(extensions) + + +def parse_iccma_output( + problem: str, + stdout: str, + *, + query: str | None = None, + certificate_required: bool = True, +) -> ICCMAOutput: + """Parse ICCMA 2023 AF solver stdout for DC, DS, and SE tasks.""" + prefix = _problem_prefix(problem) + lines = _semantic_lines(stdout) + if prefix == "SE": + return _parse_single_extension_output(problem, stdout, lines) + if prefix in {"DC", "DS"}: + if query is None: + raise ICCMAOutputParseError(f"{problem} output parsing requires a query") + return _parse_decision_output( + problem, + stdout, + lines, + query=query, + certificate_required=certificate_required, + ) + raise ICCMAOutputParseError(f"unsupported ICCMA AF problem: {problem}") + + +def solve_af_extensions( + framework: ArgumentationFramework, + *, + semantics: str, + binary: str, + timeout_seconds: float = 30.0, +) -> ICCMASolverResult: + """Invoke an ICCMA AF solver for a single-extension query.""" + problem = SEMANTICS_TO_PROBLEM.get(semantics) + if problem is None: + raise ValueError(f"unsupported ICCMA AF semantics: {semantics}") + if not supports_af_problem("SE", semantics): + return _unsupported_problem(binary, problem) + + return _run_iccma_af_solver( + framework, + problem=problem, + binary=binary, + timeout_seconds=timeout_seconds, + ) + + +def solve_af_acceptance( + framework: ArgumentationFramework, + *, + semantics: str, + task: str, + query: str, + binary: str, + timeout_seconds: float = 30.0, + certificate_required: bool = True, +) -> ICCMASolverResult: + """Invoke an ICCMA AF solver for a credulous or skeptical query.""" + prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task) + if prefix is None: + raise ValueError(f"unsupported ICCMA AF acceptance task: {task}") + semantics_code = SEMANTICS_TO_CODE.get(semantics) + if semantics_code is None: + raise ValueError(f"unsupported ICCMA AF semantics: {semantics}") + if query not in framework.arguments: + raise ValueError(f"query argument is not in framework: {query!r}") + problem = f"{prefix}-{semantics_code}" + if not supports_af_problem(prefix, semantics): + return _unsupported_problem(binary, problem) + + return _run_iccma_af_solver( + framework, + problem=problem, + binary=binary, + timeout_seconds=timeout_seconds, + query=query, + certificate_required=certificate_required, + ) + + +def supports_af_problem(task: str, semantics: str) -> bool: + prefix = ACCEPTANCE_TASK_TO_PREFIX.get(task, task) + semantics_code = SEMANTICS_TO_CODE.get(semantics) + return semantics_code is not None and f"{prefix}-{semantics_code}" in SUPPORTED_AF_PROBLEMS + + +def _unsupported_problem(binary: str, problem: str) -> ICCMASolverUnavailable: + return ICCMASolverUnavailable( + backend=binary, + reason=f"unsupported ICCMA 2023 AF problem: {problem}", + install_hint="Use an ICCMA 2023 Main/No-Limits AF subtrack problem.", + ) + + +def _run_iccma_af_solver( + framework: ArgumentationFramework, + *, + problem: str, + binary: str, + timeout_seconds: float, + query: str | None = None, + certificate_required: bool = True, +) -> ICCMASolverResult: + resolved = _resolve_command(binary) + if resolved is None: + return ICCMASolverUnavailable( + backend=binary, + reason="command executable not found on PATH", + install_hint=( + "Install an ICCMA-protocol AF solver and pass its binary path or " + "command line." + ), + ) + + path = _write_temp_af(framework) + try: + completed = subprocess.run( + _command(resolved, problem, path, query), + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + finally: + path.unlink(missing_ok=True) + + if completed.returncode != 0: + return ICCMASolverError( + backend=binary, + problem=problem, + returncode=completed.returncode, + stderr=completed.stderr, + stdout=completed.stdout, + ) + + try: + output = parse_iccma_output( + problem, + completed.stdout, + query=query, + certificate_required=certificate_required, + ) + _validate_iccma_certificate(framework, problem, output) + except ICCMAOutputParseError as exc: + return ICCMASolverProtocolError( + backend=binary, + problem=problem, + message=str(exc), + stderr=completed.stderr, + stdout=completed.stdout, + ) + + return ICCMASolverSuccess( + backend=binary, + problem=problem, + output=output, + stdout=completed.stdout, + ) + + +def _validate_iccma_certificate( + framework: ArgumentationFramework, + problem: str, + output: ICCMAOutput, +) -> None: + prefix = _problem_prefix(problem) + if prefix == "SE": + if output.no_extension: + return + _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) + return + + if prefix == "DC": + if output.answer is True: + _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) + return + if prefix == "DS": + if output.answer is False: + _validate_witness_certificate(framework, _problem_semantics(problem), output.witness) + return + raise ICCMAOutputParseError(f"unsupported ICCMA AF problem: {problem}") + + +def _problem_semantics(problem: str) -> str: + code = problem.split("-", maxsplit=1)[1] + for semantics, semantics_code in SEMANTICS_TO_CODE.items(): + if semantics_code == code: + return semantics + raise ICCMAOutputParseError(f"unsupported ICCMA AF semantics code: {code}") + + +def _validate_witness_certificate( + framework: ArgumentationFramework, + semantics: str, + witness: frozenset[str] | None, +) -> None: + witness = _required_witness(witness) + unknown = sorted(witness - framework.arguments) + if unknown: + raise ICCMAOutputParseError(f"witness references unknown arguments: {unknown!r}") + + cf_relation = framework.attacks if framework.attacks is not None else framework.defeats + if not conflict_free(witness, cf_relation): + raise ICCMAOutputParseError("witness is not conflict-free") + + if semantics == "stable": + if range_of(witness, framework.defeats) != framework.arguments: + raise ICCMAOutputParseError("stable witness does not attack every outsider") + return + if semantics == "complete": + if not _is_complete_certificate(framework, witness): + raise ICCMAOutputParseError("complete witness is not a complete extension") + return + if semantics == "grounded": + if witness != grounded_extension(framework): + raise ICCMAOutputParseError("grounded witness is not the grounded extension") + return + if semantics in {"preferred", "ideal"}: + if not admissible( + witness, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + ): + raise ICCMAOutputParseError(f"{semantics} witness is not admissible") + return + if semantics == "semi-stable": + if not _is_complete_certificate(framework, witness): + raise ICCMAOutputParseError("semi-stable witness is not a complete extension") + return + if semantics == "stage": + return + raise ICCMAOutputParseError(f"unsupported ICCMA AF semantics: {semantics}") + + +def _required_witness(witness: frozenset[str] | None) -> frozenset[str]: + if witness is None: + raise ICCMAOutputParseError("certificate output is missing a witness") + return witness + + +def _is_complete_certificate( + framework: ArgumentationFramework, + witness: frozenset[str], +) -> bool: + return admissible( + witness, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + ) and characteristic_fn( + witness, + framework.arguments, + framework.defeats, + ) == witness + + +def _command( + resolved: list[str], + problem: str, + path: Path, + query: str | None, +) -> list[str]: + command = [*resolved, "-p", problem, "-f", str(path)] + if query is not None: + command.extend(["-a", query]) + return command + + +def _resolve_command(binary: str) -> list[str] | None: + path = Path(binary) + if path.exists(): + return [str(path)] + parts = _split_command(binary) + if not parts: + return None + executable = parts[0] + executable_path = Path(executable) + resolved = str(executable_path) if executable_path.exists() else shutil.which(executable) + if resolved is None: + return None + return [resolved, *parts[1:]] + + +def _split_command(command: str) -> list[str]: + try: + parts = shlex.split(command, posix=os.name != "nt") + except ValueError: + return [] + return [_strip_outer_quotes(part) for part in parts] + + +def _strip_outer_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _write_temp_af(framework: ArgumentationFramework) -> Path: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + suffix=".af", + delete=False, + ) as handle: + handle.write(write_af(framework)) + return Path(handle.name) + + +def _problem_prefix(problem: str) -> str: + return problem.split("-", maxsplit=1)[0] + + +def _semantic_lines(stdout: str) -> list[str]: + return [ + line.strip() + for line in stdout.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def _parse_single_extension_output( + problem: str, + stdout: str, + lines: list[str], +) -> ICCMAOutput: + if lines == ["NO"]: + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.SINGLE_EXTENSION, + raw_stdout=stdout, + no_extension=True, + ) + if len(lines) != 1: + raise ICCMAOutputParseError("SE output must be one witness line or NO") + witness = _parse_witness_line(lines[0]) + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.SINGLE_EXTENSION, + raw_stdout=stdout, + witness=witness, + extensions=(witness,), + ) + + +def _parse_decision_output( + problem: str, + stdout: str, + lines: list[str], + *, + query: str, + certificate_required: bool, +) -> ICCMAOutput: + if not lines or lines[0] not in {"YES", "NO"}: + raise ICCMAOutputParseError("decision output must start with YES or NO") + if not certificate_required: + if len(lines) != 1: + raise ICCMAOutputParseError("non-certificate decision output must be YES or NO") + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.DECISION, + raw_stdout=stdout, + answer=lines[0] == "YES", + ) + + prefix = _problem_prefix(problem) + if prefix == "DC": + return _parse_credulous_decision(problem, stdout, lines, query) + if prefix == "DS": + return _parse_skeptical_decision(problem, stdout, lines, query) + raise ICCMAOutputParseError(f"unsupported decision problem: {problem}") + + +def _parse_credulous_decision( + problem: str, + stdout: str, + lines: list[str], + query: str, +) -> ICCMAOutput: + if lines[0] == "NO": + if len(lines) != 1: + raise ICCMAOutputParseError("DC NO output must not include a witness") + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.DECISION, + raw_stdout=stdout, + answer=False, + ) + if len(lines) != 2: + raise ICCMAOutputParseError("DC YES output must include one witness") + witness = _parse_witness_line(lines[1]) + if query not in witness: + raise ICCMAOutputParseError("DC YES witness must contain query") + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.DECISION, + raw_stdout=stdout, + answer=True, + witness=witness, + extensions=(witness,), + ) + + +def _parse_skeptical_decision( + problem: str, + stdout: str, + lines: list[str], + query: str, +) -> ICCMAOutput: + if lines[0] == "YES": + if len(lines) != 1: + raise ICCMAOutputParseError("DS YES output must not include a witness") + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.DECISION, + raw_stdout=stdout, + answer=True, + ) + if len(lines) != 2: + raise ICCMAOutputParseError("DS NO output must include one counterexample") + witness = _parse_witness_line(lines[1]) + if query in witness: + raise ICCMAOutputParseError("DS NO counterexample must omit query") + return ICCMAOutput( + problem=problem, + kind=ICCMAOutputKind.DECISION, + raw_stdout=stdout, + answer=False, + witness=witness, + extensions=(witness,), + ) + + +def _parse_witness_line(line: str) -> frozenset[str]: + parts = line.split() + if not parts or parts[0] != "w": + raise ICCMAOutputParseError(f"invalid witness line: {line!r}") + witness = parts[1:] + if not all(argument.isdigit() for argument in witness): + raise ICCMAOutputParseError(f"invalid witness argument in line: {line!r}") + return frozenset(witness) diff --git a/src/argumentation/solving/__init__.py b/src/argumentation/solving/__init__.py new file mode 100644 index 0000000..838f7a3 --- /dev/null +++ b/src/argumentation/solving/__init__.py @@ -0,0 +1 @@ +"""Solving layer: SAT encodings, backends, and solver orchestration.""" diff --git a/src/argumentation/af_sat.py b/src/argumentation/solving/af_sat.py similarity index 99% rename from src/argumentation/af_sat.py rename to src/argumentation/solving/af_sat.py index 6e6212f..24d2945 100644 --- a/src/argumentation/af_sat.py +++ b/src/argumentation/solving/af_sat.py @@ -9,13 +9,13 @@ from time import perf_counter from typing import Any -from argumentation.dung import ( +from argumentation.core.dung import ( ArgumentationFramework, _attackers_index, grounded_extension, range_of, ) -from argumentation.preprocessing import AfSimplification, simplify_af +from argumentation.core.preprocessing import AfSimplification, simplify_af @dataclass(frozen=True) diff --git a/src/argumentation/backends.py b/src/argumentation/solving/backends.py similarity index 100% rename from src/argumentation/backends.py rename to src/argumentation/solving/backends.py diff --git a/src/argumentation/iccma_cli.py b/src/argumentation/solving/iccma_cli.py similarity index 93% rename from src/argumentation/iccma_cli.py rename to src/argumentation/solving/iccma_cli.py index f2cc0fe..cae9027 100644 --- a/src/argumentation/iccma_cli.py +++ b/src/argumentation/solving/iccma_cli.py @@ -1,327 +1,327 @@ -"""Command-line entry point for ICCMA-style AF and ABA solving.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -import sys -from typing import Sequence, TextIO - -from argumentation.aba import ABAFramework -from argumentation.aspic import Literal -from argumentation.iccma import parse_aba, parse_af -from argumentation.labelling import ExactEnumerationExceeded -from argumentation.solver import ( - AcceptanceSolverSuccess, - SingleExtensionSolverSuccess, - solve_aba_acceptance, - solve_aba_single_extension, - solve_dung_acceptance, - solve_dung_single_extension, -) - - -PROBLEM_SEMANTICS = { - "CO": "complete", - "GR": "grounded", - "PR": "preferred", - "ST": "stable", - "SST": "semi-stable", - "STG": "stage", - "ID": "ideal", - "CF2": "cf2", -} - -TASKS = {"DC", "DS", "SE"} - - -def main(argv: Sequence[str] | None = None) -> int: - parser = _parser() - args = parser.parse_args(argv) - - try: - task, semantics = _parse_problem(args.problem) - text = args.file.read_text(encoding="utf-8") - kind = _instance_kind(text) - if kind == "aba": - return _solve_aba_cli( - text=text, - task=task, - semantics=semantics, - query_argument=args.argument, - backend=args.backend, - stdout=sys.stdout, - stderr=sys.stderr, - ) - framework = parse_af(text) - if task == "SE": - return _solve_af_single_extension( - framework=framework, - semantics=semantics, - backend=_af_backend(args.backend, semantics), - stdout=sys.stdout, - stderr=sys.stderr, - ) - if args.argument is None: - print(f"{task} tasks require -a/--argument", file=sys.stderr) - return 2 - return _solve_af_acceptance( - framework=framework, - semantics=semantics, - task="credulous" if task == "DC" else "skeptical", - query=args.argument, - backend=_af_backend(args.backend, semantics), - stdout=sys.stdout, - stderr=sys.stderr, - ) - except (OSError, ValueError, ExactEnumerationExceeded) as exc: - print(str(exc), file=sys.stderr) - return 2 - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="argumentation.iccma_cli", - description="Solve ICCMA-style abstract argumentation tasks.", - ) - parser.add_argument("-p", "--problem", required=True, help="ICCMA problem, e.g. SE-ST") - parser.add_argument( - "-f", - "--file", - required=True, - type=Path, - help="Path to an ICCMA p af or p aba input file", - ) - parser.add_argument("-a", "--argument", help="Query argument for DC/DS tasks") - parser.add_argument( - "--backend", - choices=("auto", "native", "sat"), - default="auto", - help="In-package backend to use", - ) - return parser - - -def _parse_problem(problem: str) -> tuple[str, str]: - parts = problem.upper().split("-", maxsplit=1) - if len(parts) != 2: - raise ValueError(f"ICCMA problem must look like TASK-SEMANTICS: {problem!r}") - task, semantics_code = parts - if task not in TASKS: - raise ValueError(f"unsupported ICCMA AF task: {task}") - semantics = PROBLEM_SEMANTICS.get(semantics_code) - if semantics is None: - raise ValueError(f"unsupported ICCMA AF semantics code: {semantics_code}") - return task, semantics - - -def _instance_kind(text: str) -> str: - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if parts[:2] == ["p", "af"]: - return "af" - if parts[:2] == ["p", "aba"]: - return "aba" - break - raise ValueError("ICCMA input must start with a p af or p aba header") - - -def _af_backend(requested: str, semantics: str) -> str: - if requested == "auto": - return "sat" if semantics == "stable" else "native" - return requested - - -def _solve_af_single_extension( - *, - framework, - semantics: str, - backend: str, - stdout: TextIO, - stderr: TextIO, -) -> int: - result = solve_dung_single_extension( - framework, - semantics=semantics, - backend=backend, - ) - if isinstance(result, SingleExtensionSolverSuccess): - if result.extension is None: - print("NO", file=stdout) - else: - print(_witness_line(result.extension), file=stdout) - return 0 - print(result.reason, file=stderr) - return 1 - - -def _solve_af_acceptance( - *, - framework, - semantics: str, - task: str, - query: str, - backend: str, - stdout: TextIO, - stderr: TextIO, -) -> int: - result = solve_dung_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - backend=backend, - ) - if isinstance(result, AcceptanceSolverSuccess): - if result.answer: - print("YES", file=stdout) - if task == "credulous": - print(_witness_line(result.witness), file=stdout) - else: - print("NO", file=stdout) - if task == "skeptical": - print(_witness_line(result.counterexample), file=stdout) - return 0 - print(result.reason, file=stderr) - return 1 - - -def _solve_aba_cli( - *, - text: str, - task: str, - semantics: str, - query_argument: str | None, - backend: str, - stdout: TextIO, - stderr: TextIO, -) -> int: - framework = parse_aba(text) - if task == "SE": - return _solve_aba_single_extension( - framework=framework, - semantics=semantics, - backend=backend, - stdout=stdout, - stderr=stderr, - ) - if query_argument is None: - print(f"{task} tasks require -a/--argument", file=stderr) - return 2 - query = _aba_query(framework, query_argument) - return _solve_aba_acceptance( - framework=framework, - semantics=semantics, - task="credulous" if task == "DC" else "skeptical", - query=query, - backend=backend, - stdout=stdout, - stderr=stderr, - ) - - -def _solve_aba_single_extension( - *, - framework: ABAFramework, - semantics: str, - backend: str, - stdout: TextIO, - stderr: TextIO, -) -> int: - result = solve_aba_single_extension( - framework, - semantics=semantics, - backend=backend, - ) - if isinstance(result, SingleExtensionSolverSuccess): - if result.extension is None: - print("NO", file=stdout) - else: - print(_aba_witness_line(framework, result.extension), file=stdout) - return 0 - print(result.reason, file=stderr) - return 1 - - -def _solve_aba_acceptance( - *, - framework: ABAFramework, - semantics: str, - task: str, - query: Literal, - backend: str, - stdout: TextIO, - stderr: TextIO, -) -> int: - result = solve_aba_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - backend=backend, - ) - if isinstance(result, AcceptanceSolverSuccess): - print("YES" if result.answer else "NO", file=stdout) - return 0 - print(result.reason, file=stderr) - return 1 - - -def _witness_line(extension: frozenset[object] | None) -> str: - if extension is None: - raise ValueError("solver did not return a required certificate") - return "w" + "".join(f" {argument}" for argument in _sorted_arguments(extension)) - - -def _sorted_arguments(extension: frozenset[object]) -> list[str]: - arguments = [str(argument) for argument in extension] - if all(argument.isdigit() for argument in arguments): - return sorted(arguments, key=int) - return sorted(arguments) - - -def _aba_witness_line(framework: ABAFramework, extension: frozenset[object]) -> str: - literal_ids = _aba_literal_ids(framework) - ids: list[str] = [] - for item in extension: - if not isinstance(item, Literal): - raise ValueError("ABA solver returned a non-literal witness member") - if item not in framework.assumptions: - continue - ids.append(literal_ids[item]) - return "w" + "".join(f" {atom_id}" for atom_id in sorted(ids, key=_numeric_sort_key)) - - -def _aba_query(framework: ABAFramework, atom_id: str) -> Literal: - by_id = {value: literal for literal, value in _aba_literal_ids(framework).items()} - try: - return by_id[atom_id] - except KeyError as exc: - raise ValueError(f"query atom is not in framework language: {atom_id!r}") from exc - - -def _aba_literal_ids(framework: ABAFramework) -> dict[Literal, str]: - numeric = { - literal: literal.atom.predicate - for literal in framework.language - if not literal.negated - and not literal.atom.arguments - and literal.atom.predicate.isdigit() - } - if len(numeric) == len(framework.language): - return numeric - return { - literal: str(index) - for index, literal in enumerate(sorted(framework.language, key=repr), start=1) - } - - -def _numeric_sort_key(value: str) -> tuple[int, str]: - return (int(value), "") if value.isdigit() else (sys.maxsize, value) - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Command-line entry point for ICCMA-style AF and ABA solving.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +from typing import Sequence, TextIO + +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import Literal +from argumentation.interop.iccma import parse_aba, parse_af +from argumentation.core.labelling import ExactEnumerationExceeded +from argumentation.solving.solver import ( + AcceptanceSolverSuccess, + SingleExtensionSolverSuccess, + solve_aba_acceptance, + solve_aba_single_extension, + solve_dung_acceptance, + solve_dung_single_extension, +) + + +PROBLEM_SEMANTICS = { + "CO": "complete", + "GR": "grounded", + "PR": "preferred", + "ST": "stable", + "SST": "semi-stable", + "STG": "stage", + "ID": "ideal", + "CF2": "cf2", +} + +TASKS = {"DC", "DS", "SE"} + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + + try: + task, semantics = _parse_problem(args.problem) + text = args.file.read_text(encoding="utf-8") + kind = _instance_kind(text) + if kind == "aba": + return _solve_aba_cli( + text=text, + task=task, + semantics=semantics, + query_argument=args.argument, + backend=args.backend, + stdout=sys.stdout, + stderr=sys.stderr, + ) + framework = parse_af(text) + if task == "SE": + return _solve_af_single_extension( + framework=framework, + semantics=semantics, + backend=_af_backend(args.backend, semantics), + stdout=sys.stdout, + stderr=sys.stderr, + ) + if args.argument is None: + print(f"{task} tasks require -a/--argument", file=sys.stderr) + return 2 + return _solve_af_acceptance( + framework=framework, + semantics=semantics, + task="credulous" if task == "DC" else "skeptical", + query=args.argument, + backend=_af_backend(args.backend, semantics), + stdout=sys.stdout, + stderr=sys.stderr, + ) + except (OSError, ValueError, ExactEnumerationExceeded) as exc: + print(str(exc), file=sys.stderr) + return 2 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="argumentation.solving.iccma_cli", + description="Solve ICCMA-style abstract argumentation tasks.", + ) + parser.add_argument("-p", "--problem", required=True, help="ICCMA problem, e.g. SE-ST") + parser.add_argument( + "-f", + "--file", + required=True, + type=Path, + help="Path to an ICCMA p af or p aba input file", + ) + parser.add_argument("-a", "--argument", help="Query argument for DC/DS tasks") + parser.add_argument( + "--backend", + choices=("auto", "native", "sat"), + default="auto", + help="In-package backend to use", + ) + return parser + + +def _parse_problem(problem: str) -> tuple[str, str]: + parts = problem.upper().split("-", maxsplit=1) + if len(parts) != 2: + raise ValueError(f"ICCMA problem must look like TASK-SEMANTICS: {problem!r}") + task, semantics_code = parts + if task not in TASKS: + raise ValueError(f"unsupported ICCMA AF task: {task}") + semantics = PROBLEM_SEMANTICS.get(semantics_code) + if semantics is None: + raise ValueError(f"unsupported ICCMA AF semantics code: {semantics_code}") + return task, semantics + + +def _instance_kind(text: str) -> str: + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if parts[:2] == ["p", "af"]: + return "af" + if parts[:2] == ["p", "aba"]: + return "aba" + break + raise ValueError("ICCMA input must start with a p af or p aba header") + + +def _af_backend(requested: str, semantics: str) -> str: + if requested == "auto": + return "sat" if semantics == "stable" else "native" + return requested + + +def _solve_af_single_extension( + *, + framework, + semantics: str, + backend: str, + stdout: TextIO, + stderr: TextIO, +) -> int: + result = solve_dung_single_extension( + framework, + semantics=semantics, + backend=backend, + ) + if isinstance(result, SingleExtensionSolverSuccess): + if result.extension is None: + print("NO", file=stdout) + else: + print(_witness_line(result.extension), file=stdout) + return 0 + print(result.reason, file=stderr) + return 1 + + +def _solve_af_acceptance( + *, + framework, + semantics: str, + task: str, + query: str, + backend: str, + stdout: TextIO, + stderr: TextIO, +) -> int: + result = solve_dung_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + backend=backend, + ) + if isinstance(result, AcceptanceSolverSuccess): + if result.answer: + print("YES", file=stdout) + if task == "credulous": + print(_witness_line(result.witness), file=stdout) + else: + print("NO", file=stdout) + if task == "skeptical": + print(_witness_line(result.counterexample), file=stdout) + return 0 + print(result.reason, file=stderr) + return 1 + + +def _solve_aba_cli( + *, + text: str, + task: str, + semantics: str, + query_argument: str | None, + backend: str, + stdout: TextIO, + stderr: TextIO, +) -> int: + framework = parse_aba(text) + if task == "SE": + return _solve_aba_single_extension( + framework=framework, + semantics=semantics, + backend=backend, + stdout=stdout, + stderr=stderr, + ) + if query_argument is None: + print(f"{task} tasks require -a/--argument", file=stderr) + return 2 + query = _aba_query(framework, query_argument) + return _solve_aba_acceptance( + framework=framework, + semantics=semantics, + task="credulous" if task == "DC" else "skeptical", + query=query, + backend=backend, + stdout=stdout, + stderr=stderr, + ) + + +def _solve_aba_single_extension( + *, + framework: ABAFramework, + semantics: str, + backend: str, + stdout: TextIO, + stderr: TextIO, +) -> int: + result = solve_aba_single_extension( + framework, + semantics=semantics, + backend=backend, + ) + if isinstance(result, SingleExtensionSolverSuccess): + if result.extension is None: + print("NO", file=stdout) + else: + print(_aba_witness_line(framework, result.extension), file=stdout) + return 0 + print(result.reason, file=stderr) + return 1 + + +def _solve_aba_acceptance( + *, + framework: ABAFramework, + semantics: str, + task: str, + query: Literal, + backend: str, + stdout: TextIO, + stderr: TextIO, +) -> int: + result = solve_aba_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + backend=backend, + ) + if isinstance(result, AcceptanceSolverSuccess): + print("YES" if result.answer else "NO", file=stdout) + return 0 + print(result.reason, file=stderr) + return 1 + + +def _witness_line(extension: frozenset[object] | None) -> str: + if extension is None: + raise ValueError("solver did not return a required certificate") + return "w" + "".join(f" {argument}" for argument in _sorted_arguments(extension)) + + +def _sorted_arguments(extension: frozenset[object]) -> list[str]: + arguments = [str(argument) for argument in extension] + if all(argument.isdigit() for argument in arguments): + return sorted(arguments, key=int) + return sorted(arguments) + + +def _aba_witness_line(framework: ABAFramework, extension: frozenset[object]) -> str: + literal_ids = _aba_literal_ids(framework) + ids: list[str] = [] + for item in extension: + if not isinstance(item, Literal): + raise ValueError("ABA solver returned a non-literal witness member") + if item not in framework.assumptions: + continue + ids.append(literal_ids[item]) + return "w" + "".join(f" {atom_id}" for atom_id in sorted(ids, key=_numeric_sort_key)) + + +def _aba_query(framework: ABAFramework, atom_id: str) -> Literal: + by_id = {value: literal for literal, value in _aba_literal_ids(framework).items()} + try: + return by_id[atom_id] + except KeyError as exc: + raise ValueError(f"query atom is not in framework language: {atom_id!r}") from exc + + +def _aba_literal_ids(framework: ABAFramework) -> dict[Literal, str]: + numeric = { + literal: literal.atom.predicate + for literal in framework.language + if not literal.negated + and not literal.atom.arguments + and literal.atom.predicate.isdigit() + } + if len(numeric) == len(framework.language): + return numeric + return { + literal: str(index) + for index, literal in enumerate(sorted(framework.language, key=repr), start=1) + } + + +def _numeric_sort_key(value: str) -> tuple[int, str]: + return (int(value), "") if value.isdigit() else (sys.maxsize, value) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/argumentation/sat_encoding.py b/src/argumentation/solving/sat_encoding.py similarity index 95% rename from src/argumentation/sat_encoding.py rename to src/argumentation/solving/sat_encoding.py index 208a70c..dce3c62 100644 --- a/src/argumentation/sat_encoding.py +++ b/src/argumentation/solving/sat_encoding.py @@ -1,200 +1,200 @@ -"""Pure SAT/CNF encodings for finite argumentation problems.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from argumentation.dung import ( - ArgumentationFramework, - _attackers_index, - admissible, - grounded_extension, - ideal_extension, - semi_stable_extensions, - stage_extensions, -) - - -@dataclass(frozen=True) -class CNFEncoding: - """A deterministic CNF encoding with positive integer variables.""" - - variables: tuple[tuple[str, int], ...] - clauses: tuple[tuple[int, ...], ...] - - def __post_init__(self) -> None: - variables = tuple((argument, int(variable)) for argument, variable in self.variables) - variable_ids = tuple(variable for _, variable in variables) - if any(variable <= 0 for variable in variable_ids): - raise ValueError("variable ids must be positive") - if len(set(variable_ids)) != len(variable_ids): - raise ValueError("variable ids must be unique") - arguments = tuple(argument for argument, _ in variables) - if len(set(arguments)) != len(arguments): - raise ValueError("arguments must be unique") - - known_variables = set(variable_ids) - clauses = tuple(tuple(int(literal) for literal in clause) for clause in self.clauses) - unknown = sorted( - abs(literal) - for clause in clauses - for literal in clause - if abs(literal) not in known_variables - ) - if unknown: - raise ValueError(f"clauses reference unknown variables: {unknown!r}") - - object.__setattr__(self, "variables", variables) - object.__setattr__(self, "clauses", clauses) - - def argument_for_variable(self, variable: int) -> str: - for argument, variable_id in self.variables: - if variable_id == variable: - return argument - raise ValueError(f"unknown variable id: {variable!r}") - - def variable_for_argument(self, argument: str) -> int: - for known_argument, variable_id in self.variables: - if known_argument == argument: - return variable_id - raise ValueError(f"unknown argument: {argument!r}") - - -def encode_stable_extensions(framework: ArgumentationFramework) -> CNFEncoding: - """Encode stable extensions as CNF over one variable per argument. - - Positive variable ``x`` means the corresponding argument is in the extension. - The encoding contains conflict-free clauses and outsider-coverage clauses. - """ - variables = tuple( - (argument, index + 1) - for index, argument in enumerate(sorted(framework.arguments)) - ) - variable_by_argument = dict(variables) - clauses: list[tuple[int, ...]] = [] - - cf_relation = framework.attacks if framework.attacks is not None else framework.defeats - for attacker, target in sorted(cf_relation): - clauses.append((-variable_by_argument[attacker], -variable_by_argument[target])) - - attackers_index = _attackers_index(framework.defeats) - for argument in sorted(framework.arguments): - positive_literals = [ - variable_by_argument[argument], - *( - variable_by_argument[attacker] - for attacker in sorted(attackers_index.get(argument, frozenset())) - ), - ] - clauses.append(tuple(sorted(set(positive_literals)))) - - return CNFEncoding(variables=variables, clauses=tuple(clauses)) - - -def stable_extensions_from_encoding(encoding: CNFEncoding) -> list[frozenset[str]]: - """Enumerate all satisfying assignments as stable-extension candidates.""" - variable_ids = tuple(variable for _, variable in encoding.variables) - results: list[frozenset[str]] = [] - - for mask in range(1 << len(variable_ids)): - true_variables = frozenset( - variable - for index, variable in enumerate(variable_ids) - if mask & (1 << index) - ) - if _satisfies(encoding.clauses, true_variables): - results.append( - frozenset( - argument - for argument, variable in encoding.variables - if variable in true_variables - ) - ) - - return results - - -def sat_extensions( - framework: ArgumentationFramework, - semantics: str, -) -> tuple[frozenset[str], ...]: - """Enumerate SAT-supported Dung extensions. - - Niskanen and Järvisalo 2020 encode central Dung semantics with Boolean - variables for extension membership, plus iterative SAT calls for maximality - and enumeration. This in-package backend uses the same finite candidate - surface and blocking-style deterministic enumeration, while avoiding a hard - dependency on a specific external SAT solver. - """ - if semantics == "admissible": - return _sorted_extensions(_admissible_sets(framework)) - if semantics == "grounded": - return (grounded_extension(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"): - from argumentation.scc_recursive import scc_extensions - - return _sorted_extensions(scc_extensions(framework, semantics)) - if semantics == "semi-stable": - return _sorted_extensions(semi_stable_extensions(framework)) - if semantics == "stage": - return _sorted_extensions(stage_extensions(framework)) - if semantics == "ideal": - return (ideal_extension(framework),) - raise ValueError(f"unsupported SAT Dung semantics: {semantics}") - - -def _satisfies( - clauses: tuple[tuple[int, ...], ...], - true_variables: frozenset[int], -) -> bool: - for clause in clauses: - if not any(_literal_is_true(literal, true_variables) for literal in clause): - return False - return True - - -def _literal_is_true(literal: int, true_variables: frozenset[int]) -> bool: - variable = abs(literal) - if literal > 0: - return variable in true_variables - return variable not in true_variables - - -def _admissible_sets(framework: ArgumentationFramework) -> list[frozenset[str]]: - attackers_index = _attackers_index(framework.defeats) - arguments = sorted(framework.arguments) - results: list[frozenset[str]] = [] - for mask in range(1 << len(arguments)): - candidate = frozenset( - argument - for index, argument in enumerate(arguments) - if mask & (1 << index) - ) - if admissible( - candidate, - framework.arguments, - framework.defeats, - attacks=framework.attacks, - attackers_index=attackers_index, - ): - results.append(candidate) - return results - - -def _sorted_extensions(values: list[frozenset[str]]) -> tuple[frozenset[str], ...]: - return tuple( - sorted( - values, - key=lambda extension: (len(extension), tuple(sorted(extension))), - ) - ) - - -__all__ = [ - "CNFEncoding", - "encode_stable_extensions", - "sat_extensions", - "stable_extensions_from_encoding", -] +"""Pure SAT/CNF encodings for finite argumentation problems.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from argumentation.core.dung import ( + ArgumentationFramework, + _attackers_index, + admissible, + grounded_extension, + ideal_extension, + semi_stable_extensions, + stage_extensions, +) + + +@dataclass(frozen=True) +class CNFEncoding: + """A deterministic CNF encoding with positive integer variables.""" + + variables: tuple[tuple[str, int], ...] + clauses: tuple[tuple[int, ...], ...] + + def __post_init__(self) -> None: + variables = tuple((argument, int(variable)) for argument, variable in self.variables) + variable_ids = tuple(variable for _, variable in variables) + if any(variable <= 0 for variable in variable_ids): + raise ValueError("variable ids must be positive") + if len(set(variable_ids)) != len(variable_ids): + raise ValueError("variable ids must be unique") + arguments = tuple(argument for argument, _ in variables) + if len(set(arguments)) != len(arguments): + raise ValueError("arguments must be unique") + + known_variables = set(variable_ids) + clauses = tuple(tuple(int(literal) for literal in clause) for clause in self.clauses) + unknown = sorted( + abs(literal) + for clause in clauses + for literal in clause + if abs(literal) not in known_variables + ) + if unknown: + raise ValueError(f"clauses reference unknown variables: {unknown!r}") + + object.__setattr__(self, "variables", variables) + object.__setattr__(self, "clauses", clauses) + + def argument_for_variable(self, variable: int) -> str: + for argument, variable_id in self.variables: + if variable_id == variable: + return argument + raise ValueError(f"unknown variable id: {variable!r}") + + def variable_for_argument(self, argument: str) -> int: + for known_argument, variable_id in self.variables: + if known_argument == argument: + return variable_id + raise ValueError(f"unknown argument: {argument!r}") + + +def encode_stable_extensions(framework: ArgumentationFramework) -> CNFEncoding: + """Encode stable extensions as CNF over one variable per argument. + + Positive variable ``x`` means the corresponding argument is in the extension. + The encoding contains conflict-free clauses and outsider-coverage clauses. + """ + variables = tuple( + (argument, index + 1) + for index, argument in enumerate(sorted(framework.arguments)) + ) + variable_by_argument = dict(variables) + clauses: list[tuple[int, ...]] = [] + + cf_relation = framework.attacks if framework.attacks is not None else framework.defeats + for attacker, target in sorted(cf_relation): + clauses.append((-variable_by_argument[attacker], -variable_by_argument[target])) + + attackers_index = _attackers_index(framework.defeats) + for argument in sorted(framework.arguments): + positive_literals = [ + variable_by_argument[argument], + *( + variable_by_argument[attacker] + for attacker in sorted(attackers_index.get(argument, frozenset())) + ), + ] + clauses.append(tuple(sorted(set(positive_literals)))) + + return CNFEncoding(variables=variables, clauses=tuple(clauses)) + + +def stable_extensions_from_encoding(encoding: CNFEncoding) -> list[frozenset[str]]: + """Enumerate all satisfying assignments as stable-extension candidates.""" + variable_ids = tuple(variable for _, variable in encoding.variables) + results: list[frozenset[str]] = [] + + for mask in range(1 << len(variable_ids)): + true_variables = frozenset( + variable + for index, variable in enumerate(variable_ids) + if mask & (1 << index) + ) + if _satisfies(encoding.clauses, true_variables): + results.append( + frozenset( + argument + for argument, variable in encoding.variables + if variable in true_variables + ) + ) + + return results + + +def sat_extensions( + framework: ArgumentationFramework, + semantics: str, +) -> tuple[frozenset[str], ...]: + """Enumerate SAT-supported Dung extensions. + + Niskanen and Järvisalo 2020 encode central Dung semantics with Boolean + variables for extension membership, plus iterative SAT calls for maximality + and enumeration. This in-package backend uses the same finite candidate + surface and blocking-style deterministic enumeration, while avoiding a hard + dependency on a specific external SAT solver. + """ + if semantics == "admissible": + return _sorted_extensions(_admissible_sets(framework)) + if semantics == "grounded": + return (grounded_extension(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"): + from argumentation.core.scc_recursive import scc_extensions + + return _sorted_extensions(scc_extensions(framework, semantics)) + if semantics == "semi-stable": + return _sorted_extensions(semi_stable_extensions(framework)) + if semantics == "stage": + return _sorted_extensions(stage_extensions(framework)) + if semantics == "ideal": + return (ideal_extension(framework),) + raise ValueError(f"unsupported SAT Dung semantics: {semantics}") + + +def _satisfies( + clauses: tuple[tuple[int, ...], ...], + true_variables: frozenset[int], +) -> bool: + for clause in clauses: + if not any(_literal_is_true(literal, true_variables) for literal in clause): + return False + return True + + +def _literal_is_true(literal: int, true_variables: frozenset[int]) -> bool: + variable = abs(literal) + if literal > 0: + return variable in true_variables + return variable not in true_variables + + +def _admissible_sets(framework: ArgumentationFramework) -> list[frozenset[str]]: + attackers_index = _attackers_index(framework.defeats) + arguments = sorted(framework.arguments) + results: list[frozenset[str]] = [] + for mask in range(1 << len(arguments)): + candidate = frozenset( + argument + for index, argument in enumerate(arguments) + if mask & (1 << index) + ) + if admissible( + candidate, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + attackers_index=attackers_index, + ): + results.append(candidate) + return results + + +def _sorted_extensions(values: list[frozenset[str]]) -> tuple[frozenset[str], ...]: + return tuple( + sorted( + values, + key=lambda extension: (len(extension), tuple(sorted(extension))), + ) + ) + + +__all__ = [ + "CNFEncoding", + "encode_stable_extensions", + "sat_extensions", + "stable_extensions_from_encoding", +] diff --git a/src/argumentation/solver.py b/src/argumentation/solving/solver.py similarity index 94% rename from src/argumentation/solver.py rename to src/argumentation/solving/solver.py index b32e3de..c01b09e 100644 --- a/src/argumentation/solver.py +++ b/src/argumentation/solving/solver.py @@ -1,1275 +1,1275 @@ -"""Small solver-result wrappers for extension queries.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -import importlib.util - -from argumentation import aba as aba_semantics -from argumentation import adf as adf_semantics -from argumentation import setaf as setaf_semantics -from argumentation.aba import ABAFramework, ABAInput, ABAPlusFramework -from argumentation.aba_sat import ( - native_sparse_narrow_sat_extension as native_sparse_narrow_aba_extension, - sat_stable_acceptance as sat_aba_stable_acceptance, - sat_stable_extension as sat_aba_stable_extension, - sat_support_acceptance as sat_aba_support_acceptance, - sat_support_extension as sat_aba_support_extension, - support_extensions as sat_aba_support_extensions, -) -from argumentation.af_sat import ( - SATTraceSink, - find_complete_extension, - find_ideal_extension, - find_preferred_extension, - find_semi_stable_extension, - find_stable_extension, - find_stage_extension, - is_preferred_skeptically_accepted, -) -from argumentation.adf import AbstractDialecticalFramework -from argumentation.aspic import Literal -from argumentation.dung import ( - ArgumentationFramework, - cf2_extensions, - complete_extensions, - grounded_extension, - ideal_extension, - preferred_extensions, - semi_stable_extensions, - stable_extensions, - stage_extensions, -) -from argumentation.aba_route_policy import sparse_narrow_native_sat_shape -from argumentation.sat_encoding import ( - sat_extensions, -) -from argumentation.scc_recursive import ( - SCC_RECURSIVE_SEMANTICS, - scc_extensions, -) -from argumentation.setaf import SETAF -from argumentation.solver_adapters import iccma_aba, iccma_af -from argumentation.solver_results import ( - AcceptanceSuccess, - ExtensionEnumerationSuccess, - SingleExtensionSuccess, - SolverProcessError, - SolverProtocolError, - SolverTimeout, - SolverUnavailable, -) - - -SolverBackendUnavailable = SolverUnavailable -SolverBackendError = SolverProcessError -SolverBackendTimeout = SolverTimeout - - -@dataclass(frozen=True) -class ICCMAConfig: - """ICCMA subprocess configuration for solver backends.""" - - binary: str - timeout_seconds: float = 30.0 - - -@dataclass(frozen=True) -class SATConfig: - """Configuration for package-native or externally supplied SAT solving.""" - - require_external: bool = False - trace_sink: SATTraceSink | None = None - metadata: Mapping[str, object] | None = None - - -ExtensionSolverSuccess = ExtensionEnumerationSuccess -SingleExtensionSolverSuccess = SingleExtensionSuccess -AcceptanceSolverSuccess = AcceptanceSuccess - - -ExtensionSolverResult = ( - ExtensionSolverSuccess - | SolverBackendUnavailable - | SolverBackendError - | SolverBackendTimeout - | SolverProtocolError -) -SingleExtensionSolverResult = ( - SingleExtensionSolverSuccess - | SolverBackendUnavailable - | SolverBackendError - | SolverBackendTimeout - | SolverProtocolError -) -AcceptanceSolverResult = ( - AcceptanceSolverSuccess - | SolverBackendUnavailable - | SolverBackendError - | SolverBackendTimeout - | SolverProtocolError -) - - -def solve_adf_models( - framework: AbstractDialecticalFramework, - *, - semantics: str, - backend: str = "auto", -) -> ExtensionSolverResult: - """Solve ADF model queries through native semantics or a declared backend.""" - if backend == "auto": - backend = "native" - if backend == "native": - return ExtensionSolverSuccess(_adf_models(framework, semantics)) - return SolverBackendUnavailable( - backend=backend, - reason="external ADF solver backend is not source-backed", - install_hint="Use backend='native' or add a primary-source-backed ADF adapter.", - ) - - -def solve_setaf_extensions( - framework: SETAF, - *, - semantics: str, - backend: str = "auto", -) -> ExtensionSolverResult: - """Solve SETAF extension queries through native semantics or a declared backend.""" - if backend == "auto": - backend = "native" - if backend == "native": - return ExtensionSolverSuccess(_setaf_extensions(framework, semantics)) - return SolverBackendUnavailable( - backend=backend, - reason="external SETAF solver backend is not source-backed", - install_hint="Use backend='native' or add a primary-source-backed SETAF adapter.", - ) - - -def solve_aba_single_extension( - framework: ABAInput, - *, - semantics: str, - backend: str = "auto", - iccma: ICCMAConfig | None = None, - clingo_control_args: tuple[str, ...] = (), - collect_clingo_statistics: bool = False, - clingo_solve_timeout_seconds: float | None = None, -) -> SingleExtensionSolverResult: - """Solve one flat ABA extension witness query.""" - backend = _auto_aba_backend_for_framework( - backend, - semantics, - task="single-extension", - framework=framework, - ) - if backend == "sat": - if not isinstance(framework, ABAFramework): - return _aba_sat_requires_flat_framework() - if ( - semantics in {"preferred", "stable"} - and sparse_narrow_native_sat_shape(framework) - ): - try: - result = native_sparse_narrow_aba_extension(framework, semantics) - except RuntimeError as exc: - return _aba_sat_runtime_unavailable(exc) - return SingleExtensionSolverSuccess( - extension=result.extension, - metadata=result.route_metadata | result.telemetry, - ) - if semantics == "stable": - try: - return SingleExtensionSolverSuccess( - extension=sat_aba_stable_extension(framework), - ) - except RuntimeError as exc: - return _aba_sat_runtime_unavailable(exc) - if semantics in {"complete", "preferred"}: - return SingleExtensionSolverSuccess( - extension=sat_aba_support_extension(framework, semantics), - ) - return _aba_sat_unsupported_semantics(semantics) - if backend in {"asp", "clingo"}: - if not isinstance(framework, ABAFramework): - return _aba_asp_requires_flat_framework(backend) - return _solve_asp_aba_single_extension( - framework, - semantics, - backend, - clingo_control_args=clingo_control_args, - collect_clingo_statistics=collect_clingo_statistics, - clingo_solve_timeout_seconds=clingo_solve_timeout_seconds, - ) - if backend == "native": - extensions = _sorted_object_extensions(_aba_extensions(framework, semantics)) - return SingleExtensionSolverSuccess( - extension=extensions[0] if extensions else None, - ) - if backend == "iccma": - if iccma is None: - return _missing_iccma_config() - return _solve_iccma_aba_single_extension(framework, semantics, iccma) - if backend == "aspforaba": - return _aspforaba_unavailable() - return SolverBackendUnavailable( - backend=backend, - install_hint="Use backend='native'.", - reason=f"unknown backend: {backend!r}", - ) - - -def solve_aba_acceptance( - framework: ABAInput, - *, - semantics: str, - task: str, - query: Literal, - backend: str = "auto", - iccma: ICCMAConfig | None = None, -) -> AcceptanceSolverResult: - """Solve flat ABA credulous or skeptical acceptance queries.""" - if query not in _aba_base(framework).language: - raise ValueError(f"query literal is not in framework language: {query!r}") - backend = _auto_aba_backend_for_framework( - backend, - semantics, - task=task, - framework=framework, - ) - if backend == "sat": - if not isinstance(framework, ABAFramework): - return _aba_sat_requires_flat_framework() - if semantics == "stable": - try: - return _solve_sat_stable_aba_acceptance(framework, task, query) - except RuntimeError as exc: - return _aba_sat_runtime_unavailable(exc) - if semantics in {"complete", "preferred"}: - answer, witness = sat_aba_support_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - ) - return AcceptanceSolverSuccess( - answer=answer, - witness=witness if task == "credulous" and answer else None, - counterexample=witness if task == "skeptical" and not answer else None, - ) - return _aba_sat_unsupported_semantics(semantics) - if backend in {"asp", "clingo"}: - if not isinstance(framework, ABAFramework): - return _aba_asp_requires_flat_framework(backend) - return _solve_asp_aba_acceptance(framework, semantics, task, query, backend) - if backend == "native": - return _solve_native_aba_acceptance(framework, semantics, task, query) - if backend == "iccma": - if iccma is None: - return _missing_iccma_config() - return _solve_iccma_aba_acceptance(framework, semantics, task, query, iccma) - if backend == "aspforaba": - return _aspforaba_unavailable() - return SolverBackendUnavailable( - backend=backend, - install_hint="Use backend='native'.", - reason=f"unknown backend: {backend!r}", - ) - - -def solve_dung_extensions( - framework: ArgumentationFramework, - *, - semantics: str, - backend: str = "auto", - iccma: ICCMAConfig | None = None, - sat: SATConfig | None = None, -) -> ExtensionSolverResult: - """Solve Dung extension queries through a package or external backend.""" - backend = _auto_dung_extension_backend(backend, semantics) - if backend == "iccma": - return SolverBackendUnavailable( - backend=iccma.binary if iccma is not None else "iccma", - install_hint="Use solve_dung_single_extension for ICCMA AF SE tasks.", - reason="ICCMA AF SE tasks return one extension witness, not enumeration", - ) - if backend == "sat": - if sat is not None and sat.require_external: - return _external_sat_unavailable() - return ExtensionSolverSuccess(sat_extensions(framework, semantics)) - if backend == "native": - return ExtensionSolverSuccess( - _sorted_extensions(_dung_extensions(framework, semantics)) - ) - return SolverBackendUnavailable( - backend=backend, - install_hint="Use backend='native'.", - reason=f"unknown backend: {backend!r}", - ) - - -def solve_dung_single_extension( - framework: ArgumentationFramework, - *, - semantics: str, - backend: str = "auto", - iccma: ICCMAConfig | None = None, - sat: SATConfig | None = None, -) -> SingleExtensionSolverResult: - """Solve one Dung extension witness query.""" - backend = _auto_dung_single_backend(backend, semantics) - if backend == "iccma": - if iccma is None: - return _missing_iccma_config() - return _solve_iccma_dung_single_extension(framework, semantics, iccma) - if backend == "native": - extensions = _sorted_extensions(_dung_extensions(framework, semantics)) - return SingleExtensionSolverSuccess( - extension=extensions[0] if extensions else None, - ) - if backend == "sat": - if sat is not None and sat.require_external: - return _external_sat_unavailable() - trace_sink, metadata = _sat_trace(sat) - if semantics == "stable": - try: - return SingleExtensionSolverSuccess( - extension=find_stable_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "complete": - try: - return SingleExtensionSolverSuccess( - extension=find_complete_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "preferred": - try: - return SingleExtensionSolverSuccess( - extension=find_preferred_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "semi-stable": - try: - return SingleExtensionSolverSuccess( - extension=find_semi_stable_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "stage": - try: - return SingleExtensionSolverSuccess( - extension=find_stage_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "ideal": - try: - return SingleExtensionSolverSuccess( - extension=find_ideal_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ), - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - extensions = sat_extensions(framework, semantics) - return SingleExtensionSolverSuccess( - extension=extensions[0] if extensions else None, - ) - return SolverBackendUnavailable( - backend=backend, - install_hint="Use backend='native'.", - reason=f"unknown backend: {backend!r}", - ) - - -def solve_dung_acceptance( - framework: ArgumentationFramework, - *, - semantics: str, - task: str, - query: str, - backend: str = "auto", - iccma: ICCMAConfig | None = None, - sat: SATConfig | None = None, -) -> AcceptanceSolverResult: - """Solve Dung credulous or skeptical acceptance queries.""" - if query not in framework.arguments: - raise ValueError(f"query argument is not in framework: {query!r}") - backend = _auto_dung_acceptance_backend(backend, semantics, task) - if backend == "iccma": - if iccma is None: - return _missing_iccma_config() - return _solve_iccma_dung_acceptance(framework, semantics, task, query, iccma) - if backend == "native": - return _solve_native_dung_acceptance(framework, semantics, task, query) - if backend == "sat": - if sat is not None and sat.require_external: - return _external_sat_unavailable() - trace_sink, metadata = _sat_trace(sat) - if semantics == "stable": - try: - return _solve_sat_stable_acceptance( - framework, - task, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "complete": - try: - return _solve_sat_complete_acceptance( - framework, - task, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "preferred" and task == "credulous": - try: - return _solve_sat_preferred_credulous_acceptance( - framework, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "preferred" and task == "skeptical": - try: - return _solve_sat_preferred_skeptical_acceptance( - framework, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "ideal": - try: - return _solve_sat_ideal_acceptance( - framework, - task, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "semi-stable": - try: - return _solve_sat_semi_stable_acceptance( - framework, - task, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - if semantics == "stage": - try: - return _solve_sat_stage_acceptance( - framework, - task, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - except RuntimeError as exc: - return _sat_runtime_unavailable(exc) - return _solve_dung_acceptance_from_extensions( - sat_extensions(framework, semantics), - task, - query, - ) - return SolverBackendUnavailable( - backend=backend, - install_hint="Use backend='native'.", - reason=f"unknown backend: {backend!r}", - ) - - -def _missing_iccma_config() -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="iccma", - reason="missing ICCMA solver configuration", - install_hint="Pass iccma=ICCMAConfig(binary=...).", - ) - - -def _auto_dung_extension_backend(backend: str, semantics: str) -> str: - if backend == "auto": - return "sat" if semantics in {"complete", "stable"} else "native" - return backend - - -def _auto_dung_single_backend(backend: str, semantics: str) -> str: - if backend == "auto": - return ( - "sat" - if semantics in {"complete", "ideal", "preferred", "semi-stable", "stable", "stage"} - else "native" - ) - return backend - - -def _auto_dung_acceptance_backend(backend: str, semantics: str, task: str) -> str: - if backend == "auto": - if semantics in {"complete", "ideal", "semi-stable", "stable", "stage"}: - return "sat" - if semantics == "preferred" and task in {"credulous", "skeptical"}: - return "sat" - return "native" - return backend - - -def _auto_aba_backend(backend: str, semantics: str, *, task: str) -> str: - if backend == "auto": - if ( - _has_clingo() - and ( - semantics == "grounded" - or ( - semantics == "preferred" - and task in {"single-extension", "skeptical"} - ) - or (semantics == "stable" and task == "single-extension") - ) - ): - return "asp" - return "sat" if semantics in {"complete", "preferred", "stable"} else "native" - return backend - - -def _auto_aba_backend_for_framework( - backend: str, - semantics: str, - *, - task: str, - framework: ABAInput, -) -> str: - if ( - backend == "auto" - and semantics == "preferred" - and task == "single-extension" - and isinstance(framework, ABAFramework) - and sparse_narrow_native_sat_shape(framework) - ): - return "sat" - if ( - backend == "auto" - and semantics == "stable" - and task == "single-extension" - and isinstance(framework, ABAFramework) - and _is_large_dense_flat_aba(framework) - ): - return "sat" - return _auto_aba_backend(backend, semantics, task=task) - - -def _is_large_dense_flat_aba(framework: ABAFramework) -> bool: - assumptions = len(framework.assumptions) - if assumptions <= 150: - return False - if any(rule.consequent in framework.assumptions for rule in framework.rules): - return False - return (len(framework.rules) / assumptions) > 25.0 - - -def _has_clingo() -> bool: - return importlib.util.find_spec("clingo") is not None - - -def _external_sat_unavailable() -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="sat", - reason="external SAT backend is not configured", - install_hint="Use SATConfig(require_external=False) for the package-native SAT enumerator.", - ) - - -def _sat_trace(sat: SATConfig | None) -> tuple[SATTraceSink | None, Mapping[str, object] | None]: - if sat is None: - return None, None - return sat.trace_sink, sat.metadata - - -def _sat_runtime_unavailable(exc: RuntimeError) -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="sat", - reason=str(exc), - install_hint="Install the z3-solver extra or use backend='native'.", - ) - - -def _aba_sat_runtime_unavailable(exc: RuntimeError) -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="sat", - reason=str(exc), - install_hint="Install the z3-solver extra or use backend='native'.", - ) - - -def _aba_sat_requires_flat_framework() -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="sat", - reason="ABA stable SAT backend requires a flat ABAFramework", - install_hint="Use backend='native' for ABAPlusFramework inputs.", - ) - - -def _aba_asp_requires_flat_framework(backend: str) -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend=backend, - reason="ABA ASP backend requires a flat ABAFramework", - install_hint="Use backend='native' for ABAPlusFramework inputs.", - ) - - -def _aba_sat_unsupported_semantics(semantics: str) -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="sat", - reason=f"ABA SAT backend does not support {semantics!r} semantics", - install_hint="Use backend='native' or backend='iccma'.", - ) - - -def _solve_asp_aba_single_extension( - framework: ABAFramework, - semantics: str, - backend: str, - *, - clingo_control_args: tuple[str, ...] = (), - collect_clingo_statistics: bool = False, - clingo_solve_timeout_seconds: float | None = None, -) -> SingleExtensionSolverResult: - from argumentation.aba_asp import solve_aba_with_backend - - result = solve_aba_with_backend( - framework, - backend=backend, - semantics=semantics, - task="single-extension", - clingo_control_args=clingo_control_args, - collect_clingo_statistics=collect_clingo_statistics, - clingo_solve_timeout_seconds=clingo_solve_timeout_seconds, - ) - if result.status == "success": - extension = result.extensions[0] if result.extensions else None - return SingleExtensionSolverSuccess(extension=extension, metadata=dict(result.metadata)) - return _aba_asp_failure(result) - - -def _solve_asp_aba_acceptance( - framework: ABAFramework, - semantics: str, - task: str, - query: Literal, - backend: str, -) -> AcceptanceSolverResult: - from argumentation.aba_asp import solve_aba_with_backend - - result = solve_aba_with_backend( - framework, - backend=backend, - semantics=semantics, - task=task, - query=query, - ) - if result.status == "success" and result.answer is not None: - return AcceptanceSolverSuccess( - answer=result.answer, - witness=result.witness if task == "credulous" and result.answer else None, - counterexample=( - result.counterexample if task == "skeptical" and not result.answer else None - ), - metadata=dict(result.metadata), - ) - return _aba_asp_failure(result) - - -def _aba_asp_failure( - result, -) -> SolverBackendUnavailable | SolverBackendError | SolverBackendTimeout | SolverProtocolError: - reason = result.metadata.get("reason", result.status) - stdout = result.metadata.get("stdout", "") - stderr = result.metadata.get("stderr", "") - problem = f"ABA-{result.semantics.upper()}" - if result.status == "unavailable_backend": - return SolverBackendUnavailable( - backend=result.backend, - reason=reason, - install_hint="Install the clingo Python package or use backend='sat'/'native'.", - ) - if result.status == "backend_error": - return SolverBackendError( - backend=result.backend, - problem=problem, - returncode=1, - stdout=stdout, - stderr=stderr or reason, - ) - if result.status == "timeout": - return SolverBackendTimeout( - backend=result.backend, - problem=problem, - message=reason, - metadata=dict(result.metadata), - ) - return SolverProtocolError( - backend=result.backend, - problem=problem, - message=reason, - stdout=stdout, - stderr=stderr, - ) - - -def _aspforaba_unavailable() -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend="aspforaba", - reason="ASPFORABA invocation contract is not configured", - install_hint="Use backend='native' for flat ABA queries.", - ) - - -def _solve_native_aba_acceptance( - framework: ABAInput, - semantics: str, - task: str, - query: Literal, -) -> AcceptanceSolverSuccess: - extensions = _sorted_object_extensions(_aba_extensions(framework, semantics)) - base = _aba_base(framework) - if task == "credulous": - witness = next( - ( - extension - for extension in extensions - if aba_semantics.derives(base, _literal_extension(extension), query) - ), - None, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = next( - ( - extension - for extension in extensions - if not aba_semantics.derives(base, _literal_extension(extension), query) - ), - None, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported ABA acceptance task: {task}") - - -def _solve_sat_stable_aba_acceptance( - framework: ABAFramework, - task: str, - query: Literal, -) -> AcceptanceSolverSuccess: - if task in {"credulous", "skeptical"}: - answer, witness = sat_aba_stable_acceptance(framework, task=task, query=query) - return AcceptanceSolverSuccess( - answer=answer, - witness=witness if task == "credulous" and answer else None, - counterexample=witness if task == "skeptical" and not answer else None, - ) - raise ValueError(f"unsupported ABA acceptance task: {task}") - - -def _solve_native_dung_acceptance( - framework: ArgumentationFramework, - semantics: str, - task: str, - query: str, -) -> AcceptanceSolverSuccess: - extensions = _sorted_extensions(_dung_extensions(framework, semantics)) - return _solve_dung_acceptance_from_extensions(extensions, task, query) - - -def _solve_sat_stable_acceptance( - framework: ArgumentationFramework, - task: str, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - if task == "credulous": - witness = find_stable_extension( - framework, - require_in=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = find_stable_extension( - framework, - require_out=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_sat_complete_acceptance( - framework: ArgumentationFramework, - task: str, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - if task == "credulous": - witness = find_complete_extension( - framework, - require_in=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = find_complete_extension( - framework, - require_out=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_sat_preferred_credulous_acceptance( - framework: ArgumentationFramework, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - witness = find_preferred_extension( - framework, - require_in=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - - -def _solve_sat_preferred_skeptical_acceptance( - framework: ArgumentationFramework, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - answer = is_preferred_skeptically_accepted( - framework, - query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=answer, - ) - - -def _solve_sat_ideal_acceptance( - framework: ArgumentationFramework, - task: str, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - extension = find_ideal_extension( - framework, - trace_sink=trace_sink, - metadata=metadata, - ) - if task == "credulous": - return AcceptanceSolverSuccess( - answer=query in extension, - witness=extension if query in extension else None, - ) - if task == "skeptical": - return AcceptanceSolverSuccess( - answer=query in extension, - counterexample=None if query in extension else extension, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_sat_semi_stable_acceptance( - framework: ArgumentationFramework, - task: str, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - if task == "credulous": - witness = find_semi_stable_extension( - framework, - require_in=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = find_semi_stable_extension( - framework, - require_out=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_sat_stage_acceptance( - framework: ArgumentationFramework, - task: str, - query: str, - *, - trace_sink: SATTraceSink | None = None, - metadata: Mapping[str, object] | None = None, -) -> AcceptanceSolverSuccess: - if task == "credulous": - witness = find_stage_extension( - framework, - require_in=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = find_stage_extension( - framework, - require_out=query, - trace_sink=trace_sink, - metadata=metadata, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_dung_acceptance_from_extensions( - extensions: tuple[frozenset[str], ...], - task: str, - query: str, -) -> AcceptanceSolverSuccess: - if task == "credulous": - witness = next( - (extension for extension in extensions if query in extension), - None, - ) - return AcceptanceSolverSuccess( - answer=witness is not None, - witness=witness, - ) - if task == "skeptical": - counterexample = next( - (extension for extension in extensions if query not in extension), - None, - ) - return AcceptanceSolverSuccess( - answer=counterexample is None, - counterexample=counterexample, - ) - raise ValueError(f"unsupported Dung acceptance task: {task}") - - -def _solve_iccma_dung_single_extension( - framework: ArgumentationFramework, - semantics: str, - backend: ICCMAConfig, -) -> SingleExtensionSolverResult: - result = iccma_af.solve_af_extensions( - framework=framework, - semantics=semantics, - binary=backend.binary, - timeout_seconds=backend.timeout_seconds, - ) - if isinstance(result, iccma_af.ICCMASolverSuccess): - return SingleExtensionSolverSuccess( - extension=result.witness if not result.output.no_extension else None, - ) - if isinstance(result, iccma_af.ICCMASolverUnavailable): - return result - if isinstance(result, iccma_af.ICCMASolverError): - return result - return result - - -def _solve_iccma_aba_single_extension( - framework: ABAInput, - semantics: str, - backend: ICCMAConfig, -) -> SingleExtensionSolverResult: - if not isinstance(framework, ABAFramework): - return _iccma_aba_requires_flat_framework(backend) - result = iccma_aba.solve_aba_extensions( - framework=framework, - semantics=semantics, - binary=backend.binary, - timeout_seconds=backend.timeout_seconds, - ) - if isinstance(result, iccma_aba.ICCMAABASolverSuccess): - return SingleExtensionSolverSuccess( - extension=result.witness if not result.output.no_extension else None, - ) - if isinstance(result, iccma_aba.ICCMAABASolverUnavailable): - return result - if isinstance(result, iccma_aba.ICCMAABASolverError): - return result - return result - - -def _solve_iccma_dung_acceptance( - framework: ArgumentationFramework, - semantics: str, - task: str, - query: str, - backend: ICCMAConfig, -) -> AcceptanceSolverResult: - result = iccma_af.solve_af_acceptance( - framework=framework, - semantics=semantics, - task=task, - query=query, - binary=backend.binary, - timeout_seconds=backend.timeout_seconds, - certificate_required=True, - ) - if isinstance(result, iccma_af.ICCMASolverSuccess): - return AcceptanceSolverSuccess( - answer=result.answer is True, - witness=result.witness if result.answer is True else None, - counterexample=result.witness if result.answer is False else None, - ) - if isinstance(result, iccma_af.ICCMASolverUnavailable): - return result - if isinstance(result, iccma_af.ICCMASolverError): - return result - return result - - -def _solve_iccma_aba_acceptance( - framework: ABAInput, - semantics: str, - task: str, - query: Literal, - backend: ICCMAConfig, -) -> AcceptanceSolverResult: - if not isinstance(framework, ABAFramework): - return _iccma_aba_requires_flat_framework(backend) - result = iccma_aba.solve_aba_acceptance( - framework=framework, - semantics=semantics, - task=task, - query=query, - binary=backend.binary, - timeout_seconds=backend.timeout_seconds, - ) - if isinstance(result, iccma_aba.ICCMAABASolverSuccess): - return AcceptanceSolverSuccess( - answer=result.answer is True, - ) - if isinstance(result, iccma_aba.ICCMAABASolverUnavailable): - return result - if isinstance(result, iccma_aba.ICCMAABASolverError): - return result - return result - - -def _iccma_aba_requires_flat_framework( - backend: ICCMAConfig, -) -> SolverBackendUnavailable: - return SolverBackendUnavailable( - backend=backend.binary, - reason="ICCMA ABA backend requires a flat ABAFramework", - install_hint="Use backend='native' for ABAPlusFramework inputs.", - ) - - -def _dung_extensions( - framework: ArgumentationFramework, - semantics: str, -) -> list[frozenset[str]]: - if semantics == "grounded": - return [grounded_extension(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, - # faster on layered/many-small-SCC AFs, ~1.0x on a single giant SCC. - if semantics in SCC_RECURSIVE_SEMANTICS: - return scc_extensions(framework, semantics) - if semantics == "complete": - return complete_extensions(framework) - if semantics == "preferred": - return preferred_extensions(framework) - if semantics == "stable": - return stable_extensions(framework) - if semantics == "semi-stable": - return semi_stable_extensions(framework) - if semantics == "stage": - return stage_extensions(framework) - if semantics == "ideal": - return [ideal_extension(framework)] - if semantics == "cf2": - return cf2_extensions(framework) - raise ValueError(f"Unknown Dung semantics: {semantics}") - - -def _adf_models( - framework: AbstractDialecticalFramework, - semantics: str, -) -> tuple[frozenset[object], ...]: - if semantics == "grounded": - return (frozenset(adf_semantics.grounded_interpretation(framework)),) - if semantics == "complete": - return tuple(frozenset(model) for model in adf_semantics.complete_models(framework)) - if semantics == "model": - return tuple(frozenset(model) for model in adf_semantics.model_models(framework)) - if semantics == "preferred": - return tuple(frozenset(model) for model in adf_semantics.preferred_models(framework)) - if semantics == "stable": - return tuple(frozenset(model) for model in adf_semantics.stable_models(framework)) - raise ValueError(f"Unknown ADF semantics: {semantics}") - - -def _setaf_extensions( - framework: SETAF, - semantics: str, -) -> tuple[frozenset[object], ...]: - if semantics == "grounded": - return (frozenset(setaf_semantics.grounded_extension(framework)),) - if semantics == "complete": - return tuple(frozenset(extension) for extension in setaf_semantics.complete_extensions(framework)) - if semantics == "preferred": - return tuple(frozenset(extension) for extension in setaf_semantics.preferred_extensions(framework)) - if semantics == "stable": - return tuple(frozenset(extension) for extension in setaf_semantics.stable_extensions(framework)) - if semantics == "semi-stable": - return tuple(frozenset(extension) for extension in setaf_semantics.semi_stable_extensions(framework)) - if semantics == "stage": - return tuple(frozenset(extension) for extension in setaf_semantics.stage_extensions(framework)) - raise ValueError(f"Unknown SETAF semantics: {semantics}") - - -def _aba_extensions( - framework: ABAInput, - semantics: str, -) -> tuple[frozenset[Literal], ...]: - if semantics == "grounded": - return (aba_semantics.grounded_extension(framework),) - if semantics == "complete": - return aba_semantics.complete_extensions(framework) - if semantics == "preferred": - return aba_semantics.preferred_extensions(framework) - if semantics == "stable": - return aba_semantics.stable_extensions(framework) - if semantics == "well-founded": - return (aba_semantics.well_founded_extension(framework),) - if semantics == "ideal": - return (aba_semantics.ideal_extension(framework),) - raise ValueError(f"Unknown ABA semantics: {semantics}") - - -def _aba_base(framework: ABAInput) -> ABAFramework: - return framework.framework if isinstance(framework, ABAPlusFramework) else framework - - -def _literal_extension(extension: frozenset[object]) -> frozenset[Literal]: - if all(isinstance(item, Literal) for item in extension): - return frozenset(item for item in extension if isinstance(item, Literal)) - raise TypeError("ABA extension contains non-literal members") - - -def _sorted_extensions(values: list[frozenset[str]]) -> tuple[frozenset[str], ...]: - return tuple( - sorted( - values, - key=lambda extension: (len(extension), tuple(sorted(extension))), - ) - ) - - -def _sorted_object_extensions( - values: tuple[frozenset[Literal], ...], -) -> tuple[frozenset[object], ...]: - return tuple( - sorted( - (frozenset(extension) for extension in values), - key=lambda extension: (len(extension), tuple(sorted(map(repr, extension)))), - ) - ) +"""Small solver-result wrappers for extension queries.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import importlib.util + +from argumentation.structured.aba import aba as aba_semantics +from argumentation.frameworks import adf as adf_semantics +from argumentation.frameworks import setaf as setaf_semantics +from argumentation.structured.aba.aba import ABAFramework, ABAInput, ABAPlusFramework +from argumentation.structured.aba.aba_sat import ( + native_sparse_narrow_sat_extension as native_sparse_narrow_aba_extension, + sat_stable_acceptance as sat_aba_stable_acceptance, + sat_stable_extension as sat_aba_stable_extension, + sat_support_acceptance as sat_aba_support_acceptance, + sat_support_extension as sat_aba_support_extension, + support_extensions as sat_aba_support_extensions, +) +from argumentation.solving.af_sat import ( + SATTraceSink, + find_complete_extension, + find_ideal_extension, + find_preferred_extension, + find_semi_stable_extension, + find_stable_extension, + find_stage_extension, + is_preferred_skeptically_accepted, +) +from argumentation.frameworks.adf import AbstractDialecticalFramework +from argumentation.structured.aspic.aspic import Literal +from argumentation.core.dung import ( + ArgumentationFramework, + cf2_extensions, + complete_extensions, + grounded_extension, + ideal_extension, + preferred_extensions, + semi_stable_extensions, + stable_extensions, + stage_extensions, +) +from argumentation.structured.aba.aba_route_policy import sparse_narrow_native_sat_shape +from argumentation.solving.sat_encoding import ( + sat_extensions, +) +from argumentation.core.scc_recursive import ( + SCC_RECURSIVE_SEMANTICS, + scc_extensions, +) +from argumentation.frameworks.setaf import SETAF +from argumentation.solver_adapters import iccma_aba, iccma_af +from argumentation.core.solver_results import ( + AcceptanceSuccess, + ExtensionEnumerationSuccess, + SingleExtensionSuccess, + SolverProcessError, + SolverProtocolError, + SolverTimeout, + SolverUnavailable, +) + + +SolverBackendUnavailable = SolverUnavailable +SolverBackendError = SolverProcessError +SolverBackendTimeout = SolverTimeout + + +@dataclass(frozen=True) +class ICCMAConfig: + """ICCMA subprocess configuration for solver backends.""" + + binary: str + timeout_seconds: float = 30.0 + + +@dataclass(frozen=True) +class SATConfig: + """Configuration for package-native or externally supplied SAT solving.""" + + require_external: bool = False + trace_sink: SATTraceSink | None = None + metadata: Mapping[str, object] | None = None + + +ExtensionSolverSuccess = ExtensionEnumerationSuccess +SingleExtensionSolverSuccess = SingleExtensionSuccess +AcceptanceSolverSuccess = AcceptanceSuccess + + +ExtensionSolverResult = ( + ExtensionSolverSuccess + | SolverBackendUnavailable + | SolverBackendError + | SolverBackendTimeout + | SolverProtocolError +) +SingleExtensionSolverResult = ( + SingleExtensionSolverSuccess + | SolverBackendUnavailable + | SolverBackendError + | SolverBackendTimeout + | SolverProtocolError +) +AcceptanceSolverResult = ( + AcceptanceSolverSuccess + | SolverBackendUnavailable + | SolverBackendError + | SolverBackendTimeout + | SolverProtocolError +) + + +def solve_adf_models( + framework: AbstractDialecticalFramework, + *, + semantics: str, + backend: str = "auto", +) -> ExtensionSolverResult: + """Solve ADF model queries through native semantics or a declared backend.""" + if backend == "auto": + backend = "native" + if backend == "native": + return ExtensionSolverSuccess(_adf_models(framework, semantics)) + return SolverBackendUnavailable( + backend=backend, + reason="external ADF solver backend is not source-backed", + install_hint="Use backend='native' or add a primary-source-backed ADF adapter.", + ) + + +def solve_setaf_extensions( + framework: SETAF, + *, + semantics: str, + backend: str = "auto", +) -> ExtensionSolverResult: + """Solve SETAF extension queries through native semantics or a declared backend.""" + if backend == "auto": + backend = "native" + if backend == "native": + return ExtensionSolverSuccess(_setaf_extensions(framework, semantics)) + return SolverBackendUnavailable( + backend=backend, + reason="external SETAF solver backend is not source-backed", + install_hint="Use backend='native' or add a primary-source-backed SETAF adapter.", + ) + + +def solve_aba_single_extension( + framework: ABAInput, + *, + semantics: str, + backend: str = "auto", + iccma: ICCMAConfig | None = None, + clingo_control_args: tuple[str, ...] = (), + collect_clingo_statistics: bool = False, + clingo_solve_timeout_seconds: float | None = None, +) -> SingleExtensionSolverResult: + """Solve one flat ABA extension witness query.""" + backend = _auto_aba_backend_for_framework( + backend, + semantics, + task="single-extension", + framework=framework, + ) + if backend == "sat": + if not isinstance(framework, ABAFramework): + return _aba_sat_requires_flat_framework() + if ( + semantics in {"preferred", "stable"} + and sparse_narrow_native_sat_shape(framework) + ): + try: + result = native_sparse_narrow_aba_extension(framework, semantics) + except RuntimeError as exc: + return _aba_sat_runtime_unavailable(exc) + return SingleExtensionSolverSuccess( + extension=result.extension, + metadata=result.route_metadata | result.telemetry, + ) + if semantics == "stable": + try: + return SingleExtensionSolverSuccess( + extension=sat_aba_stable_extension(framework), + ) + except RuntimeError as exc: + return _aba_sat_runtime_unavailable(exc) + if semantics in {"complete", "preferred"}: + return SingleExtensionSolverSuccess( + extension=sat_aba_support_extension(framework, semantics), + ) + return _aba_sat_unsupported_semantics(semantics) + if backend in {"asp", "clingo"}: + if not isinstance(framework, ABAFramework): + return _aba_asp_requires_flat_framework(backend) + return _solve_asp_aba_single_extension( + framework, + semantics, + backend, + clingo_control_args=clingo_control_args, + collect_clingo_statistics=collect_clingo_statistics, + clingo_solve_timeout_seconds=clingo_solve_timeout_seconds, + ) + if backend == "native": + extensions = _sorted_object_extensions(_aba_extensions(framework, semantics)) + return SingleExtensionSolverSuccess( + extension=extensions[0] if extensions else None, + ) + if backend == "iccma": + if iccma is None: + return _missing_iccma_config() + return _solve_iccma_aba_single_extension(framework, semantics, iccma) + if backend == "aspforaba": + return _aspforaba_unavailable() + return SolverBackendUnavailable( + backend=backend, + install_hint="Use backend='native'.", + reason=f"unknown backend: {backend!r}", + ) + + +def solve_aba_acceptance( + framework: ABAInput, + *, + semantics: str, + task: str, + query: Literal, + backend: str = "auto", + iccma: ICCMAConfig | None = None, +) -> AcceptanceSolverResult: + """Solve flat ABA credulous or skeptical acceptance queries.""" + if query not in _aba_base(framework).language: + raise ValueError(f"query literal is not in framework language: {query!r}") + backend = _auto_aba_backend_for_framework( + backend, + semantics, + task=task, + framework=framework, + ) + if backend == "sat": + if not isinstance(framework, ABAFramework): + return _aba_sat_requires_flat_framework() + if semantics == "stable": + try: + return _solve_sat_stable_aba_acceptance(framework, task, query) + except RuntimeError as exc: + return _aba_sat_runtime_unavailable(exc) + if semantics in {"complete", "preferred"}: + answer, witness = sat_aba_support_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + ) + return AcceptanceSolverSuccess( + answer=answer, + witness=witness if task == "credulous" and answer else None, + counterexample=witness if task == "skeptical" and not answer else None, + ) + return _aba_sat_unsupported_semantics(semantics) + if backend in {"asp", "clingo"}: + if not isinstance(framework, ABAFramework): + return _aba_asp_requires_flat_framework(backend) + return _solve_asp_aba_acceptance(framework, semantics, task, query, backend) + if backend == "native": + return _solve_native_aba_acceptance(framework, semantics, task, query) + if backend == "iccma": + if iccma is None: + return _missing_iccma_config() + return _solve_iccma_aba_acceptance(framework, semantics, task, query, iccma) + if backend == "aspforaba": + return _aspforaba_unavailable() + return SolverBackendUnavailable( + backend=backend, + install_hint="Use backend='native'.", + reason=f"unknown backend: {backend!r}", + ) + + +def solve_dung_extensions( + framework: ArgumentationFramework, + *, + semantics: str, + backend: str = "auto", + iccma: ICCMAConfig | None = None, + sat: SATConfig | None = None, +) -> ExtensionSolverResult: + """Solve Dung extension queries through a package or external backend.""" + backend = _auto_dung_extension_backend(backend, semantics) + if backend == "iccma": + return SolverBackendUnavailable( + backend=iccma.binary if iccma is not None else "iccma", + install_hint="Use solve_dung_single_extension for ICCMA AF SE tasks.", + reason="ICCMA AF SE tasks return one extension witness, not enumeration", + ) + if backend == "sat": + if sat is not None and sat.require_external: + return _external_sat_unavailable() + return ExtensionSolverSuccess(sat_extensions(framework, semantics)) + if backend == "native": + return ExtensionSolverSuccess( + _sorted_extensions(_dung_extensions(framework, semantics)) + ) + return SolverBackendUnavailable( + backend=backend, + install_hint="Use backend='native'.", + reason=f"unknown backend: {backend!r}", + ) + + +def solve_dung_single_extension( + framework: ArgumentationFramework, + *, + semantics: str, + backend: str = "auto", + iccma: ICCMAConfig | None = None, + sat: SATConfig | None = None, +) -> SingleExtensionSolverResult: + """Solve one Dung extension witness query.""" + backend = _auto_dung_single_backend(backend, semantics) + if backend == "iccma": + if iccma is None: + return _missing_iccma_config() + return _solve_iccma_dung_single_extension(framework, semantics, iccma) + if backend == "native": + extensions = _sorted_extensions(_dung_extensions(framework, semantics)) + return SingleExtensionSolverSuccess( + extension=extensions[0] if extensions else None, + ) + if backend == "sat": + if sat is not None and sat.require_external: + return _external_sat_unavailable() + trace_sink, metadata = _sat_trace(sat) + if semantics == "stable": + try: + return SingleExtensionSolverSuccess( + extension=find_stable_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "complete": + try: + return SingleExtensionSolverSuccess( + extension=find_complete_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "preferred": + try: + return SingleExtensionSolverSuccess( + extension=find_preferred_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "semi-stable": + try: + return SingleExtensionSolverSuccess( + extension=find_semi_stable_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "stage": + try: + return SingleExtensionSolverSuccess( + extension=find_stage_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "ideal": + try: + return SingleExtensionSolverSuccess( + extension=find_ideal_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ), + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + extensions = sat_extensions(framework, semantics) + return SingleExtensionSolverSuccess( + extension=extensions[0] if extensions else None, + ) + return SolverBackendUnavailable( + backend=backend, + install_hint="Use backend='native'.", + reason=f"unknown backend: {backend!r}", + ) + + +def solve_dung_acceptance( + framework: ArgumentationFramework, + *, + semantics: str, + task: str, + query: str, + backend: str = "auto", + iccma: ICCMAConfig | None = None, + sat: SATConfig | None = None, +) -> AcceptanceSolverResult: + """Solve Dung credulous or skeptical acceptance queries.""" + if query not in framework.arguments: + raise ValueError(f"query argument is not in framework: {query!r}") + backend = _auto_dung_acceptance_backend(backend, semantics, task) + if backend == "iccma": + if iccma is None: + return _missing_iccma_config() + return _solve_iccma_dung_acceptance(framework, semantics, task, query, iccma) + if backend == "native": + return _solve_native_dung_acceptance(framework, semantics, task, query) + if backend == "sat": + if sat is not None and sat.require_external: + return _external_sat_unavailable() + trace_sink, metadata = _sat_trace(sat) + if semantics == "stable": + try: + return _solve_sat_stable_acceptance( + framework, + task, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "complete": + try: + return _solve_sat_complete_acceptance( + framework, + task, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "preferred" and task == "credulous": + try: + return _solve_sat_preferred_credulous_acceptance( + framework, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "preferred" and task == "skeptical": + try: + return _solve_sat_preferred_skeptical_acceptance( + framework, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "ideal": + try: + return _solve_sat_ideal_acceptance( + framework, + task, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "semi-stable": + try: + return _solve_sat_semi_stable_acceptance( + framework, + task, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + if semantics == "stage": + try: + return _solve_sat_stage_acceptance( + framework, + task, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + except RuntimeError as exc: + return _sat_runtime_unavailable(exc) + return _solve_dung_acceptance_from_extensions( + sat_extensions(framework, semantics), + task, + query, + ) + return SolverBackendUnavailable( + backend=backend, + install_hint="Use backend='native'.", + reason=f"unknown backend: {backend!r}", + ) + + +def _missing_iccma_config() -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="iccma", + reason="missing ICCMA solver configuration", + install_hint="Pass iccma=ICCMAConfig(binary=...).", + ) + + +def _auto_dung_extension_backend(backend: str, semantics: str) -> str: + if backend == "auto": + return "sat" if semantics in {"complete", "stable"} else "native" + return backend + + +def _auto_dung_single_backend(backend: str, semantics: str) -> str: + if backend == "auto": + return ( + "sat" + if semantics in {"complete", "ideal", "preferred", "semi-stable", "stable", "stage"} + else "native" + ) + return backend + + +def _auto_dung_acceptance_backend(backend: str, semantics: str, task: str) -> str: + if backend == "auto": + if semantics in {"complete", "ideal", "semi-stable", "stable", "stage"}: + return "sat" + if semantics == "preferred" and task in {"credulous", "skeptical"}: + return "sat" + return "native" + return backend + + +def _auto_aba_backend(backend: str, semantics: str, *, task: str) -> str: + if backend == "auto": + if ( + _has_clingo() + and ( + semantics == "grounded" + or ( + semantics == "preferred" + and task in {"single-extension", "skeptical"} + ) + or (semantics == "stable" and task == "single-extension") + ) + ): + return "asp" + return "sat" if semantics in {"complete", "preferred", "stable"} else "native" + return backend + + +def _auto_aba_backend_for_framework( + backend: str, + semantics: str, + *, + task: str, + framework: ABAInput, +) -> str: + if ( + backend == "auto" + and semantics == "preferred" + and task == "single-extension" + and isinstance(framework, ABAFramework) + and sparse_narrow_native_sat_shape(framework) + ): + return "sat" + if ( + backend == "auto" + and semantics == "stable" + and task == "single-extension" + and isinstance(framework, ABAFramework) + and _is_large_dense_flat_aba(framework) + ): + return "sat" + return _auto_aba_backend(backend, semantics, task=task) + + +def _is_large_dense_flat_aba(framework: ABAFramework) -> bool: + assumptions = len(framework.assumptions) + if assumptions <= 150: + return False + if any(rule.consequent in framework.assumptions for rule in framework.rules): + return False + return (len(framework.rules) / assumptions) > 25.0 + + +def _has_clingo() -> bool: + return importlib.util.find_spec("clingo") is not None + + +def _external_sat_unavailable() -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="sat", + reason="external SAT backend is not configured", + install_hint="Use SATConfig(require_external=False) for the package-native SAT enumerator.", + ) + + +def _sat_trace(sat: SATConfig | None) -> tuple[SATTraceSink | None, Mapping[str, object] | None]: + if sat is None: + return None, None + return sat.trace_sink, sat.metadata + + +def _sat_runtime_unavailable(exc: RuntimeError) -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="sat", + reason=str(exc), + install_hint="Install the z3-solver extra or use backend='native'.", + ) + + +def _aba_sat_runtime_unavailable(exc: RuntimeError) -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="sat", + reason=str(exc), + install_hint="Install the z3-solver extra or use backend='native'.", + ) + + +def _aba_sat_requires_flat_framework() -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="sat", + reason="ABA stable SAT backend requires a flat ABAFramework", + install_hint="Use backend='native' for ABAPlusFramework inputs.", + ) + + +def _aba_asp_requires_flat_framework(backend: str) -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend=backend, + reason="ABA ASP backend requires a flat ABAFramework", + install_hint="Use backend='native' for ABAPlusFramework inputs.", + ) + + +def _aba_sat_unsupported_semantics(semantics: str) -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="sat", + reason=f"ABA SAT backend does not support {semantics!r} semantics", + install_hint="Use backend='native' or backend='iccma'.", + ) + + +def _solve_asp_aba_single_extension( + framework: ABAFramework, + semantics: str, + backend: str, + *, + clingo_control_args: tuple[str, ...] = (), + collect_clingo_statistics: bool = False, + clingo_solve_timeout_seconds: float | None = None, +) -> SingleExtensionSolverResult: + from argumentation.structured.aba.aba_asp import solve_aba_with_backend + + result = solve_aba_with_backend( + framework, + backend=backend, + semantics=semantics, + task="single-extension", + clingo_control_args=clingo_control_args, + collect_clingo_statistics=collect_clingo_statistics, + clingo_solve_timeout_seconds=clingo_solve_timeout_seconds, + ) + if result.status == "success": + extension = result.extensions[0] if result.extensions else None + return SingleExtensionSolverSuccess(extension=extension, metadata=dict(result.metadata)) + return _aba_asp_failure(result) + + +def _solve_asp_aba_acceptance( + framework: ABAFramework, + semantics: str, + task: str, + query: Literal, + backend: str, +) -> AcceptanceSolverResult: + from argumentation.structured.aba.aba_asp import solve_aba_with_backend + + result = solve_aba_with_backend( + framework, + backend=backend, + semantics=semantics, + task=task, + query=query, + ) + if result.status == "success" and result.answer is not None: + return AcceptanceSolverSuccess( + answer=result.answer, + witness=result.witness if task == "credulous" and result.answer else None, + counterexample=( + result.counterexample if task == "skeptical" and not result.answer else None + ), + metadata=dict(result.metadata), + ) + return _aba_asp_failure(result) + + +def _aba_asp_failure( + result, +) -> SolverBackendUnavailable | SolverBackendError | SolverBackendTimeout | SolverProtocolError: + reason = result.metadata.get("reason", result.status) + stdout = result.metadata.get("stdout", "") + stderr = result.metadata.get("stderr", "") + problem = f"ABA-{result.semantics.upper()}" + if result.status == "unavailable_backend": + return SolverBackendUnavailable( + backend=result.backend, + reason=reason, + install_hint="Install the clingo Python package or use backend='sat'/'native'.", + ) + if result.status == "backend_error": + return SolverBackendError( + backend=result.backend, + problem=problem, + returncode=1, + stdout=stdout, + stderr=stderr or reason, + ) + if result.status == "timeout": + return SolverBackendTimeout( + backend=result.backend, + problem=problem, + message=reason, + metadata=dict(result.metadata), + ) + return SolverProtocolError( + backend=result.backend, + problem=problem, + message=reason, + stdout=stdout, + stderr=stderr, + ) + + +def _aspforaba_unavailable() -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend="aspforaba", + reason="ASPFORABA invocation contract is not configured", + install_hint="Use backend='native' for flat ABA queries.", + ) + + +def _solve_native_aba_acceptance( + framework: ABAInput, + semantics: str, + task: str, + query: Literal, +) -> AcceptanceSolverSuccess: + extensions = _sorted_object_extensions(_aba_extensions(framework, semantics)) + base = _aba_base(framework) + if task == "credulous": + witness = next( + ( + extension + for extension in extensions + if aba_semantics.derives(base, _literal_extension(extension), query) + ), + None, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = next( + ( + extension + for extension in extensions + if not aba_semantics.derives(base, _literal_extension(extension), query) + ), + None, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported ABA acceptance task: {task}") + + +def _solve_sat_stable_aba_acceptance( + framework: ABAFramework, + task: str, + query: Literal, +) -> AcceptanceSolverSuccess: + if task in {"credulous", "skeptical"}: + answer, witness = sat_aba_stable_acceptance(framework, task=task, query=query) + return AcceptanceSolverSuccess( + answer=answer, + witness=witness if task == "credulous" and answer else None, + counterexample=witness if task == "skeptical" and not answer else None, + ) + raise ValueError(f"unsupported ABA acceptance task: {task}") + + +def _solve_native_dung_acceptance( + framework: ArgumentationFramework, + semantics: str, + task: str, + query: str, +) -> AcceptanceSolverSuccess: + extensions = _sorted_extensions(_dung_extensions(framework, semantics)) + return _solve_dung_acceptance_from_extensions(extensions, task, query) + + +def _solve_sat_stable_acceptance( + framework: ArgumentationFramework, + task: str, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + if task == "credulous": + witness = find_stable_extension( + framework, + require_in=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = find_stable_extension( + framework, + require_out=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_sat_complete_acceptance( + framework: ArgumentationFramework, + task: str, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + if task == "credulous": + witness = find_complete_extension( + framework, + require_in=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = find_complete_extension( + framework, + require_out=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_sat_preferred_credulous_acceptance( + framework: ArgumentationFramework, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + witness = find_preferred_extension( + framework, + require_in=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + + +def _solve_sat_preferred_skeptical_acceptance( + framework: ArgumentationFramework, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + answer = is_preferred_skeptically_accepted( + framework, + query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=answer, + ) + + +def _solve_sat_ideal_acceptance( + framework: ArgumentationFramework, + task: str, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + extension = find_ideal_extension( + framework, + trace_sink=trace_sink, + metadata=metadata, + ) + if task == "credulous": + return AcceptanceSolverSuccess( + answer=query in extension, + witness=extension if query in extension else None, + ) + if task == "skeptical": + return AcceptanceSolverSuccess( + answer=query in extension, + counterexample=None if query in extension else extension, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_sat_semi_stable_acceptance( + framework: ArgumentationFramework, + task: str, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + if task == "credulous": + witness = find_semi_stable_extension( + framework, + require_in=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = find_semi_stable_extension( + framework, + require_out=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_sat_stage_acceptance( + framework: ArgumentationFramework, + task: str, + query: str, + *, + trace_sink: SATTraceSink | None = None, + metadata: Mapping[str, object] | None = None, +) -> AcceptanceSolverSuccess: + if task == "credulous": + witness = find_stage_extension( + framework, + require_in=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = find_stage_extension( + framework, + require_out=query, + trace_sink=trace_sink, + metadata=metadata, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_dung_acceptance_from_extensions( + extensions: tuple[frozenset[str], ...], + task: str, + query: str, +) -> AcceptanceSolverSuccess: + if task == "credulous": + witness = next( + (extension for extension in extensions if query in extension), + None, + ) + return AcceptanceSolverSuccess( + answer=witness is not None, + witness=witness, + ) + if task == "skeptical": + counterexample = next( + (extension for extension in extensions if query not in extension), + None, + ) + return AcceptanceSolverSuccess( + answer=counterexample is None, + counterexample=counterexample, + ) + raise ValueError(f"unsupported Dung acceptance task: {task}") + + +def _solve_iccma_dung_single_extension( + framework: ArgumentationFramework, + semantics: str, + backend: ICCMAConfig, +) -> SingleExtensionSolverResult: + result = iccma_af.solve_af_extensions( + framework=framework, + semantics=semantics, + binary=backend.binary, + timeout_seconds=backend.timeout_seconds, + ) + if isinstance(result, iccma_af.ICCMASolverSuccess): + return SingleExtensionSolverSuccess( + extension=result.witness if not result.output.no_extension else None, + ) + if isinstance(result, iccma_af.ICCMASolverUnavailable): + return result + if isinstance(result, iccma_af.ICCMASolverError): + return result + return result + + +def _solve_iccma_aba_single_extension( + framework: ABAInput, + semantics: str, + backend: ICCMAConfig, +) -> SingleExtensionSolverResult: + if not isinstance(framework, ABAFramework): + return _iccma_aba_requires_flat_framework(backend) + result = iccma_aba.solve_aba_extensions( + framework=framework, + semantics=semantics, + binary=backend.binary, + timeout_seconds=backend.timeout_seconds, + ) + if isinstance(result, iccma_aba.ICCMAABASolverSuccess): + return SingleExtensionSolverSuccess( + extension=result.witness if not result.output.no_extension else None, + ) + if isinstance(result, iccma_aba.ICCMAABASolverUnavailable): + return result + if isinstance(result, iccma_aba.ICCMAABASolverError): + return result + return result + + +def _solve_iccma_dung_acceptance( + framework: ArgumentationFramework, + semantics: str, + task: str, + query: str, + backend: ICCMAConfig, +) -> AcceptanceSolverResult: + result = iccma_af.solve_af_acceptance( + framework=framework, + semantics=semantics, + task=task, + query=query, + binary=backend.binary, + timeout_seconds=backend.timeout_seconds, + certificate_required=True, + ) + if isinstance(result, iccma_af.ICCMASolverSuccess): + return AcceptanceSolverSuccess( + answer=result.answer is True, + witness=result.witness if result.answer is True else None, + counterexample=result.witness if result.answer is False else None, + ) + if isinstance(result, iccma_af.ICCMASolverUnavailable): + return result + if isinstance(result, iccma_af.ICCMASolverError): + return result + return result + + +def _solve_iccma_aba_acceptance( + framework: ABAInput, + semantics: str, + task: str, + query: Literal, + backend: ICCMAConfig, +) -> AcceptanceSolverResult: + if not isinstance(framework, ABAFramework): + return _iccma_aba_requires_flat_framework(backend) + result = iccma_aba.solve_aba_acceptance( + framework=framework, + semantics=semantics, + task=task, + query=query, + binary=backend.binary, + timeout_seconds=backend.timeout_seconds, + ) + if isinstance(result, iccma_aba.ICCMAABASolverSuccess): + return AcceptanceSolverSuccess( + answer=result.answer is True, + ) + if isinstance(result, iccma_aba.ICCMAABASolverUnavailable): + return result + if isinstance(result, iccma_aba.ICCMAABASolverError): + return result + return result + + +def _iccma_aba_requires_flat_framework( + backend: ICCMAConfig, +) -> SolverBackendUnavailable: + return SolverBackendUnavailable( + backend=backend.binary, + reason="ICCMA ABA backend requires a flat ABAFramework", + install_hint="Use backend='native' for ABAPlusFramework inputs.", + ) + + +def _dung_extensions( + framework: ArgumentationFramework, + semantics: str, +) -> list[frozenset[str]]: + if semantics == "grounded": + return [grounded_extension(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, + # faster on layered/many-small-SCC AFs, ~1.0x on a single giant SCC. + if semantics in SCC_RECURSIVE_SEMANTICS: + return scc_extensions(framework, semantics) + if semantics == "complete": + return complete_extensions(framework) + if semantics == "preferred": + return preferred_extensions(framework) + if semantics == "stable": + return stable_extensions(framework) + if semantics == "semi-stable": + return semi_stable_extensions(framework) + if semantics == "stage": + return stage_extensions(framework) + if semantics == "ideal": + return [ideal_extension(framework)] + if semantics == "cf2": + return cf2_extensions(framework) + raise ValueError(f"Unknown Dung semantics: {semantics}") + + +def _adf_models( + framework: AbstractDialecticalFramework, + semantics: str, +) -> tuple[frozenset[object], ...]: + if semantics == "grounded": + return (frozenset(adf_semantics.grounded_interpretation(framework)),) + if semantics == "complete": + return tuple(frozenset(model) for model in adf_semantics.complete_models(framework)) + if semantics == "model": + return tuple(frozenset(model) for model in adf_semantics.model_models(framework)) + if semantics == "preferred": + return tuple(frozenset(model) for model in adf_semantics.preferred_models(framework)) + if semantics == "stable": + return tuple(frozenset(model) for model in adf_semantics.stable_models(framework)) + raise ValueError(f"Unknown ADF semantics: {semantics}") + + +def _setaf_extensions( + framework: SETAF, + semantics: str, +) -> tuple[frozenset[object], ...]: + if semantics == "grounded": + return (frozenset(setaf_semantics.grounded_extension(framework)),) + if semantics == "complete": + return tuple(frozenset(extension) for extension in setaf_semantics.complete_extensions(framework)) + if semantics == "preferred": + return tuple(frozenset(extension) for extension in setaf_semantics.preferred_extensions(framework)) + if semantics == "stable": + return tuple(frozenset(extension) for extension in setaf_semantics.stable_extensions(framework)) + if semantics == "semi-stable": + return tuple(frozenset(extension) for extension in setaf_semantics.semi_stable_extensions(framework)) + if semantics == "stage": + return tuple(frozenset(extension) for extension in setaf_semantics.stage_extensions(framework)) + raise ValueError(f"Unknown SETAF semantics: {semantics}") + + +def _aba_extensions( + framework: ABAInput, + semantics: str, +) -> tuple[frozenset[Literal], ...]: + if semantics == "grounded": + return (aba_semantics.grounded_extension(framework),) + if semantics == "complete": + return aba_semantics.complete_extensions(framework) + if semantics == "preferred": + return aba_semantics.preferred_extensions(framework) + if semantics == "stable": + return aba_semantics.stable_extensions(framework) + if semantics == "well-founded": + return (aba_semantics.well_founded_extension(framework),) + if semantics == "ideal": + return (aba_semantics.ideal_extension(framework),) + raise ValueError(f"Unknown ABA semantics: {semantics}") + + +def _aba_base(framework: ABAInput) -> ABAFramework: + return framework.framework if isinstance(framework, ABAPlusFramework) else framework + + +def _literal_extension(extension: frozenset[object]) -> frozenset[Literal]: + if all(isinstance(item, Literal) for item in extension): + return frozenset(item for item in extension if isinstance(item, Literal)) + raise TypeError("ABA extension contains non-literal members") + + +def _sorted_extensions(values: list[frozenset[str]]) -> tuple[frozenset[str], ...]: + return tuple( + sorted( + values, + key=lambda extension: (len(extension), tuple(sorted(extension))), + ) + ) + + +def _sorted_object_extensions( + values: tuple[frozenset[Literal], ...], +) -> tuple[frozenset[object], ...]: + return tuple( + sorted( + (frozenset(extension) for extension in values), + key=lambda extension: (len(extension), tuple(sorted(map(repr, extension)))), + ) + ) diff --git a/src/argumentation/solver_differential.py b/src/argumentation/solving/solver_differential.py similarity index 99% rename from src/argumentation/solver_differential.py rename to src/argumentation/solving/solver_differential.py index 5c6c8a8..388c5f7 100644 --- a/src/argumentation/solver_differential.py +++ b/src/argumentation/solving/solver_differential.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Literal -from argumentation.solver import ( +from argumentation.solving.solver import ( AcceptanceSolverSuccess, ExtensionSolverResult, ExtensionSolverSuccess, diff --git a/src/argumentation/structured/__init__.py b/src/argumentation/structured/__init__.py new file mode 100644 index 0000000..c73b357 --- /dev/null +++ b/src/argumentation/structured/__init__.py @@ -0,0 +1 @@ +"""Structured layer: rule-based argumentation (ASPIC+ and ABA).""" diff --git a/src/argumentation/structured/aba/__init__.py b/src/argumentation/structured/aba/__init__.py new file mode 100644 index 0000000..9d8cba6 --- /dev/null +++ b/src/argumentation/structured/aba/__init__.py @@ -0,0 +1 @@ +"""Assumption-based argumentation (ABA) layer.""" diff --git a/src/argumentation/aba.py b/src/argumentation/structured/aba/aba.py similarity index 99% rename from src/argumentation/aba.py rename to src/argumentation/structured/aba/aba.py index 009c8a7..5db440f 100644 --- a/src/argumentation/aba.py +++ b/src/argumentation/structured/aba/aba.py @@ -17,8 +17,8 @@ from itertools import chain, combinations from typing import Mapping, TypeAlias -from argumentation.aspic import Literal, Rule -from argumentation.dung import ArgumentationFramework +from argumentation.structured.aspic.aspic import Literal, Rule +from argumentation.core.dung import ArgumentationFramework AssumptionSet: TypeAlias = frozenset[Literal] diff --git a/src/argumentation/aba_asp.py b/src/argumentation/structured/aba/aba_asp.py similarity index 97% rename from src/argumentation/aba_asp.py rename to src/argumentation/structured/aba/aba_asp.py index 9a42b68..8f74b53 100644 --- a/src/argumentation/aba_asp.py +++ b/src/argumentation/structured/aba/aba_asp.py @@ -7,11 +7,11 @@ from dataclasses import dataclass, field from typing import Any -from argumentation import aba as aba_semantics -from argumentation.aba import ABAFramework, ABAPlusFramework, AssumptionSet, derives -from argumentation.aba_preprocessing import GROUNDED_REDUCT_ABA_SEMANTICS -from argumentation.aba_sat import _minimal_supports, support_extensions -from argumentation.aspic import Literal +from argumentation.structured.aba import aba as aba_semantics +from argumentation.structured.aba.aba import ABAFramework, ABAPlusFramework, AssumptionSet, derives +from argumentation.structured.aba.aba_preprocessing import GROUNDED_REDUCT_ABA_SEMANTICS +from argumentation.structured.aba.aba_sat import _minimal_supports, support_extensions +from argumentation.structured.aspic.aspic import Literal @dataclass(frozen=True) @@ -112,7 +112,7 @@ def solve_aba_with_backend( and isinstance(framework, ABAFramework) and semantics in GROUNDED_REDUCT_ABA_SEMANTICS ): - from argumentation.aba_preprocessing import simplify_aba + from argumentation.structured.aba.aba_preprocessing import simplify_aba simplification = simplify_aba(framework, semantics=semantics) if not simplification.is_trivial: @@ -256,7 +256,7 @@ def _solve_multishot( single grounded solve for complete/stable) plus the shared task projection for the other ABA queries. """ - from argumentation import aba_incremental + from argumentation.structured.aba import aba_incremental metadata_base = { "encoding": encoding.metadata["encoding"], @@ -487,8 +487,8 @@ def _solve_simplified_ds_pr( drops ``fixed_out``-using rules), so run Algorithm 1 on the residual and lift the counterexample. """ - from argumentation import aba as _aba - from argumentation import aba_incremental + from argumentation.structured.aba import aba as _aba + from argumentation.structured.aba import aba_incremental original = simplification.original residual = simplification.residual diff --git a/src/argumentation/aba_decomposition.py b/src/argumentation/structured/aba/aba_decomposition.py similarity index 81% rename from src/argumentation/aba_decomposition.py rename to src/argumentation/structured/aba/aba_decomposition.py index 2faaaee..3da170f 100644 --- a/src/argumentation/aba_decomposition.py +++ b/src/argumentation/structured/aba/aba_decomposition.py @@ -7,9 +7,9 @@ from dataclasses import dataclass from typing import Any -from argumentation.aba import ABAFramework, AssumptionSet -from argumentation.aba_preprocessing import simplify_aba -from argumentation.aspic import Literal, Rule +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet +from argumentation.structured.aba.aba_preprocessing import simplify_aba +from argumentation.structured.aspic.aspic import Literal, Rule @dataclass(frozen=True) @@ -32,7 +32,7 @@ class AbaDecompositionPlan: @dataclass(frozen=True) class AbaDecomposedPrefSatResult: - extension: AssumptionSet + extension: AssumptionSet | None telemetry: dict[str, Any] component_results: tuple[Any, ...] = () @@ -85,19 +85,37 @@ def decomposed_prefsat_extension( *, require_assumptions: AssumptionSet = frozenset(), ) -> AbaDecomposedPrefSatResult: - from argumentation import aba_sat + from argumentation.structured.aba import aba_sat simplification = simplify_aba(framework, semantics="preferred") residual = simplification.residual - residual_required = frozenset(require_assumptions - simplification.fixed_in) plan = plan_decomposed_prefsat(residual) telemetry = _base_telemetry(framework, plan) + if require_assumptions & simplification.fixed_out: + # A required assumption forced OUT by preprocessing (its contrary is + # forward-derivable from the grounded set alone) cannot appear in any + # preferred extension: every preferred set is conflict-free (Bondarenko + # et al. 1997, Def. 2.2, p.70) and contains the well-founded set (Thm. + # 6.4, p.90), whose theory derives that assumption's contrary -- so + # including it would break conflict-freeness. The query is therefore + # unsatisfiable. (Were this assumption left in residual_required, it + # would leak into the residual solver, which has no SAT variable for it + # -> KeyError.) + telemetry["decomp_validation_success"] = 0 + telemetry["decomp_lifted_extension_size"] = 0 + return AbaDecomposedPrefSatResult(extension=None, telemetry=telemetry) + + residual_required = frozenset(require_assumptions - simplification.fixed_in) + if plan.no_reduction_reason == "empty_residual": extension = simplification.lift(frozenset()) - telemetry["decomp_validation_success"] = _validation_success(framework, extension) - telemetry["decomp_lifted_extension_size"] = len(extension) - return AbaDecomposedPrefSatResult(extension=extension, telemetry=telemetry) + return _decomposed_result( + framework, + require_assumptions, + extension, + telemetry, + ) if plan.no_reduction_reason != "reduced": prefsat = ( @@ -112,11 +130,11 @@ def decomposed_prefsat_extension( extension = simplification.lift(result.extension) telemetry["decomp_full_instance_prefsat_calls"] = 1 telemetry["decomp_solver_checks"] = _solver_checks(result.telemetry) - telemetry["decomp_validation_success"] = _validation_success(framework, extension) - telemetry["decomp_lifted_extension_size"] = len(extension) - return AbaDecomposedPrefSatResult( - extension=extension, - telemetry=telemetry, + return _decomposed_result( + framework, + require_assumptions, + extension, + telemetry, component_results=(result,), ) @@ -136,12 +154,37 @@ def decomposed_prefsat_extension( extension = simplification.lift(residual_extension) telemetry["decomp_prefsat_component_calls"] = len(component_results) telemetry["decomp_solver_checks"] = solver_checks + return _decomposed_result( + framework, + require_assumptions, + extension, + telemetry, + component_results=tuple(component_results), + ) + + +def _decomposed_result( + framework: ABAFramework, + require_assumptions: AssumptionSet, + extension: AssumptionSet, + telemetry: dict[str, Any], + *, + component_results: tuple[Any, ...] = (), +) -> AbaDecomposedPrefSatResult: + if not require_assumptions <= extension: + telemetry["decomp_validation_success"] = 0 + telemetry["decomp_lifted_extension_size"] = 0 + return AbaDecomposedPrefSatResult( + extension=None, + telemetry=telemetry, + component_results=component_results, + ) telemetry["decomp_validation_success"] = _validation_success(framework, extension) telemetry["decomp_lifted_extension_size"] = len(extension) return AbaDecomposedPrefSatResult( extension=extension, telemetry=telemetry, - component_results=tuple(component_results), + component_results=component_results, ) @@ -274,7 +317,7 @@ def _solver_checks(telemetry: dict[str, Any]) -> int: def _validation_success(framework: ABAFramework, extension: AssumptionSet) -> int: - from argumentation import aba_sat + from argumentation.structured.aba import aba_sat if len(framework.assumptions) > 12: return 1 diff --git a/src/argumentation/aba_incremental.py b/src/argumentation/structured/aba/aba_incremental.py similarity index 98% rename from src/argumentation/aba_incremental.py rename to src/argumentation/structured/aba/aba_incremental.py index 5a5f89d..a7719dc 100644 --- a/src/argumentation/aba_incremental.py +++ b/src/argumentation/structured/aba/aba_incremental.py @@ -11,15 +11,15 @@ * a fresh ``#program`` part re-grounded per refinement for the *permanent* ``constr(out(I)) = :- out(a1), ..., out(ak).`` accumulation -- this is the abstraction-refinement clause of Algorithm 1, the clingo analog of the Z3 - ``solver.add`` in :mod:`argumentation.aba_sat`. + ``solver.add`` in :mod:`argumentation.structured.aba.aba_sat`. This module *replaces* the enumerate-then-filter subprocess clingo path in -:mod:`argumentation.aba_asp` for the ``asp`` / ``clingo`` backends on flat ABA +:mod:`argumentation.structured.aba.aba_asp` for the ``asp`` / ``clingo`` backends on flat ABA with ``complete`` / ``stable`` / ``preferred`` / ``grounded`` semantics. The old subprocess path is still reachable as ``backend="clingo_subprocess"`` (oracle). ``admissible`` keeps the subprocess path (no ``pi_com``-style module for it). -Composes under :func:`argumentation.aba_preprocessing.simplify_aba`: the caller +Composes under :func:`argumentation.structured.aba.aba_preprocessing.simplify_aba`: the caller (``solve_aba_with_backend``) runs ``simplify_aba`` first and feeds this solver the residual; lifting back is the caller's job. """ @@ -33,10 +33,10 @@ from importlib import resources from typing import Any -from argumentation.aba import ABAFramework, AssumptionSet -from argumentation.aba_asp import ABAEncoding, encode_aba_theory -from argumentation.aba_preprocessing import grounded_assumption_set_via_supports -from argumentation.aspic import Literal +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet +from argumentation.structured.aba.aba_asp import ABAEncoding, encode_aba_theory +from argumentation.structured.aba.aba_preprocessing import grounded_assumption_set_via_supports +from argumentation.structured.aspic.aspic import Literal _COM_MODULE_RESOURCE = "aba_com_incremental.lp" SUPPORTED_SEMANTICS = frozenset({"complete", "stable", "preferred", "grounded"}) diff --git a/src/argumentation/aba_preprocessing.py b/src/argumentation/structured/aba/aba_preprocessing.py similarity index 97% rename from src/argumentation/aba_preprocessing.py rename to src/argumentation/structured/aba/aba_preprocessing.py index fd4a8bc..b130d48 100644 --- a/src/argumentation/aba_preprocessing.py +++ b/src/argumentation/structured/aba/aba_preprocessing.py @@ -1,7 +1,7 @@ """Semantics-preserving preprocessing for flat ABA frameworks. Wave C2a of the graph-theory speedup workstream: the ABA analog of -:mod:`argumentation.preprocessing`'s Dung grounded reduct. Before a flat ABA +:mod:`argumentation.core.preprocessing`'s Dung grounded reduct. Before a flat ABA framework is handed to the Z3 SAT path or the clingo ASP path, fix the part of the answer that is polynomially computable -- the well-founded (grounded) assumption set and the assumptions whose contrary it already derives -- then @@ -51,13 +51,13 @@ from dataclasses import dataclass from itertools import chain -from argumentation.aba import ( +from argumentation.structured.aba.aba import ( ABAFramework, ABAInput, ABAPlusFramework, AssumptionSet, ) -from argumentation.aspic import Literal, Rule +from argumentation.structured.aspic.aspic import Literal, Rule # Semantics for which the grounded ABA reduct is semantics-preserving. @@ -122,7 +122,7 @@ def grounded_assumption_set_via_supports(framework: ABAFramework) -> AssumptionS against it, rather than recomputing attack relations inside every ``defends`` check. """ - from argumentation.aba_sat import _SupportState + from argumentation.structured.aba.aba_sat import _SupportState state = _SupportState.from_framework(framework) n = len(state.assumptions) @@ -162,7 +162,7 @@ def grounded_assumption_set_via_supports(framework: ABAFramework) -> AssumptionS def _forward_closure(framework: ABAFramework, premises: AssumptionSet) -> frozenset[Literal]: - from argumentation.aba import _closure + from argumentation.structured.aba.aba import _closure return _closure(framework, premises) diff --git a/src/argumentation/aba_route_policy.py b/src/argumentation/structured/aba/aba_route_policy.py similarity index 100% rename from src/argumentation/aba_route_policy.py rename to src/argumentation/structured/aba/aba_route_policy.py diff --git a/src/argumentation/aba_sat.py b/src/argumentation/structured/aba/aba_sat.py similarity index 99% rename from src/argumentation/aba_sat.py rename to src/argumentation/structured/aba/aba_sat.py index 1373c5d..c852123 100644 --- a/src/argumentation/aba_sat.py +++ b/src/argumentation/structured/aba/aba_sat.py @@ -8,12 +8,12 @@ import time from typing import Any -from argumentation.aba import ABAFramework, AssumptionSet, derives -from argumentation.aba_route_policy import ( +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet, derives +from argumentation.structured.aba.aba_route_policy import ( SPARSE_NARROW_NATIVE_SAT_PAGE_IMAGES, native_cnf_prefsat_dense_shape, ) -from argumentation.aspic import Literal, Rule +from argumentation.structured.aspic.aspic import Literal, Rule @dataclass(frozen=True) @@ -43,7 +43,7 @@ def _positive_solver_model(solver: Any) -> frozenset[int]: def _aba_simplification(framework: ABAFramework, semantics: str): """Lazily import to avoid a module import cycle (aba_preprocessing -> aba_sat).""" - from argumentation.aba_preprocessing import simplify_aba + from argumentation.structured.aba.aba_preprocessing import simplify_aba return simplify_aba(framework, semantics=semantics) @@ -547,14 +547,16 @@ def sat_support_extension( f"excluded literal is not in framework language: {require_not_derived!r}" ) if semantics == "preferred" and require_derived is None and require_not_derived is None: - from argumentation.aba_decomposition import decomposed_prefsat_extension + from argumentation.structured.aba.aba_decomposition import decomposed_prefsat_extension decomposed = decomposed_prefsat_extension( framework, require_assumptions=require_assumptions, - ).extension - if require_assumptions <= decomposed: - return decomposed + ) + if decomposed.extension is None: + return None + if require_assumptions <= decomposed.extension: + return decomposed.extension return None if semantics == "preferred" and ( require_derived is not None or require_not_derived is not None diff --git a/src/argumentation/aba_telemetry.py b/src/argumentation/structured/aba/aba_telemetry.py similarity index 98% rename from src/argumentation/aba_telemetry.py rename to src/argumentation/structured/aba/aba_telemetry.py index cb36ac9..514499d 100644 --- a/src/argumentation/aba_telemetry.py +++ b/src/argumentation/structured/aba/aba_telemetry.py @@ -5,8 +5,8 @@ from collections import Counter, defaultdict, deque from typing import Iterable -from argumentation.aba import ABAFramework -from argumentation.aspic import Literal +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import Literal STRUCTURAL_TELEMETRY_KEYS = frozenset( diff --git a/src/argumentation/structured/aspic/__init__.py b/src/argumentation/structured/aspic/__init__.py new file mode 100644 index 0000000..68e2276 --- /dev/null +++ b/src/argumentation/structured/aspic/__init__.py @@ -0,0 +1 @@ +"""ASPIC+ structured argumentation layer.""" diff --git a/src/argumentation/aspic.py b/src/argumentation/structured/aspic/aspic.py similarity index 99% rename from src/argumentation/aspic.py rename to src/argumentation/structured/aspic/aspic.py index 48b8190..5bb7c89 100644 --- a/src/argumentation/aspic.py +++ b/src/argumentation/structured/aspic/aspic.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, TypeAlias, TypeVar, Union if TYPE_CHECKING: - from argumentation.dung import ArgumentationFramework + from argumentation.core.dung import ArgumentationFramework Scalar: TypeAlias = str | int | float | bool @@ -1325,7 +1325,7 @@ def build_abstract_framework( id_prefix: str = "arg", ) -> ASPICAbstractProjection: """Build arguments, attacks, defeats, and the derived Dung AF together.""" - from argumentation.dung import ArgumentationFramework + from argumentation.core.dung import ArgumentationFramework arguments = build_arguments(system, kb) attacks = compute_attacks(arguments, system) diff --git a/src/argumentation/aspic_encoding.py b/src/argumentation/structured/aspic/aspic_encoding.py similarity index 99% rename from src/argumentation/aspic_encoding.py rename to src/argumentation/structured/aspic/aspic_encoding.py index b22e9ee..8bb1529 100644 --- a/src/argumentation/aspic_encoding.py +++ b/src/argumentation/structured/aspic/aspic_encoding.py @@ -6,7 +6,7 @@ import re from dataclasses import dataclass, field -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, KnowledgeBase, Literal, @@ -131,7 +131,7 @@ def solve_aspic_grounded( materialized ASPIC-to-Dung reference path; optional ASP/clingo backends can attach to the same encoding/result contract in later slices. """ - from argumentation.dung import grounded_extension + from argumentation.core.dung import grounded_extension encoding = encode_aspic_theory(system, kb, pref) projection = build_abstract_framework(system, kb, pref) @@ -269,7 +269,7 @@ def solve_aspic_with_backend( # AF preprocessing (Wave A): shrink the projected Dung framework with the # grounded reduct + self-loop-sink removal before handing it to clingo, # then lift every returned extension back to the full argument set. - from argumentation.preprocessing import simplify_af + from argumentation.core.preprocessing import simplify_af simplification = simplify_af(projection.framework, semantics=semantics) residual_framework = simplification.residual @@ -392,7 +392,7 @@ def _backend_failure_result( def _materialized_extensions(framework, semantics: str) -> tuple[frozenset[str], ...]: - from argumentation import dung + from argumentation.core import dung if semantics == "grounded": return (dung.grounded_extension(framework),) diff --git a/src/argumentation/aspic_incomplete.py b/src/argumentation/structured/aspic/aspic_incomplete.py similarity index 96% rename from src/argumentation/aspic_incomplete.py rename to src/argumentation/structured/aspic/aspic_incomplete.py index 0fd88c4..e557df2 100644 --- a/src/argumentation/aspic_incomplete.py +++ b/src/argumentation/structured/aspic/aspic_incomplete.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from itertools import combinations -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, KnowledgeBase, Literal, @@ -13,7 +13,7 @@ build_arguments, conc, ) -from argumentation.aspic_encoding import solve_aspic_grounded +from argumentation.structured.aspic.aspic_encoding import solve_aspic_grounded @dataclass(frozen=True) diff --git a/src/argumentation/datalog_grounding.py b/src/argumentation/structured/aspic/datalog_grounding.py similarity index 96% rename from src/argumentation/datalog_grounding.py rename to src/argumentation/structured/aspic/datalog_grounding.py index 843bc1f..3407021 100644 --- a/src/argumentation/datalog_grounding.py +++ b/src/argumentation/structured/aspic/datalog_grounding.py @@ -1,392 +1,392 @@ -"""Ground Gunray defeasible-Datalog theories into propositional ASPIC+. - -This module is intentionally a consumer of Gunray's public -``DefeasibleTheory`` and ``inspect_grounding`` surfaces. Propstore already -owns authored predicate/rule documents and translates them to Gunray; this -module avoids adding a second document schema in ``argumentation``. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal as TypingLiteral - -from argumentation.aspic import ( - ArgumentationSystem, - ContrarinessFn, - GroundAtom, - KnowledgeBase, - Literal, - PreferenceConfig, - Rule, - Scalar, -) -from argumentation.preference import strict_partial_order_closure - -if TYPE_CHECKING: - from gunray import DefeasibleTheory, GroundingInspection, GroundRuleInstance - - -@dataclass(frozen=True) -class GroundRuleOrigin: - """Structured origin for a generated grounded ASPIC+ rule.""" - - source_rule_id: str - substitution: tuple[tuple[str, Scalar], ...] - role: TypingLiteral["ground", "undercut"] - target_rule: Rule | None = None - - -@dataclass(frozen=True) -class GroundedDatalogTheory: - """ASPIC+ projection of a grounded Gunray defeasible theory.""" - - system: ArgumentationSystem - kb: KnowledgeBase - pref: PreferenceConfig - inspection: "GroundingInspection" - source_to_ground_rules: Mapping[str, frozenset[Rule]] - rule_origins: Mapping[Rule, GroundRuleOrigin] - non_approximated_predicates: frozenset[str] - - -def ground_defeasible_theory( - theory: "DefeasibleTheory", - *, - comparison: str = "elitist", - link: str = "last", - simplify: bool = True, -) -> GroundedDatalogTheory: - """Ground a Gunray ``DefeasibleTheory`` into ASPIC+ objects. - - The returned ``ArgumentationSystem``, ``KnowledgeBase``, and - ``PreferenceConfig`` are ordinary propositional ASPIC+ structures and can - be passed to ``build_arguments`` or ``build_abstract_framework``. - Strong-negated Gunray predicates such as ``~flies("opus")`` are normalized - to ASPIC literals ``Literal(GroundAtom("flies", ("opus",)), negated=True)`` - rather than being treated as predicates literally named ``"~flies"``. - """ - - gunray = _require_gunray() - inspection = gunray.inspect_grounding(theory) - return grounding_inspection_to_aspic( - inspection, - superiority=theory.superiority, - conflicts=theory.conflicts, - comparison=comparison, - link=link, - simplify=simplify, - ) - - -def grounding_inspection_to_aspic( - inspection: "GroundingInspection", - *, - superiority: tuple[tuple[str, str], ...] = (), - conflicts: tuple[tuple[str, str], ...] = (), - comparison: str = "elitist", - link: str = "last", - simplify: bool = True, -) -> GroundedDatalogTheory: - """Project a Gunray ``GroundingInspection`` into ASPIC+ objects. - - This is the direct integration point for callers that already ran Gunray - and kept the inspection report, such as propstore's ``GroundedRulesBundle``. - """ - - simplification = inspection.simplification - - if simplify: - fact_atoms = simplification.definite_fact_atoms - strict_instances = simplification.strict_rules_for_argumentation - defeasible_instances = simplification.defeasible_rules_for_argumentation - defeater_instances = simplification.defeater_rules_for_argumentation - non_approximated = frozenset(simplification.non_approximated_predicates) - else: - fact_atoms = inspection.fact_atoms - strict_instances = inspection.strict_rules - defeasible_instances = inspection.defeasible_rules - defeater_instances = inspection.defeater_rules - non_approximated = frozenset() - - axioms = frozenset(_literal_from_ground_atom(atom) for atom in fact_atoms) - rule_origins: dict[Rule, GroundRuleOrigin] = {} - strict_rules = tuple( - _rule_from_instance( - instance, - kind="strict", - name=None, - origins=rule_origins, - ) - for instance in strict_instances - ) - defeasible_rules = [] - for index, instance in enumerate(defeasible_instances): - defeasible_rules.append( - _rule_from_instance( - instance, - kind="defeasible", - name=f"gr{index}", - origins=rule_origins, - ) - ) - defeasible_rules.extend( - _undercut_rules_from_defeaters( - defeater_instances, - defeasible_rules, - rule_origins, - ) - ) - - source_to_ground = _source_to_ground_rules(rule_origins) - pref = PreferenceConfig( - rule_order=_project_rule_order(superiority, source_to_ground), - premise_order=frozenset(), - comparison=comparison, - link=link, - ) - kb = KnowledgeBase(axioms=axioms, premises=frozenset()) - - language = _language_from_parts(axioms, strict_rules, tuple(defeasible_rules)) - contrariness = _contrariness_from_language(language, conflicts) - system = ArgumentationSystem( - language=language, - contrariness=contrariness, - strict_rules=frozenset(strict_rules), - defeasible_rules=frozenset(defeasible_rules), - ) - return GroundedDatalogTheory( - system=system, - kb=kb, - pref=pref, - inspection=inspection, - source_to_ground_rules=source_to_ground, - rule_origins=rule_origins, - non_approximated_predicates=non_approximated, - ) - - -def _require_gunray() -> Any: - try: - import gunray - except ImportError as exc: - raise ImportError( - "argumentation Datalog grounding requires the [grounding] extra: " - "install formal-argumentation[grounding]" - ) from exc - return gunray - - -def _literal_from_ground_atom(atom: Any) -> Literal: - predicate = str(atom.predicate) - negated = predicate.startswith("~") - if negated: - predicate = predicate[1:] - return Literal( - atom=GroundAtom(predicate=predicate, arguments=tuple(atom.arguments)), - negated=negated, - ) - - -def _rule_from_instance( - instance: "GroundRuleInstance", - *, - kind: str, - name: str | None, - origins: dict[Rule, GroundRuleOrigin], -) -> Rule: - if getattr(instance, "default_negated_body", ()): - raise ValueError( - "ASPIC+ grounding does not accept default-negated rule bodies" - ) - rule = Rule( - antecedents=tuple(_literal_from_ground_atom(atom) for atom in instance.body), - consequent=_literal_from_ground_atom(instance.head), - kind=kind, - name=name, - ) - origins[rule] = GroundRuleOrigin( - source_rule_id=instance.rule_id, - substitution=tuple( - (name, value) - for name, value in instance.substitution - ), - role="ground", - ) - return rule - - -def _undercut_rules_from_defeaters( - defeater_instances: tuple["GroundRuleInstance", ...], - target_rules: list[Rule], - origins: dict[Rule, GroundRuleOrigin], -) -> tuple[Rule, ...]: - undercut_rules: list[Rule] = [] - for instance in defeater_instances: - if getattr(instance, "default_negated_body", ()): - raise ValueError( - "ASPIC+ grounding does not accept default-negated defeater bodies" - ) - defeater_head = _literal_from_ground_atom(instance.head) - antecedents = tuple( - _literal_from_ground_atom(atom) - for atom in instance.body - ) - defeater_targets = _defeater_targets(defeater_head, target_rules, origins) - for target_rule in defeater_targets: - if target_rule.name is None: - continue - rule = Rule( - antecedents=antecedents, - consequent=Literal(GroundAtom(target_rule.name), negated=True), - kind="defeasible", - name=f"uc{len(undercut_rules)}", - ) - origins[rule] = GroundRuleOrigin( - source_rule_id=instance.rule_id, - substitution=tuple( - (name, value) - for name, value in instance.substitution - ), - role="undercut", - target_rule=target_rule, - ) - undercut_rules.append(rule) - return tuple(undercut_rules) - - -def _defeater_targets( - defeater_head: Literal, - rules: list[Rule], - origins: Mapping[Rule, GroundRuleOrigin], -) -> tuple[Rule, ...]: - if defeater_head.negated: - source_id_targets = tuple( - rule - for rule in rules - if rule.name is not None - and origins[rule].source_rule_id == defeater_head.atom.predicate - ) - if source_id_targets: - return source_id_targets - return tuple( - rule - for rule in rules - if rule.name is not None and rule.consequent == defeater_head.contrary - ) - - -def _source_to_ground_rules( - origins: Mapping[Rule, GroundRuleOrigin], -) -> Mapping[str, frozenset[Rule]]: - grouped: dict[str, set[Rule]] = {} - for rule, origin in origins.items(): - if origin.role != "ground": - continue - grouped.setdefault(origin.source_rule_id, set()).add(rule) - return { - source_id: frozenset(rules) - for source_id, rules in grouped.items() - } - - -def _project_rule_order( - superiority: tuple[tuple[str, str], ...], - source_to_ground: Mapping[str, frozenset[Rule]], -) -> frozenset[tuple[Rule, Rule]]: - projected: set[tuple[Rule, Rule]] = set() - for superior_id, inferior_id in superiority: - stronger_rules = source_to_ground.get(superior_id, frozenset()) - weaker_rules = source_to_ground.get(inferior_id, frozenset()) - for weaker in weaker_rules: - for stronger in stronger_rules: - if weaker != stronger: - projected.add((weaker, stronger)) - return strict_partial_order_closure(projected) - - -def _language_from_parts( - axioms: frozenset[Literal], - strict_rules: tuple[Rule, ...], - defeasible_rules: tuple[Rule, ...], -) -> frozenset[Literal]: - language: set[Literal] = set(axioms) - for rule in (*strict_rules, *defeasible_rules): - language.add(rule.consequent) - language.update(rule.antecedents) - if rule.name is not None: - language.add(Literal(GroundAtom(rule.name))) - language.add(Literal(GroundAtom(rule.name), negated=True)) - - closed_language = set(language) - for literal in language: - closed_language.add(literal.contrary) - return frozenset(closed_language) - - -def _contrariness_from_language( - language: frozenset[Literal], - conflicts: tuple[tuple[str, str], ...], -) -> ContrarinessFn: - contradictories = { - (literal, literal.contrary) - for literal in language - if literal.contrary in language and not literal.negated - } - contraries: set[tuple[Literal, Literal]] = set() - - by_shape: dict[tuple[str, tuple[Scalar, ...]], set[Literal]] = {} - for literal in language: - key = (literal.atom.predicate, tuple(literal.atom.arguments)) - by_shape.setdefault(key, set()).add(literal) - - for left_predicate, right_predicate in conflicts: - for left, right in _conflict_literals(left_predicate, right_predicate, by_shape): - if left == right: - continue - if left.contrary == right or right.contrary == left: - contradictories.add((left, right)) - else: - contraries.add((left, right)) - contraries.add((right, left)) - - return ContrarinessFn( - contradictories=frozenset(contradictories), - contraries=frozenset(contraries), - ) - - -def _conflict_literals( - left_predicate: str, - right_predicate: str, - by_shape: Mapping[tuple[str, tuple[Scalar, ...]], set[Literal]], -) -> tuple[tuple[Literal, Literal], ...]: - left_name, left_negated = _decode_predicate_polarity(left_predicate) - right_name, right_negated = _decode_predicate_polarity(right_predicate) - - keys = { - args - for predicate, args in by_shape - if predicate in {left_name, right_name} - } - pairs: list[tuple[Literal, Literal]] = [] - for args in keys: - left = Literal(GroundAtom(left_name, args), left_negated) - right = Literal(GroundAtom(right_name, args), right_negated) - pairs.append((left, right)) - return tuple(pairs) - - -def _decode_predicate_polarity(predicate: str) -> tuple[str, bool]: - if predicate.startswith("~"): - return predicate[1:], True - return predicate, False - - -__all__ = [ - "GroundedDatalogTheory", - "GroundRuleOrigin", - "ground_defeasible_theory", - "grounding_inspection_to_aspic", -] +"""Ground Gunray defeasible-Datalog theories into propositional ASPIC+. + +This module is intentionally a consumer of Gunray's public +``DefeasibleTheory`` and ``inspect_grounding`` surfaces. Propstore already +owns authored predicate/rule documents and translates them to Gunray; this +module avoids adding a second document schema in ``argumentation``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal as TypingLiteral + +from argumentation.structured.aspic.aspic import ( + ArgumentationSystem, + ContrarinessFn, + GroundAtom, + KnowledgeBase, + Literal, + PreferenceConfig, + Rule, + Scalar, +) +from argumentation.core.preference import strict_partial_order_closure + +if TYPE_CHECKING: + from gunray import DefeasibleTheory, GroundingInspection, GroundRuleInstance + + +@dataclass(frozen=True) +class GroundRuleOrigin: + """Structured origin for a generated grounded ASPIC+ rule.""" + + source_rule_id: str + substitution: tuple[tuple[str, Scalar], ...] + role: TypingLiteral["ground", "undercut"] + target_rule: Rule | None = None + + +@dataclass(frozen=True) +class GroundedDatalogTheory: + """ASPIC+ projection of a grounded Gunray defeasible theory.""" + + system: ArgumentationSystem + kb: KnowledgeBase + pref: PreferenceConfig + inspection: "GroundingInspection" + source_to_ground_rules: Mapping[str, frozenset[Rule]] + rule_origins: Mapping[Rule, GroundRuleOrigin] + non_approximated_predicates: frozenset[str] + + +def ground_defeasible_theory( + theory: "DefeasibleTheory", + *, + comparison: str = "elitist", + link: str = "last", + simplify: bool = True, +) -> GroundedDatalogTheory: + """Ground a Gunray ``DefeasibleTheory`` into ASPIC+ objects. + + The returned ``ArgumentationSystem``, ``KnowledgeBase``, and + ``PreferenceConfig`` are ordinary propositional ASPIC+ structures and can + be passed to ``build_arguments`` or ``build_abstract_framework``. + Strong-negated Gunray predicates such as ``~flies("opus")`` are normalized + to ASPIC literals ``Literal(GroundAtom("flies", ("opus",)), negated=True)`` + rather than being treated as predicates literally named ``"~flies"``. + """ + + gunray = _require_gunray() + inspection = gunray.inspect_grounding(theory) + return grounding_inspection_to_aspic( + inspection, + superiority=theory.superiority, + conflicts=theory.conflicts, + comparison=comparison, + link=link, + simplify=simplify, + ) + + +def grounding_inspection_to_aspic( + inspection: "GroundingInspection", + *, + superiority: tuple[tuple[str, str], ...] = (), + conflicts: tuple[tuple[str, str], ...] = (), + comparison: str = "elitist", + link: str = "last", + simplify: bool = True, +) -> GroundedDatalogTheory: + """Project a Gunray ``GroundingInspection`` into ASPIC+ objects. + + This is the direct integration point for callers that already ran Gunray + and kept the inspection report, such as propstore's ``GroundedRulesBundle``. + """ + + simplification = inspection.simplification + + if simplify: + fact_atoms = simplification.definite_fact_atoms + strict_instances = simplification.strict_rules_for_argumentation + defeasible_instances = simplification.defeasible_rules_for_argumentation + defeater_instances = simplification.defeater_rules_for_argumentation + non_approximated = frozenset(simplification.non_approximated_predicates) + else: + fact_atoms = inspection.fact_atoms + strict_instances = inspection.strict_rules + defeasible_instances = inspection.defeasible_rules + defeater_instances = inspection.defeater_rules + non_approximated = frozenset() + + axioms = frozenset(_literal_from_ground_atom(atom) for atom in fact_atoms) + rule_origins: dict[Rule, GroundRuleOrigin] = {} + strict_rules = tuple( + _rule_from_instance( + instance, + kind="strict", + name=None, + origins=rule_origins, + ) + for instance in strict_instances + ) + defeasible_rules = [] + for index, instance in enumerate(defeasible_instances): + defeasible_rules.append( + _rule_from_instance( + instance, + kind="defeasible", + name=f"gr{index}", + origins=rule_origins, + ) + ) + defeasible_rules.extend( + _undercut_rules_from_defeaters( + defeater_instances, + defeasible_rules, + rule_origins, + ) + ) + + source_to_ground = _source_to_ground_rules(rule_origins) + pref = PreferenceConfig( + rule_order=_project_rule_order(superiority, source_to_ground), + premise_order=frozenset(), + comparison=comparison, + link=link, + ) + kb = KnowledgeBase(axioms=axioms, premises=frozenset()) + + language = _language_from_parts(axioms, strict_rules, tuple(defeasible_rules)) + contrariness = _contrariness_from_language(language, conflicts) + system = ArgumentationSystem( + language=language, + contrariness=contrariness, + strict_rules=frozenset(strict_rules), + defeasible_rules=frozenset(defeasible_rules), + ) + return GroundedDatalogTheory( + system=system, + kb=kb, + pref=pref, + inspection=inspection, + source_to_ground_rules=source_to_ground, + rule_origins=rule_origins, + non_approximated_predicates=non_approximated, + ) + + +def _require_gunray() -> Any: + try: + import gunray + except ImportError as exc: + raise ImportError( + "argumentation Datalog grounding requires the [grounding] extra: " + "install formal-argumentation[grounding]" + ) from exc + return gunray + + +def _literal_from_ground_atom(atom: Any) -> Literal: + predicate = str(atom.predicate) + negated = predicate.startswith("~") + if negated: + predicate = predicate[1:] + return Literal( + atom=GroundAtom(predicate=predicate, arguments=tuple(atom.arguments)), + negated=negated, + ) + + +def _rule_from_instance( + instance: "GroundRuleInstance", + *, + kind: str, + name: str | None, + origins: dict[Rule, GroundRuleOrigin], +) -> Rule: + if getattr(instance, "default_negated_body", ()): + raise ValueError( + "ASPIC+ grounding does not accept default-negated rule bodies" + ) + rule = Rule( + antecedents=tuple(_literal_from_ground_atom(atom) for atom in instance.body), + consequent=_literal_from_ground_atom(instance.head), + kind=kind, + name=name, + ) + origins[rule] = GroundRuleOrigin( + source_rule_id=instance.rule_id, + substitution=tuple( + (name, value) + for name, value in instance.substitution + ), + role="ground", + ) + return rule + + +def _undercut_rules_from_defeaters( + defeater_instances: tuple["GroundRuleInstance", ...], + target_rules: list[Rule], + origins: dict[Rule, GroundRuleOrigin], +) -> tuple[Rule, ...]: + undercut_rules: list[Rule] = [] + for instance in defeater_instances: + if getattr(instance, "default_negated_body", ()): + raise ValueError( + "ASPIC+ grounding does not accept default-negated defeater bodies" + ) + defeater_head = _literal_from_ground_atom(instance.head) + antecedents = tuple( + _literal_from_ground_atom(atom) + for atom in instance.body + ) + defeater_targets = _defeater_targets(defeater_head, target_rules, origins) + for target_rule in defeater_targets: + if target_rule.name is None: + continue + rule = Rule( + antecedents=antecedents, + consequent=Literal(GroundAtom(target_rule.name), negated=True), + kind="defeasible", + name=f"uc{len(undercut_rules)}", + ) + origins[rule] = GroundRuleOrigin( + source_rule_id=instance.rule_id, + substitution=tuple( + (name, value) + for name, value in instance.substitution + ), + role="undercut", + target_rule=target_rule, + ) + undercut_rules.append(rule) + return tuple(undercut_rules) + + +def _defeater_targets( + defeater_head: Literal, + rules: list[Rule], + origins: Mapping[Rule, GroundRuleOrigin], +) -> tuple[Rule, ...]: + if defeater_head.negated: + source_id_targets = tuple( + rule + for rule in rules + if rule.name is not None + and origins[rule].source_rule_id == defeater_head.atom.predicate + ) + if source_id_targets: + return source_id_targets + return tuple( + rule + for rule in rules + if rule.name is not None and rule.consequent == defeater_head.contrary + ) + + +def _source_to_ground_rules( + origins: Mapping[Rule, GroundRuleOrigin], +) -> Mapping[str, frozenset[Rule]]: + grouped: dict[str, set[Rule]] = {} + for rule, origin in origins.items(): + if origin.role != "ground": + continue + grouped.setdefault(origin.source_rule_id, set()).add(rule) + return { + source_id: frozenset(rules) + for source_id, rules in grouped.items() + } + + +def _project_rule_order( + superiority: tuple[tuple[str, str], ...], + source_to_ground: Mapping[str, frozenset[Rule]], +) -> frozenset[tuple[Rule, Rule]]: + projected: set[tuple[Rule, Rule]] = set() + for superior_id, inferior_id in superiority: + stronger_rules = source_to_ground.get(superior_id, frozenset()) + weaker_rules = source_to_ground.get(inferior_id, frozenset()) + for weaker in weaker_rules: + for stronger in stronger_rules: + if weaker != stronger: + projected.add((weaker, stronger)) + return strict_partial_order_closure(projected) + + +def _language_from_parts( + axioms: frozenset[Literal], + strict_rules: tuple[Rule, ...], + defeasible_rules: tuple[Rule, ...], +) -> frozenset[Literal]: + language: set[Literal] = set(axioms) + for rule in (*strict_rules, *defeasible_rules): + language.add(rule.consequent) + language.update(rule.antecedents) + if rule.name is not None: + language.add(Literal(GroundAtom(rule.name))) + language.add(Literal(GroundAtom(rule.name), negated=True)) + + closed_language = set(language) + for literal in language: + closed_language.add(literal.contrary) + return frozenset(closed_language) + + +def _contrariness_from_language( + language: frozenset[Literal], + conflicts: tuple[tuple[str, str], ...], +) -> ContrarinessFn: + contradictories = { + (literal, literal.contrary) + for literal in language + if literal.contrary in language and not literal.negated + } + contraries: set[tuple[Literal, Literal]] = set() + + by_shape: dict[tuple[str, tuple[Scalar, ...]], set[Literal]] = {} + for literal in language: + key = (literal.atom.predicate, tuple(literal.atom.arguments)) + by_shape.setdefault(key, set()).add(literal) + + for left_predicate, right_predicate in conflicts: + for left, right in _conflict_literals(left_predicate, right_predicate, by_shape): + if left == right: + continue + if left.contrary == right or right.contrary == left: + contradictories.add((left, right)) + else: + contraries.add((left, right)) + contraries.add((right, left)) + + return ContrarinessFn( + contradictories=frozenset(contradictories), + contraries=frozenset(contraries), + ) + + +def _conflict_literals( + left_predicate: str, + right_predicate: str, + by_shape: Mapping[tuple[str, tuple[Scalar, ...]], set[Literal]], +) -> tuple[tuple[Literal, Literal], ...]: + left_name, left_negated = _decode_predicate_polarity(left_predicate) + right_name, right_negated = _decode_predicate_polarity(right_predicate) + + keys = { + args + for predicate, args in by_shape + if predicate in {left_name, right_name} + } + pairs: list[tuple[Literal, Literal]] = [] + for args in keys: + left = Literal(GroundAtom(left_name, args), left_negated) + right = Literal(GroundAtom(right_name, args), right_negated) + pairs.append((left, right)) + return tuple(pairs) + + +def _decode_predicate_polarity(predicate: str) -> tuple[str, bool]: + if predicate.startswith("~"): + return predicate[1:], True + return predicate, False + + +__all__ = [ + "GroundedDatalogTheory", + "GroundRuleOrigin", + "ground_defeasible_theory", + "grounding_inspection_to_aspic", +] diff --git a/src/argumentation/subjective_aspic.py b/src/argumentation/structured/aspic/subjective_aspic.py similarity index 99% rename from src/argumentation/subjective_aspic.py rename to src/argumentation/structured/aspic/subjective_aspic.py index 64edb33..5ff89cd 100644 --- a/src/argumentation/subjective_aspic.py +++ b/src/argumentation/structured/aspic/subjective_aspic.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ASPICAbstractProjection, ArgumentationSystem, GroundAtom, diff --git a/tests/aba_hypothesis_generators.py b/tests/aba_hypothesis_generators.py index 66fd910..c05040c 100644 --- a/tests/aba_hypothesis_generators.py +++ b/tests/aba_hypothesis_generators.py @@ -4,8 +4,8 @@ from hypothesis import strategies as st -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule @dataclass(frozen=True) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..d743aba --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +"""Tests for the core layer.""" diff --git a/tests/test_accrual.py b/tests/core/test_accrual.py similarity index 90% rename from tests/test_accrual.py rename to tests/core/test_accrual.py index 4bfb247..de9257c 100644 --- a/tests/test_accrual.py +++ b/tests/core/test_accrual.py @@ -1,25 +1,19 @@ from __future__ import annotations -import argumentation -from argumentation.accrual import ( +from argumentation.core.accrual import ( AccrualArgument, accrual_envelope, accrual_grounded_labelling, strongly_applicable, weakly_applicable, ) -from argumentation.labelling import Label, Labelling +from argumentation.core.labelling import Label, Labelling def _labelling(statuses: dict[str, Label]) -> Labelling: return Labelling.from_statuses(arguments=frozenset(statuses), statuses=statuses) -def test_accrual_module_is_exported() -> None: - assert argumentation.accrual.AccrualArgument is AccrualArgument - assert "accrual" in argumentation.__all__ - - def test_weak_and_strong_applicability_follow_labelling_statuses() -> None: argument = AccrualArgument( identifier="a", diff --git a/tests/test_bipolar_argumentation.py b/tests/core/test_bipolar_argumentation.py similarity index 96% rename from tests/test_bipolar_argumentation.py rename to tests/core/test_bipolar_argumentation.py index 5b1965c..7ec77d6 100644 --- a/tests/test_bipolar_argumentation.py +++ b/tests/core/test_bipolar_argumentation.py @@ -1,173 +1,173 @@ -"""Tests for Cayrol-style bipolar argumentation and Dung attack metadata.""" - -from __future__ import annotations - -import pytest - -from argumentation.bipolar import ( - BipolarArgumentationFramework, - c_preferred_extensions, - cayrol_derived_defeats, - d_preferred_extensions, - s_preferred_extensions, -) -from argumentation.dung import ( - ArgumentationFramework, - admissible, - complete_extensions, - conflict_free, - grounded_extension, - stable_extensions, -) - - -class TestCayrolDerivedDefeats: - def test_supported_defeat(self) -> None: - supports = frozenset({("A", "B")}) - defeats = frozenset({("B", "C")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "C") in derived - - def test_indirect_defeat(self) -> None: - supports = frozenset({("B", "C")}) - defeats = frozenset({("A", "B")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "C") in derived - - def test_no_supports_no_derived(self) -> None: - defeats = frozenset({("A", "B")}) - derived = cayrol_derived_defeats(defeats, frozenset()) - assert derived == frozenset() - - def test_no_defeats_no_derived(self) -> None: - supports = frozenset({("A", "B")}) - derived = cayrol_derived_defeats(frozenset(), supports) - assert derived == frozenset() - - def test_chain_supported_defeat(self) -> None: - supports = frozenset({("A", "B"), ("B", "C")}) - defeats = frozenset({("C", "D")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "D") in derived - assert ("B", "D") in derived - - def test_chain_indirect_defeat(self) -> None: - supports = frozenset({("B", "C"), ("C", "D")}) - defeats = frozenset({("A", "B")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "C") in derived - assert ("A", "D") in derived - - def test_cayrol_derived_defeats_chain_transitively(self) -> None: - supports = frozenset({("A", "B"), ("C", "D")}) - defeats = frozenset({("B", "C")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "C") in derived - assert ("B", "D") in derived - assert ("A", "D") in derived - - def test_direct_defeat_not_duplicated(self) -> None: - supports = frozenset({("A", "B")}) - defeats = frozenset({("A", "C")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("A", "C") not in derived - - def test_self_support_loop_terminates(self) -> None: - supports = frozenset({("A", "B"), ("B", "A")}) - defeats = frozenset({("A", "C")}) - derived = cayrol_derived_defeats(defeats, supports) - assert ("B", "C") in derived - - -class TestBipolarFrameworkValidation: - def test_defeats_must_reference_declared_arguments(self) -> None: - with pytest.raises(ValueError, match="defeats"): - BipolarArgumentationFramework( - arguments=frozenset({"A"}), - defeats=frozenset({("A", "B")}), - ) - - def test_supports_must_reference_declared_arguments(self) -> None: - with pytest.raises(ValueError, match="supports"): - BipolarArgumentationFramework( - arguments=frozenset({"A"}), - defeats=frozenset(), - supports=frozenset({("B", "A")}), - ) - - -class TestAttackBasedConflictFree: - def test_defeat_based_cf_allows_single_undefeated_argument(self) -> None: - defeats = frozenset({("A", "B")}) - assert not conflict_free(frozenset({"A", "B"}), defeats) - assert conflict_free(frozenset({"B"}), defeats) - - def test_attack_based_cf_blocks_coexistence(self) -> None: - attacks = frozenset({("A", "B"), ("B", "A")}) - assert not conflict_free(frozenset({"A", "B"}), attacks) - - def test_admissible_uses_attacks(self) -> None: - args = frozenset({"A", "B"}) - attacks = frozenset({("B", "A")}) - defeats = frozenset() - assert admissible(frozenset({"A", "B"}), args, defeats) - assert not admissible(frozenset({"A", "B"}), args, defeats, attacks=attacks) - - def test_complete_extensions_with_attacks(self) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset(), - attacks=frozenset({("A", "B")}), - ) - exts = complete_extensions(framework) - assert frozenset({"A", "B"}) not in exts - - def test_complete_extensions_attacks_with_defeats(self) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset({("A", "B")}), - attacks=frozenset({("A", "B"), ("B", "A")}), - ) - exts = complete_extensions(framework) - assert frozenset({"A"}) in exts - assert frozenset({"A", "B"}) not in exts - - def test_stable_extensions_with_attacks(self) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset(), - attacks=frozenset({("A", "B"), ("B", "A")}), - ) - exts = stable_extensions(framework) - assert frozenset({"A", "B"}) not in exts - - def test_grounded_extension_ignores_attack_metadata(self) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset(), - attacks=frozenset({("A", "B")}), - ) - assert grounded_extension(framework) == frozenset({"A", "B"}) - - -class TestBipolarExtensions: - def test_bipolar_preferred_semantics_differ(self) -> None: - framework = BipolarArgumentationFramework( - arguments=frozenset({"A", "B", "C", "H"}), - defeats=frozenset({("A", "B")}), - supports=frozenset({("C", "B")}), - ) - candidate = frozenset({"A", "C", "H"}) - assert candidate in d_preferred_extensions(framework) - assert candidate not in s_preferred_extensions(framework) - assert candidate not in c_preferred_extensions(framework) - - def test_bipolar_helpers_on_supported_defeat_fixture(self) -> None: - framework = BipolarArgumentationFramework( - arguments=frozenset({"A", "B", "C"}), - defeats=frozenset({("B", "C")}), - supports=frozenset({("A", "B")}), - ) - assert frozenset({"A", "B"}) in d_preferred_extensions(framework) - assert frozenset({"A", "B"}) in s_preferred_extensions(framework) - assert frozenset({"A", "B"}) in c_preferred_extensions(framework) +"""Tests for Cayrol-style bipolar argumentation and Dung attack metadata.""" + +from __future__ import annotations + +import pytest + +from argumentation.core.bipolar import ( + BipolarArgumentationFramework, + c_preferred_extensions, + cayrol_derived_defeats, + d_preferred_extensions, + s_preferred_extensions, +) +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + complete_extensions, + conflict_free, + grounded_extension, + stable_extensions, +) + + +class TestCayrolDerivedDefeats: + def test_supported_defeat(self) -> None: + supports = frozenset({("A", "B")}) + defeats = frozenset({("B", "C")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "C") in derived + + def test_indirect_defeat(self) -> None: + supports = frozenset({("B", "C")}) + defeats = frozenset({("A", "B")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "C") in derived + + def test_no_supports_no_derived(self) -> None: + defeats = frozenset({("A", "B")}) + derived = cayrol_derived_defeats(defeats, frozenset()) + assert derived == frozenset() + + def test_no_defeats_no_derived(self) -> None: + supports = frozenset({("A", "B")}) + derived = cayrol_derived_defeats(frozenset(), supports) + assert derived == frozenset() + + def test_chain_supported_defeat(self) -> None: + supports = frozenset({("A", "B"), ("B", "C")}) + defeats = frozenset({("C", "D")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "D") in derived + assert ("B", "D") in derived + + def test_chain_indirect_defeat(self) -> None: + supports = frozenset({("B", "C"), ("C", "D")}) + defeats = frozenset({("A", "B")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "C") in derived + assert ("A", "D") in derived + + def test_cayrol_derived_defeats_chain_transitively(self) -> None: + supports = frozenset({("A", "B"), ("C", "D")}) + defeats = frozenset({("B", "C")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "C") in derived + assert ("B", "D") in derived + assert ("A", "D") in derived + + def test_direct_defeat_not_duplicated(self) -> None: + supports = frozenset({("A", "B")}) + defeats = frozenset({("A", "C")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("A", "C") not in derived + + def test_self_support_loop_terminates(self) -> None: + supports = frozenset({("A", "B"), ("B", "A")}) + defeats = frozenset({("A", "C")}) + derived = cayrol_derived_defeats(defeats, supports) + assert ("B", "C") in derived + + +class TestBipolarFrameworkValidation: + def test_defeats_must_reference_declared_arguments(self) -> None: + with pytest.raises(ValueError, match="defeats"): + BipolarArgumentationFramework( + arguments=frozenset({"A"}), + defeats=frozenset({("A", "B")}), + ) + + def test_supports_must_reference_declared_arguments(self) -> None: + with pytest.raises(ValueError, match="supports"): + BipolarArgumentationFramework( + arguments=frozenset({"A"}), + defeats=frozenset(), + supports=frozenset({("B", "A")}), + ) + + +class TestAttackBasedConflictFree: + def test_defeat_based_cf_allows_single_undefeated_argument(self) -> None: + defeats = frozenset({("A", "B")}) + assert not conflict_free(frozenset({"A", "B"}), defeats) + assert conflict_free(frozenset({"B"}), defeats) + + def test_attack_based_cf_blocks_coexistence(self) -> None: + attacks = frozenset({("A", "B"), ("B", "A")}) + assert not conflict_free(frozenset({"A", "B"}), attacks) + + def test_admissible_uses_attacks(self) -> None: + args = frozenset({"A", "B"}) + attacks = frozenset({("B", "A")}) + defeats = frozenset() + assert admissible(frozenset({"A", "B"}), args, defeats) + assert not admissible(frozenset({"A", "B"}), args, defeats, attacks=attacks) + + def test_complete_extensions_with_attacks(self) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset(), + attacks=frozenset({("A", "B")}), + ) + exts = complete_extensions(framework) + assert frozenset({"A", "B"}) not in exts + + def test_complete_extensions_attacks_with_defeats(self) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset({("A", "B")}), + attacks=frozenset({("A", "B"), ("B", "A")}), + ) + exts = complete_extensions(framework) + assert frozenset({"A"}) in exts + assert frozenset({"A", "B"}) not in exts + + def test_stable_extensions_with_attacks(self) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset(), + attacks=frozenset({("A", "B"), ("B", "A")}), + ) + exts = stable_extensions(framework) + assert frozenset({"A", "B"}) not in exts + + def test_grounded_extension_ignores_attack_metadata(self) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset(), + attacks=frozenset({("A", "B")}), + ) + assert grounded_extension(framework) == frozenset({"A", "B"}) + + +class TestBipolarExtensions: + def test_bipolar_preferred_semantics_differ(self) -> None: + framework = BipolarArgumentationFramework( + arguments=frozenset({"A", "B", "C", "H"}), + defeats=frozenset({("A", "B")}), + supports=frozenset({("C", "B")}), + ) + candidate = frozenset({"A", "C", "H"}) + assert candidate in d_preferred_extensions(framework) + assert candidate not in s_preferred_extensions(framework) + assert candidate not in c_preferred_extensions(framework) + + def test_bipolar_helpers_on_supported_defeat_fixture(self) -> None: + framework = BipolarArgumentationFramework( + arguments=frozenset({"A", "B", "C"}), + defeats=frozenset({("B", "C")}), + supports=frozenset({("A", "B")}), + ) + assert frozenset({"A", "B"}) in d_preferred_extensions(framework) + assert frozenset({"A", "B"}) in s_preferred_extensions(framework) + assert frozenset({"A", "B"}) in c_preferred_extensions(framework) diff --git a/tests/test_bipolar_semantics.py b/tests/core/test_bipolar_semantics.py similarity index 96% rename from tests/test_bipolar_semantics.py rename to tests/core/test_bipolar_semantics.py index e3b8035..029e6cb 100644 --- a/tests/test_bipolar_semantics.py +++ b/tests/core/test_bipolar_semantics.py @@ -1,321 +1,321 @@ -"""Tests for explicit Cayrol 2005 bipolar semantics.""" - -from __future__ import annotations - -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.bipolar import ( - BipolarArgumentationFramework, - c_admissible, - c_preferred_extensions, - conflict_free, - d_admissible, - d_preferred_extensions, - defends, - derived_set_defeats, - s_admissible, - s_preferred_extensions, - safe, - set_defeats, - set_supports, - stable_extensions, - support_closed, -) - - -def baf( - args: set[str], - defeats: set[tuple[str, str]], - supports: set[tuple[str, str]], -) -> BipolarArgumentationFramework: - return BipolarArgumentationFramework( - arguments=frozenset(args), - defeats=frozenset(defeats), - supports=frozenset(supports), - ) - - -class TestCayrolDefinitions: - def test_set_support_and_set_defeat_match_example_2_left_fragment(self): - framework = baf( - {"A", "B", "C", "D", "E", "G", "H"}, - {("G", "A"), ("E", "C"), ("H", "B"), ("C", "D")}, - {("A", "B"), ("B", "C")}, - ) - assert set_supports(frozenset({"A"}), "B", framework) - assert set_supports(frozenset({"A"}), "C", framework) - assert set_defeats(frozenset({"B"}), "D", framework) - assert set_defeats(frozenset({"H"}), "C", framework) - - def test_bipolar_d_admissible_differs_from_s_admissible_when_safety_fails(self): - framework = baf( - {"A", "B", "C", "D", "F"}, - {("C", "D")}, - {("A", "B"), ("B", "C"), ("F", "D")}, - ) - candidate = frozenset({"B", "F"}) - assert conflict_free(candidate, framework) - assert defends(candidate, "B", framework) - assert defends(candidate, "F", framework) - assert d_admissible(candidate, framework) - assert not safe(candidate, framework) - assert not s_admissible(candidate, framework) - - def test_bipolar_c_admissible_requires_support_closure(self): - framework = baf( - {"A", "B"}, - set(), - {("A", "B")}, - ) - candidate = frozenset({"A"}) - assert conflict_free(candidate, framework) - assert d_admissible(candidate, framework) - assert s_admissible(candidate, framework) - assert not support_closed(candidate, framework) - assert not c_admissible(candidate, framework) - - def test_cayrol_example_2_set_classifications(self): - framework = baf( - {"A", "B", "C", "D", "E", "F", "G", "H"}, - {("G", "A"), ("E", "C"), ("H", "B"), ("C", "D")}, - {("A", "B"), ("B", "C"), ("F", "D")}, - ) - assert conflict_free(frozenset({"A", "H"}), framework) - assert not safe(frozenset({"A", "H"}), framework) - assert conflict_free(frozenset({"B", "F"}), framework) - assert not safe(frozenset({"B", "F"}), framework) - assert not conflict_free(frozenset({"H", "C"}), framework) - assert not conflict_free(frozenset({"B", "D"}), framework) - assert safe(frozenset({"G", "H", "E"}), framework) - - def test_preferred_hierarchy_distinguishes_d_s_c(self): - framework = baf( - {"A", "B", "C", "H"}, - {("H", "B"), ("A", "C")}, - {("A", "B"), ("C", "A")}, - ) - assert frozenset({"A", "H"}) in d_preferred_extensions(framework) - assert frozenset({"A", "H"}) not in s_preferred_extensions(framework) - assert frozenset({"A", "H"}) not in c_preferred_extensions(framework) - - -@st.composite -def bipolar_frameworks(draw, max_args: int = 6): - args = draw( - st.frozensets( - st.text(alphabet="abcdef", min_size=1, max_size=2), - min_size=1, - max_size=max_args, - ) - ) - arg_list = sorted(args) - pairs = [ - (source, target) - for source in arg_list - for target in arg_list - if source != target - ] - defeats = draw( - st.frozensets( - st.sampled_from(pairs), - max_size=len(pairs), - ) - ) if pairs else frozenset() - support_pairs = [pair for pair in pairs if pair not in defeats] - supports = draw( - st.frozensets( - st.sampled_from(support_pairs), - max_size=len(support_pairs), - ) - ) if support_pairs else frozenset() - return BipolarArgumentationFramework( - arguments=args, - defeats=defeats, - supports=supports, - ) - - -_PROP_SETTINGS = settings(deadline=None) - - -class TestCayrolProperties: - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_safe_implies_conflict_free(self, framework): - candidate = frozenset(sorted(framework.arguments)[: len(framework.arguments) // 2]) - if safe(candidate, framework): - assert conflict_free(candidate, framework) - - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_conflict_free_and_support_closed_implies_safe(self, framework): - candidate = frozenset( - arg for index, arg in enumerate(sorted(framework.arguments)) if index % 2 == 0 - ) - if conflict_free(candidate, framework) and support_closed(candidate, framework): - assert safe(candidate, framework) - - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_c_implies_s_implies_d(self, framework): - candidate = frozenset( - arg for index, arg in enumerate(sorted(framework.arguments)) if index % 2 == 0 - ) - if c_admissible(candidate, framework): - assert s_admissible(candidate, framework) - if s_admissible(candidate, framework): - assert d_admissible(candidate, framework) - - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_set_defeat_is_monotone_in_the_attacking_set(self, framework): - ordered = sorted(framework.arguments) - subset = frozenset(ordered[: len(ordered) // 2]) - superset = frozenset(ordered) - for target in framework.arguments: - if set_defeats(subset, target, framework): - assert set_defeats(superset, target, framework) - - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_derived_set_defeats_is_idempotent(self, framework): - first = derived_set_defeats(framework) - second = derived_set_defeats( - BipolarArgumentationFramework( - arguments=framework.arguments, - defeats=first, - supports=framework.supports, - ) - ) - assert second == first - - @given(bipolar_frameworks()) - @_PROP_SETTINGS - def test_stable_extensions_are_d_admissible(self, framework): - for extension in stable_extensions(framework): - assert d_admissible(extension, framework) - - -# ── Support cycle and self-support property tests (audit-2026-03-28) ── - -from argumentation.bipolar import cayrol_derived_defeats - - -@st.composite -def bipolar_frameworks_with_cycles(draw, max_args: int = 5): - """Generate bipolar frameworks that may include self-supports and support cycles. - - The standard bipolar_frameworks() strategy excludes self-edges. - This strategy allows self-supports (A supports A) and support cycles - (A supports B, B supports A) to test termination guarantees. - """ - args = draw( - st.frozensets( - st.text(alphabet="abcde", min_size=1, max_size=2), - min_size=2, - max_size=max_args, - ) - ) - arg_list = sorted(args) - # All pairs INCLUDING self-edges for supports - all_pairs = [(s, t) for s in arg_list for t in arg_list] - non_self_pairs = [(s, t) for s, t in all_pairs if s != t] - - # Defeats: no self-defeats (standard) - defeats = draw( - st.frozensets( - st.sampled_from(non_self_pairs), - max_size=len(non_self_pairs), - ) - ) if non_self_pairs else frozenset() - - # Supports: allow self-supports, exclude edges that are defeats - support_candidates = [p for p in all_pairs if p not in defeats] - supports = draw( - st.frozensets( - st.sampled_from(support_candidates), - max_size=len(support_candidates), - ) - ) if support_candidates else frozenset() - - return BipolarArgumentationFramework( - arguments=args, - defeats=defeats, - supports=supports, - ) - - -class TestBipolarCycleProperties: - """Property tests for bipolar frameworks with support cycles. - - Cayrol & Lagasquie-Schiex (2005): derived defeats must reach a fixpoint - even when support cycles exist. The working_defeats set grows monotonically - and is bounded by |args|^2, guaranteeing termination. - """ - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_derived_defeats_terminates_with_self_supports(self, framework): - """cayrol_derived_defeats terminates even with self-supports (A→A).""" - derived = cayrol_derived_defeats(framework.defeats, framework.supports) - assert isinstance(derived, frozenset) - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_derived_defeats_no_self_defeats(self, framework): - """Derived defeats never include self-defeats (A, A). - - Self-defeats are filtered by cayrol_derived_defeats (source != target). - """ - derived = cayrol_derived_defeats(framework.defeats, framework.supports) - for src, tgt in derived: - assert src != tgt, f"Self-defeat ({src}, {src}) in derived defeats" - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_derived_defeats_idempotent_with_cycles(self, framework): - """Applying derived_set_defeats twice gives the same result — even with cycles.""" - first = derived_set_defeats(framework) - second = derived_set_defeats( - BipolarArgumentationFramework( - arguments=framework.arguments, - defeats=first, - supports=framework.supports, - ) - ) - assert second == first, ( - f"Not idempotent: first pass added {first - framework.defeats}, " - f"second pass added {second - first}" - ) - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_derived_defeats_bounded_by_argument_pairs(self, framework): - """Total defeats (original + derived) bounded by |args|^2. - - The defeat closure cannot exceed the total number of possible - directed pairs (excluding self-edges). - """ - derived = cayrol_derived_defeats(framework.defeats, framework.supports) - total = framework.defeats | derived - n = len(framework.arguments) - max_possible = n * (n - 1) # directed pairs, no self-edges - assert len(total) <= max_possible - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_support_closed_terminates_with_cycles(self, framework): - """support_closed check terminates even with support cycles.""" - candidate = frozenset(sorted(framework.arguments)[:len(framework.arguments) // 2]) - # Just verify it returns a boolean without hanging - result = support_closed(candidate, framework) - assert isinstance(result, bool) - - @given(bipolar_frameworks_with_cycles()) - @_PROP_SETTINGS - def test_safe_implies_conflict_free_with_cycles(self, framework): - """safe(S) => conflict_free(S) holds even with support cycles.""" - candidate = frozenset(sorted(framework.arguments)[:len(framework.arguments) // 2]) - if safe(candidate, framework): - assert conflict_free(candidate, framework) +"""Tests for explicit Cayrol 2005 bipolar semantics.""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.bipolar import ( + BipolarArgumentationFramework, + c_admissible, + c_preferred_extensions, + conflict_free, + d_admissible, + d_preferred_extensions, + defends, + derived_set_defeats, + s_admissible, + s_preferred_extensions, + safe, + set_defeats, + set_supports, + stable_extensions, + support_closed, +) + + +def baf( + args: set[str], + defeats: set[tuple[str, str]], + supports: set[tuple[str, str]], +) -> BipolarArgumentationFramework: + return BipolarArgumentationFramework( + arguments=frozenset(args), + defeats=frozenset(defeats), + supports=frozenset(supports), + ) + + +class TestCayrolDefinitions: + def test_set_support_and_set_defeat_match_example_2_left_fragment(self): + framework = baf( + {"A", "B", "C", "D", "E", "G", "H"}, + {("G", "A"), ("E", "C"), ("H", "B"), ("C", "D")}, + {("A", "B"), ("B", "C")}, + ) + assert set_supports(frozenset({"A"}), "B", framework) + assert set_supports(frozenset({"A"}), "C", framework) + assert set_defeats(frozenset({"B"}), "D", framework) + assert set_defeats(frozenset({"H"}), "C", framework) + + def test_bipolar_d_admissible_differs_from_s_admissible_when_safety_fails(self): + framework = baf( + {"A", "B", "C", "D", "F"}, + {("C", "D")}, + {("A", "B"), ("B", "C"), ("F", "D")}, + ) + candidate = frozenset({"B", "F"}) + assert conflict_free(candidate, framework) + assert defends(candidate, "B", framework) + assert defends(candidate, "F", framework) + assert d_admissible(candidate, framework) + assert not safe(candidate, framework) + assert not s_admissible(candidate, framework) + + def test_bipolar_c_admissible_requires_support_closure(self): + framework = baf( + {"A", "B"}, + set(), + {("A", "B")}, + ) + candidate = frozenset({"A"}) + assert conflict_free(candidate, framework) + assert d_admissible(candidate, framework) + assert s_admissible(candidate, framework) + assert not support_closed(candidate, framework) + assert not c_admissible(candidate, framework) + + def test_cayrol_example_2_set_classifications(self): + framework = baf( + {"A", "B", "C", "D", "E", "F", "G", "H"}, + {("G", "A"), ("E", "C"), ("H", "B"), ("C", "D")}, + {("A", "B"), ("B", "C"), ("F", "D")}, + ) + assert conflict_free(frozenset({"A", "H"}), framework) + assert not safe(frozenset({"A", "H"}), framework) + assert conflict_free(frozenset({"B", "F"}), framework) + assert not safe(frozenset({"B", "F"}), framework) + assert not conflict_free(frozenset({"H", "C"}), framework) + assert not conflict_free(frozenset({"B", "D"}), framework) + assert safe(frozenset({"G", "H", "E"}), framework) + + def test_preferred_hierarchy_distinguishes_d_s_c(self): + framework = baf( + {"A", "B", "C", "H"}, + {("H", "B"), ("A", "C")}, + {("A", "B"), ("C", "A")}, + ) + assert frozenset({"A", "H"}) in d_preferred_extensions(framework) + assert frozenset({"A", "H"}) not in s_preferred_extensions(framework) + assert frozenset({"A", "H"}) not in c_preferred_extensions(framework) + + +@st.composite +def bipolar_frameworks(draw, max_args: int = 6): + args = draw( + st.frozensets( + st.text(alphabet="abcdef", min_size=1, max_size=2), + min_size=1, + max_size=max_args, + ) + ) + arg_list = sorted(args) + pairs = [ + (source, target) + for source in arg_list + for target in arg_list + if source != target + ] + defeats = draw( + st.frozensets( + st.sampled_from(pairs), + max_size=len(pairs), + ) + ) if pairs else frozenset() + support_pairs = [pair for pair in pairs if pair not in defeats] + supports = draw( + st.frozensets( + st.sampled_from(support_pairs), + max_size=len(support_pairs), + ) + ) if support_pairs else frozenset() + return BipolarArgumentationFramework( + arguments=args, + defeats=defeats, + supports=supports, + ) + + +_PROP_SETTINGS = settings(deadline=None) + + +class TestCayrolProperties: + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_safe_implies_conflict_free(self, framework): + candidate = frozenset(sorted(framework.arguments)[: len(framework.arguments) // 2]) + if safe(candidate, framework): + assert conflict_free(candidate, framework) + + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_conflict_free_and_support_closed_implies_safe(self, framework): + candidate = frozenset( + arg for index, arg in enumerate(sorted(framework.arguments)) if index % 2 == 0 + ) + if conflict_free(candidate, framework) and support_closed(candidate, framework): + assert safe(candidate, framework) + + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_c_implies_s_implies_d(self, framework): + candidate = frozenset( + arg for index, arg in enumerate(sorted(framework.arguments)) if index % 2 == 0 + ) + if c_admissible(candidate, framework): + assert s_admissible(candidate, framework) + if s_admissible(candidate, framework): + assert d_admissible(candidate, framework) + + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_set_defeat_is_monotone_in_the_attacking_set(self, framework): + ordered = sorted(framework.arguments) + subset = frozenset(ordered[: len(ordered) // 2]) + superset = frozenset(ordered) + for target in framework.arguments: + if set_defeats(subset, target, framework): + assert set_defeats(superset, target, framework) + + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_derived_set_defeats_is_idempotent(self, framework): + first = derived_set_defeats(framework) + second = derived_set_defeats( + BipolarArgumentationFramework( + arguments=framework.arguments, + defeats=first, + supports=framework.supports, + ) + ) + assert second == first + + @given(bipolar_frameworks()) + @_PROP_SETTINGS + def test_stable_extensions_are_d_admissible(self, framework): + for extension in stable_extensions(framework): + assert d_admissible(extension, framework) + + +# ── Support cycle and self-support property tests (audit-2026-03-28) ── + +from argumentation.core.bipolar import cayrol_derived_defeats + + +@st.composite +def bipolar_frameworks_with_cycles(draw, max_args: int = 5): + """Generate bipolar frameworks that may include self-supports and support cycles. + + The standard bipolar_frameworks() strategy excludes self-edges. + This strategy allows self-supports (A supports A) and support cycles + (A supports B, B supports A) to test termination guarantees. + """ + args = draw( + st.frozensets( + st.text(alphabet="abcde", min_size=1, max_size=2), + min_size=2, + max_size=max_args, + ) + ) + arg_list = sorted(args) + # All pairs INCLUDING self-edges for supports + all_pairs = [(s, t) for s in arg_list for t in arg_list] + non_self_pairs = [(s, t) for s, t in all_pairs if s != t] + + # Defeats: no self-defeats (standard) + defeats = draw( + st.frozensets( + st.sampled_from(non_self_pairs), + max_size=len(non_self_pairs), + ) + ) if non_self_pairs else frozenset() + + # Supports: allow self-supports, exclude edges that are defeats + support_candidates = [p for p in all_pairs if p not in defeats] + supports = draw( + st.frozensets( + st.sampled_from(support_candidates), + max_size=len(support_candidates), + ) + ) if support_candidates else frozenset() + + return BipolarArgumentationFramework( + arguments=args, + defeats=defeats, + supports=supports, + ) + + +class TestBipolarCycleProperties: + """Property tests for bipolar frameworks with support cycles. + + Cayrol & Lagasquie-Schiex (2005): derived defeats must reach a fixpoint + even when support cycles exist. The working_defeats set grows monotonically + and is bounded by |args|^2, guaranteeing termination. + """ + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_derived_defeats_terminates_with_self_supports(self, framework): + """cayrol_derived_defeats terminates even with self-supports (A→A).""" + derived = cayrol_derived_defeats(framework.defeats, framework.supports) + assert isinstance(derived, frozenset) + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_derived_defeats_no_self_defeats(self, framework): + """Derived defeats never include self-defeats (A, A). + + Self-defeats are filtered by cayrol_derived_defeats (source != target). + """ + derived = cayrol_derived_defeats(framework.defeats, framework.supports) + for src, tgt in derived: + assert src != tgt, f"Self-defeat ({src}, {src}) in derived defeats" + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_derived_defeats_idempotent_with_cycles(self, framework): + """Applying derived_set_defeats twice gives the same result — even with cycles.""" + first = derived_set_defeats(framework) + second = derived_set_defeats( + BipolarArgumentationFramework( + arguments=framework.arguments, + defeats=first, + supports=framework.supports, + ) + ) + assert second == first, ( + f"Not idempotent: first pass added {first - framework.defeats}, " + f"second pass added {second - first}" + ) + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_derived_defeats_bounded_by_argument_pairs(self, framework): + """Total defeats (original + derived) bounded by |args|^2. + + The defeat closure cannot exceed the total number of possible + directed pairs (excluding self-edges). + """ + derived = cayrol_derived_defeats(framework.defeats, framework.supports) + total = framework.defeats | derived + n = len(framework.arguments) + max_possible = n * (n - 1) # directed pairs, no self-edges + assert len(total) <= max_possible + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_support_closed_terminates_with_cycles(self, framework): + """support_closed check terminates even with support cycles.""" + candidate = frozenset(sorted(framework.arguments)[:len(framework.arguments) // 2]) + # Just verify it returns a boolean without hanging + result = support_closed(candidate, framework) + assert isinstance(result, bool) + + @given(bipolar_frameworks_with_cycles()) + @_PROP_SETTINGS + def test_safe_implies_conflict_free_with_cycles(self, framework): + """safe(S) => conflict_free(S) holds even with support cycles.""" + candidate = frozenset(sorted(framework.arguments)[:len(framework.arguments) // 2]) + if safe(candidate, framework): + assert conflict_free(candidate, framework) diff --git a/tests/test_dung.py b/tests/core/test_dung.py similarity index 96% rename from tests/test_dung.py rename to tests/core/test_dung.py index dfb2950..6a08d9c 100644 --- a/tests/test_dung.py +++ b/tests/core/test_dung.py @@ -1,904 +1,904 @@ -"""Tests for Dung's abstract argumentation semantics. - -Property-based tests verify formal theorems from: - Dung, P.M. (1995). On the acceptability of arguments and its - fundamental role in nonmonotonic reasoning, logic programming - and n-person games. Artificial Intelligence, 77(2), 321-357. - -Concrete regression tests use known examples from the paper. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from hypothesis import given, settings, assume -from hypothesis import strategies as st - -from argumentation.dung import ( - ArgumentationFramework, - attackers_of, - characteristic_fn, - admissible, - cf2_extensions, - conflict_free, - complete_extensions, - defends, - grounded_extension, - ideal_extension, - preferred_extensions, - range_of, - semi_stable_extensions, - stage_extensions, - stable_extensions, -) -from argumentation.semantics import extensions -from argumentation.labelling import ExactEnumerationExceeded - - -# ── Hypothesis strategies ─────────────────────────────────────────── - - -@st.composite -def argumentation_frameworks(draw, max_args=8): - """Generate random argumentation frameworks for property testing.""" - args = draw( - st.frozensets( - st.text(alphabet="abcdefgh", min_size=1, max_size=2), - min_size=1, - max_size=max_args, - ) - ) - arg_list = sorted(args) - attacks = draw( - st.frozensets( - st.tuples( - st.sampled_from(arg_list), - st.sampled_from(arg_list), - ), - max_size=len(arg_list) ** 2, - ) - ) - return ArgumentationFramework(arguments=args, defeats=attacks) - - -def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: - ordered = sorted(arguments) - return [ - frozenset(arg for index, arg in enumerate(ordered) if mask & (1 << index)) - for mask in range(1 << len(ordered)) - ] - - -def _definition_range( - extension: frozenset[str], - defeats: frozenset[tuple[str, str]], -) -> frozenset[str]: - return extension | frozenset( - target - for attacker, target in defeats - if attacker in extension - ) - - -def _draw_subset(data: st.DataObject, arguments: frozenset[str]) -> frozenset[str]: - return frozenset(data.draw(st.sets(st.sampled_from(sorted(arguments)), max_size=len(arguments)))) - - -# ── Concrete regression tests ─────────────────────────────────────── - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - """Shorthand for building an AF from plain sets.""" - return ArgumentationFramework( - arguments=frozenset(args), - defeats=frozenset(defeats), - ) - - -class TestGroundedConcrete: - """Concrete examples for grounded extension.""" - - def test_empty_framework(self): - """Empty AF has empty grounded extension.""" - assert grounded_extension(af(set(), set())) == frozenset() - - def test_unattacked_wins(self): - """A attacks B, nothing attacks A. Grounded = {A}.""" - assert grounded_extension(af({"A", "B"}, {("A", "B")})) == frozenset({"A"}) - - def test_nixon_diamond(self): - """A attacks B, B attacks A. Grounded = {} (mutual defeat).""" - assert grounded_extension(af({"A", "B"}, {("A", "B"), ("B", "A")})) == frozenset() - - def test_reinstatement(self): - """A attacks B, B attacks C. A reinstates C. Grounded = {A, C}.""" - result = grounded_extension(af({"A", "B", "C"}, {("A", "B"), ("B", "C")})) - assert result == frozenset({"A", "C"}) - - def test_odd_cycle(self): - """A->B->C->A. Grounded = {} (no unattacked argument).""" - result = grounded_extension( - af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}) - ) - assert result == frozenset() - - def test_self_attacker(self): - """A attacks itself. Grounded = {}.""" - assert grounded_extension(af({"A"}, {("A", "A")})) == frozenset() - - def test_floating_defeat(self): - """A attacks B and C, B and C attack each other. Grounded = {A}.""" - result = grounded_extension( - af({"A", "B", "C"}, {("A", "B"), ("A", "C"), ("B", "C"), ("C", "B")}) - ) - assert result == frozenset({"A"}) - - def test_no_attacks(self): - """No attacks means all arguments are in grounded extension.""" - assert grounded_extension(af({"A", "B", "C"}, set())) == frozenset({"A", "B", "C"}) - - def test_chain_of_four(self): - """A->B->C->D. Grounded = {A, C}. A and C are defended.""" - result = grounded_extension( - af({"A", "B", "C", "D"}, {("A", "B"), ("B", "C"), ("C", "D")}) - ) - assert result == frozenset({"A", "C"}) - - def test_grounded_ignores_attack_metadata(self): - """Grounded semantics always uses defeats, even when attacks are present.""" - fw = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset(), - attacks=frozenset({("A", "B")}), - ) - assert grounded_extension(fw) == frozenset({"A", "B"}) - - -class TestFrameworkValidation: - def test_defeats_must_reference_declared_arguments(self) -> None: - with pytest.raises(ValueError, match="defeats"): - ArgumentationFramework( - arguments=frozenset({"A"}), - defeats=frozenset({("A", "B")}), - ) - - def test_attacks_must_reference_declared_arguments(self) -> None: - with pytest.raises(ValueError, match="attacks"): - ArgumentationFramework( - arguments=frozenset({"A"}), - defeats=frozenset(), - attacks=frozenset({("B", "A")}), - ) - - -class TestPreferredConcrete: - """Concrete examples for preferred extensions.""" - - def test_nixon_diamond(self): - """Nixon diamond has two preferred extensions: {A} and {B}.""" - exts = preferred_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) - assert len(exts) == 2 - assert frozenset({"A"}) in exts - assert frozenset({"B"}) in exts - - def test_unattacked_wins(self): - """A attacks B. Single preferred = {A}.""" - exts = preferred_extensions(af({"A", "B"}, {("A", "B")})) - assert exts == [frozenset({"A"})] - - def test_reinstatement(self): - """A->B->C. Single preferred = {A, C}.""" - exts = preferred_extensions( - af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) - ) - assert exts == [frozenset({"A", "C"})] - - def test_self_attacker(self): - """Self-attacking A. Preferred = [{}] (empty set is maximal admissible).""" - exts = preferred_extensions(af({"A"}, {("A", "A")})) - assert exts == [frozenset()] - - def test_no_attacks(self): - """No attacks. Single preferred = all arguments.""" - exts = preferred_extensions(af({"A", "B"}, set())) - assert exts == [frozenset({"A", "B"})] - - -class TestStableConcrete: - """Concrete examples for stable extensions.""" - - def test_nixon_diamond(self): - """Nixon diamond: two stable extensions {A} and {B}.""" - exts = stable_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) - assert len(exts) == 2 - assert frozenset({"A"}) in exts - assert frozenset({"B"}) in exts - - def test_odd_cycle_no_stable(self): - """A->B->C->A. No stable extension exists.""" - exts = stable_extensions( - af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}) - ) - assert exts == [] - - def test_self_attacker_no_stable(self): - """Self-attacking A. No stable extension (can't defeat A from empty set).""" - exts = stable_extensions(af({"A"}, {("A", "A")})) - assert exts == [] - - def test_unattacked_wins(self): - """A attacks B. Stable = [{A}].""" - exts = stable_extensions(af({"A", "B"}, {("A", "B")})) - assert exts == [frozenset({"A"})] - - def test_no_attacks(self): - """No attacks. Stable = [all args] (defeats all outsiders vacuously).""" - exts = stable_extensions(af({"A", "B"}, set())) - assert exts == [frozenset({"A", "B"})] - - def test_attack_metadata_blocks_preference_filtered_joint_stable_extension(self): - """Stable sets are attack-conflict-free and defeat every outsider.""" - framework = ArgumentationFramework( - arguments=frozenset({"A", "B"}), - defeats=frozenset(), - attacks=frozenset({("A", "B")}), - ) - - assert stable_extensions(framework) == [] - - -class TestRangeSemanticsConcrete: - """Concrete examples for range-based semantics.""" - - def test_range_of_extension_is_extension_plus_defeated_arguments(self): - """Grounded in Caminada 2011 page-image page 3, Definition 2.3.""" - assert range_of( - frozenset({"A", "C"}), - frozenset({("A", "B"), ("B", "C")}), - ) == frozenset({"A", "B", "C"}) - - def test_semi_stable_coincides_with_stable_when_stable_exists(self): - """Semi-stable should agree with stable on stable-friendly AFs.""" - framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) - - assert set(semi_stable_extensions(framework)) == set( - stable_extensions(framework) - ) - - def test_semi_stable_is_complete_with_maximal_range_when_no_stable_exists(self): - """Odd cycles have no stable extension but still have semi-stable ones.""" - framework = af( - {"A", "B", "C"}, - {("A", "B"), ("B", "C"), ("C", "A")}, - ) - - assert stable_extensions(framework) == [] - assert semi_stable_extensions(framework) == [frozenset()] - - def test_stage_extensions_are_conflict_free_sets_with_maximal_range(self): - """Stage does not require completeness, so an odd cycle has singleton stages.""" - framework = af( - {"A", "B", "C"}, - {("A", "B"), ("B", "C"), ("C", "A")}, - ) - - assert set(stage_extensions(framework)) == { - frozenset({"A"}), - frozenset({"B"}), - frozenset({"C"}), - } - - def test_dispatch_supports_semi_stable_and_stage(self): - framework = af( - {"A", "B", "C"}, - {("A", "B"), ("B", "C"), ("C", "A")}, - ) - - assert extensions(framework, semantics="semi-stable") == (frozenset(),) - assert extensions(framework, semantics="stage") == ( - frozenset({"A"}), - frozenset({"B"}), - frozenset({"C"}), - ) - - -class TestIdealConcrete: - """Concrete examples for ideal semantics.""" - - def test_ideal_can_be_less_skeptical_than_grounded(self): - """Grounded in Dung, Mancarella, Toni 2007 page-image page 4.""" - framework = af( - {"a", "b", "c", "d"}, - { - ("a", "a"), - ("a", "b"), - ("b", "a"), - ("c", "d"), - ("d", "c"), - }, - ) - - assert grounded_extension(framework) == frozenset() - assert set(preferred_extensions(framework)) == { - frozenset({"b", "c"}), - frozenset({"b", "d"}), - } - assert ideal_extension(framework) == frozenset({"b"}) - - def test_ideal_can_be_proper_subset_of_preferred_intersection(self): - """Grounded in Dung, Mancarella, Toni 2007 page-image pages 4-5.""" - framework = af( - {"a", "b", "c", "d", "e", "f"}, - { - ("a", "a"), - ("a", "b"), - ("b", "a"), - ("c", "d"), - ("d", "c"), - ("c", "e"), - ("d", "e"), - ("e", "f"), - }, - ) - - assert set(preferred_extensions(framework)) == { - frozenset({"b", "c", "f"}), - frozenset({"b", "d", "f"}), - } - assert ideal_extension(framework) == frozenset({"b"}) - - def test_dispatch_supports_ideal(self): - framework = af( - {"a", "b", "c", "d"}, - { - ("a", "a"), - ("a", "b"), - ("b", "a"), - ("c", "d"), - ("d", "c"), - }, - ) - - assert extensions(framework, semantics="ideal") == (frozenset({"b"}),) - - -class TestCF2Concrete: - """Concrete examples for CF2 semantics.""" - - def test_cf2_uses_naive_sets_inside_a_single_scc(self): - """Grounded in Gaggl and Woltran 2013 page-image pages 3 and 5.""" - framework = af( - {"a", "b", "c"}, - {("a", "b"), ("b", "c"), ("c", "a")}, - ) - - assert set(cf2_extensions(framework)) == { - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - } - - def test_cf2_respects_component_defeat_across_sccs(self): - """A selected upstream argument removes downstream arguments it defeats.""" - framework = af({"a", "b"}, {("a", "b")}) - - assert cf2_extensions(framework) == [frozenset({"a"})] - - def test_cf2_matches_gaggl_woltran_2013_example_2_8(self): - """Grounded in Gaggl and Woltran 2013 page-image page 5.""" - framework = af( - {"a", "b", "c", "d", "e", "f", "g", "h", "i"}, - { - ("a", "b"), - ("b", "c"), - ("c", "a"), - ("b", "d"), - ("b", "e"), - ("d", "f"), - ("e", "f"), - ("f", "e"), - ("f", "g"), - ("g", "h"), - ("h", "i"), - ("i", "f"), - }, - ) - - assert set(cf2_extensions(framework)) == { - frozenset({"a", "d", "e", "g", "i"}), - frozenset({"b", "f", "h"}), - frozenset({"b", "g", "i"}), - frozenset({"c", "d", "e", "g", "i"}), - } - - def test_dispatch_supports_cf2(self): - framework = af( - {"a", "b", "c"}, - {("a", "b"), ("b", "c"), ("c", "a")}, - ) - - assert extensions(framework, semantics="cf2") == ( - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - ) - - -class TestCitationDocumentation: - """Documentation pins semantics choices that combine literature definitions.""" - - def test_stable_extension_attack_defeat_choice_is_cited(self): - text = Path("CITATIONS.md").read_text(encoding="utf-8") - - assert "stable_extensions" in text - assert "Dung 1995" in text - assert "Modgil" in text - assert "Prakken" in text - assert "attack-conflict-free" in text - assert "defeat every argument outside" in text - - -class TestCompleteConcrete: - """Concrete examples for complete extensions.""" - - def test_nixon_diamond(self): - """Nixon diamond: three complete extensions: {}, {A}, {B}.""" - exts = complete_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) - assert len(exts) == 3 - assert frozenset() in exts - assert frozenset({"A"}) in exts - assert frozenset({"B"}) in exts - - def test_no_attacks(self): - """No attacks. Single complete = all arguments.""" - exts = complete_extensions(af({"A", "B"}, set())) - assert exts == [frozenset({"A", "B"})] - - def test_complete_extensions_exposes_exact_enumeration_budget(self): - framework = af( - {"A", "B", "C", "D"}, - {("A", "B"), ("B", "A"), ("C", "D"), ("D", "C")}, - ) - - with pytest.raises(ExactEnumerationExceeded, match="complete labellings"): - complete_extensions(framework, max_candidates=3) - - -class TestHelpers: - """Tests for helper functions.""" - - def test_attackers_of(self): - defeats = frozenset({("A", "B"), ("C", "B"), ("A", "C")}) - assert attackers_of("B", defeats) == frozenset({"A", "C"}) - assert attackers_of("C", defeats) == frozenset({"A"}) - assert attackers_of("A", defeats) == frozenset() - - def test_conflict_free_yes(self): - defeats = frozenset({("A", "B")}) - assert conflict_free(frozenset({"A"}), defeats) is True - assert conflict_free(frozenset({"B"}), defeats) is True - - def test_conflict_free_no(self): - defeats = frozenset({("A", "B")}) - assert conflict_free(frozenset({"A", "B"}), defeats) is False - - def test_conflict_free_self_attack(self): - defeats = frozenset({("A", "A")}) - assert conflict_free(frozenset({"A"}), defeats) is False - - def test_defends(self): - all_args = frozenset({"A", "B", "C"}) - defeats = frozenset({("A", "B"), ("B", "C")}) - # A defends C because A attacks B (C's only attacker) - assert defends(frozenset({"A"}), "C", all_args, defeats) is True - # Empty set doesn't defend C (B attacks C, nothing counter-attacks B) - assert defends(frozenset(), "C", all_args, defeats) is False - - def test_characteristic_fn(self): - all_args = frozenset({"A", "B", "C"}) - defeats = frozenset({("A", "B"), ("B", "C")}) - # F({}) = {A} (A has no attackers, so defended by any set) - assert characteristic_fn(frozenset(), all_args, defeats) == frozenset({"A"}) - # F({A}) = {A, C} (A defends itself + C) - assert characteristic_fn(frozenset({"A"}), all_args, defeats) == frozenset({"A", "C"}) - - def test_admissible(self): - all_args = frozenset({"A", "B", "C"}) - defeats = frozenset({("A", "B"), ("B", "C")}) - assert admissible(frozenset({"A", "C"}), all_args, defeats) is True - assert admissible(frozenset({"B"}), all_args, defeats) is False # B attacked by A, can't defend - - -# ── Property tests ────────────────────────────────────────────────── - - -_PROP_SETTINGS = settings(deadline=None) - - -class TestDungDefinitionProperties: - """Property tests for Dung 1995 definitions.""" - - pytestmark = pytest.mark.property - - @given(framework=argumentation_frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_conflict_free_matches_dung_1995_page_326_definition(self, framework, data): - """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. - - Dung's conflict-free definition excludes every set that contains both - sides of an attack relation. - """ - candidate = _draw_subset(data, framework.arguments) - - expected = not any( - attacker in candidate and target in candidate - for attacker, target in framework.defeats - ) - - assert conflict_free(candidate, framework.defeats) is expected - - @given(framework=argumentation_frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_admissible_matches_dung_1995_page_326_definition(self, framework, data): - """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. - - An admissible set is conflict-free and every member is acceptable with - respect to that set. - """ - candidate = _draw_subset(data, framework.arguments) - - expected = conflict_free(candidate, framework.defeats) and all( - defends(candidate, argument, framework.arguments, framework.defeats) - for argument in candidate - ) - - assert admissible(candidate, framework.arguments, framework.defeats) is expected - - @given(argumentation_frameworks(max_args=6)) - @_PROP_SETTINGS - def test_fundamental_lemma_dung_1995_page_327(self, framework): - """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. - - If an admissible set S defends A and B, then S plus A remains - admissible and still defends B. - """ - for candidate in _all_subsets(framework.arguments): - if not admissible(candidate, framework.arguments, framework.defeats): - continue - acceptable = characteristic_fn(candidate, framework.arguments, framework.defeats) - for accepted_argument in acceptable: - enlarged = candidate | {accepted_argument} - assert admissible(enlarged, framework.arguments, framework.defeats) - for still_accepted in acceptable: - assert defends( - enlarged, - still_accepted, - framework.arguments, - framework.defeats, - ) - - -class TestGroundedProperties: - """Property tests for grounded extension (Dung 1995 theorems).""" - - pytestmark = pytest.mark.property - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_conflict_free(self, framework): - """P1: Grounded extension is conflict-free. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. - """ - ext = grounded_extension(framework) - assert conflict_free(ext, framework.defeats) - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_admissible(self, framework): - """P2: Grounded extension is admissible. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. - """ - ext = grounded_extension(framework) - assert admissible(ext, framework.arguments, framework.defeats) - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_fixed_point(self, framework): - """P7: Grounded is fixed point of F. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. - """ - ext = grounded_extension(framework) - assert characteristic_fn(ext, framework.arguments, framework.defeats) == ext - - @given(argumentation_frameworks(max_args=6)) - @_PROP_SETTINGS - def test_grounded_is_least_fixed_point_dung_1995_page_329(self, framework): - """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`.""" - grounded = grounded_extension(framework) - fixed_points = [ - candidate - for candidate in _all_subsets(framework.arguments) - if characteristic_fn(candidate, framework.arguments, framework.defeats) == candidate - ] - - assert fixed_points - assert all(grounded <= fixed_point for fixed_point in fixed_points) - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_subset_of_every_preferred(self, framework): - """P3: Grounded is contained in every preferred extension. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. - """ - grounded = grounded_extension(framework) - for pref in preferred_extensions(framework): - assert grounded <= pref - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_no_attacks_means_all_in(self, framework): - """P10: Empty defeat set → grounded = all arguments.""" - assume(len(framework.defeats) == 0) - assert grounded_extension(framework) == framework.arguments - - -class TestPreferredProperties: - """Property tests for preferred extensions.""" - - pytestmark = pytest.mark.property - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_all_admissible(self, framework): - """P4: Every preferred extension is admissible. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. - """ - for ext in preferred_extensions(framework): - assert admissible(ext, framework.arguments, framework.defeats) - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_maximal_admissible(self, framework): - """P12: Preferred extensions are maximal admissible sets.""" - prefs = preferred_extensions(framework) - for ext in prefs: - # No proper superset of ext should be admissible - for arg in framework.arguments - ext: - candidate = ext | {arg} - if admissible(candidate, framework.arguments, framework.defeats): - # candidate is admissible and strictly larger — ext is not maximal - # But it must be a subset of some other preferred extension - assert any(candidate <= other for other in prefs) - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_at_least_one(self, framework): - """Every AF has at least one preferred extension. - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. - """ - assert len(preferred_extensions(framework)) >= 1 - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_are_complete(self, framework): - """P5/P9: Every preferred extension is a complete extension.""" - prefs = set(preferred_extensions(framework)) - comps = set(complete_extensions(framework)) - for p in prefs: - assert p in comps - - -class TestStableProperties: - """Property tests for stable extensions.""" - - pytestmark = pytest.mark.property - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_implies_preferred(self, framework): - """P5: Every stable extension is a preferred extension (Thm 13).""" - stables = set(stable_extensions(framework)) - prefs = set(preferred_extensions(framework)) - for s in stables: - assert s in prefs - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_conflict_free_and_defeats_all_outsiders(self, framework): - """P6: Stable = conflict-free + defeats all outsiders (Def 12).""" - for ext in stable_extensions(framework): - assert conflict_free(ext, framework.defeats) - outsiders = framework.arguments - ext - for out in outsiders: - assert any((a, out) in framework.defeats for a in ext) - - -class TestCompleteProperties: - """Property tests for complete extensions.""" - - pytestmark = pytest.mark.property - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_are_fixed_points(self, framework): - """P8: Complete extensions are fixed points of F (Def 10).""" - for ext in complete_extensions(framework): - assert characteristic_fn(ext, framework.arguments, framework.defeats) == ext - - @given(argumentation_frameworks()) - @_PROP_SETTINGS - def test_grounded_is_least_complete(self, framework): - """Grounded is the least (smallest) complete extension.""" - grounded = grounded_extension(framework) - for ext in complete_extensions(framework): - assert grounded <= ext - - -class TestNewSemanticsProperties: - """Property coverage for non-Dung-1995 semantics added in this workstream.""" - - pytestmark = pytest.mark.property - - @given(argumentation_frameworks(max_args=5)) - @_PROP_SETTINGS - def test_semi_stable_extensions_are_complete_with_maximal_range(self, framework): - completes = complete_extensions(framework) - complete_ranges = { - complete: _definition_range(complete, framework.defeats) - for complete in completes - } - - for extension in semi_stable_extensions(framework): - assert extension in completes - extension_range = complete_ranges[extension] - assert not any( - extension_range < other_range - for other_range in complete_ranges.values() - ) - - @given(argumentation_frameworks(max_args=5)) - @_PROP_SETTINGS - def test_stage_extensions_are_conflict_free_with_maximal_range(self, framework): - conflict_free_sets = [ - candidate - for candidate in _all_subsets(framework.arguments) - if conflict_free(candidate, framework.defeats) - ] - ranges = { - candidate: _definition_range(candidate, framework.defeats) - for candidate in conflict_free_sets - } - - for extension in stage_extensions(framework): - assert extension in conflict_free_sets - extension_range = ranges[extension] - assert not any( - extension_range < other_range - for other_range in ranges.values() - ) - - @given(argumentation_frameworks(max_args=5)) - @_PROP_SETTINGS - def test_ideal_is_maximal_admissible_subset_of_every_preferred(self, framework): - ideal = ideal_extension(framework) - preferred = preferred_extensions(framework) - - assert admissible(ideal, framework.arguments, framework.defeats) - assert all(ideal <= extension for extension in preferred) - - for candidate in _all_subsets(framework.arguments): - if not admissible(candidate, framework.arguments, framework.defeats): - continue - if all(candidate <= extension for extension in preferred): - assert candidate <= ideal - - @given(argumentation_frameworks(max_args=5)) - @_PROP_SETTINGS - def test_cf2_extensions_are_conflict_free(self, framework): - for extension in cf2_extensions(framework): - assert conflict_free(extension, framework.defeats) - - -class TestCharacteristicFnProperties: - """Property tests for the characteristic function.""" - - pytestmark = pytest.mark.property - - @given(framework=argumentation_frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_monotone(self, framework, data): - """F is monotone: if S1 is a subset of S2, then F(S1) is a subset of F(S2). - - Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. - """ - args = framework.arguments - defeats = framework.defeats - lower = _draw_subset(data, args) - remaining = args - lower - extra = ( - frozenset(data.draw(st.sets(st.sampled_from(sorted(remaining)), max_size=len(remaining)))) - if remaining - else frozenset() - ) - upper = lower | extra - - assert characteristic_fn(lower, args, defeats) <= characteristic_fn(upper, args, defeats) - - -class TestSingleDungPath: - def test_complete_labelling_path_handles_small_framework(self): - framework = af({"A", "B"}, {("A", "B")}) - - assert set(complete_extensions(framework)) == { - frozenset({"A"}), - } - - def test_complete_labelling_path_handles_larger_framework(self): - args = {f"a{i}" for i in range(7)} - framework = af(args, {(f"a{i}", f"a{(i + 1) % 7}") for i in range(7)}) - - assert complete_extensions(framework) == [frozenset()] - - def test_preferred_labelling_path_handles_reinstatement(self): - framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) - - assert preferred_extensions(framework) == [frozenset({"A", "C"})] - - def test_stable_labelling_path_handles_odd_cycle(self): - framework = af( - {f"a{i}" for i in range(7)}, - {(f"a{i}", f"a{(i + 1) % 7}") for i in range(7)}, - ) - - assert stable_extensions(framework) == [] - - -# ── Attacks ≠ Defeats property tests (F27) ───────────────────────── - - -@st.composite -def af_with_attacks_superset(draw, max_args=6): - """Generate AFs where attacks is a superset of defeats. - - Some attacks are filtered by preference, producing a strict - subset as defeats. This exercises the post-hoc attack-CF - pruning path in grounded_extension (dung.py lines 126-150). - """ - args = draw( - st.frozensets( - st.text(alphabet="abcdef", min_size=1, max_size=2), - min_size=1, - max_size=max_args, - ) - ) - arg_list = sorted(args) - # Generate all attacks first - all_attacks = draw( - st.frozensets( - st.tuples( - st.sampled_from(arg_list), - st.sampled_from(arg_list), - ), - max_size=len(arg_list) ** 2, - ) - ) - # Defeats is a subset of attacks (some attacks filtered by preference) - defeats = draw( - st.frozensets( - st.sampled_from(sorted(all_attacks)) if all_attacks else st.nothing(), - max_size=len(all_attacks), - ) - ) - return ArgumentationFramework( - arguments=args, - defeats=defeats, - attacks=all_attacks, - ) +"""Tests for Dung's abstract argumentation semantics. + +Property-based tests verify formal theorems from: + Dung, P.M. (1995). On the acceptability of arguments and its + fundamental role in nonmonotonic reasoning, logic programming + and n-person games. Artificial Intelligence, 77(2), 321-357. + +Concrete regression tests use known examples from the paper. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +from argumentation.core.dung import ( + ArgumentationFramework, + attackers_of, + characteristic_fn, + admissible, + cf2_extensions, + conflict_free, + complete_extensions, + defends, + grounded_extension, + ideal_extension, + preferred_extensions, + range_of, + semi_stable_extensions, + stage_extensions, + stable_extensions, +) +from argumentation.semantics import extensions +from argumentation.core.labelling import ExactEnumerationExceeded + + +# ── Hypothesis strategies ─────────────────────────────────────────── + + +@st.composite +def argumentation_frameworks(draw, max_args=8): + """Generate random argumentation frameworks for property testing.""" + args = draw( + st.frozensets( + st.text(alphabet="abcdefgh", min_size=1, max_size=2), + min_size=1, + max_size=max_args, + ) + ) + arg_list = sorted(args) + attacks = draw( + st.frozensets( + st.tuples( + st.sampled_from(arg_list), + st.sampled_from(arg_list), + ), + max_size=len(arg_list) ** 2, + ) + ) + return ArgumentationFramework(arguments=args, defeats=attacks) + + +def _all_subsets(arguments: frozenset[str]) -> list[frozenset[str]]: + ordered = sorted(arguments) + return [ + frozenset(arg for index, arg in enumerate(ordered) if mask & (1 << index)) + for mask in range(1 << len(ordered)) + ] + + +def _definition_range( + extension: frozenset[str], + defeats: frozenset[tuple[str, str]], +) -> frozenset[str]: + return extension | frozenset( + target + for attacker, target in defeats + if attacker in extension + ) + + +def _draw_subset(data: st.DataObject, arguments: frozenset[str]) -> frozenset[str]: + return frozenset(data.draw(st.sets(st.sampled_from(sorted(arguments)), max_size=len(arguments)))) + + +# ── Concrete regression tests ─────────────────────────────────────── + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + """Shorthand for building an AF from plain sets.""" + return ArgumentationFramework( + arguments=frozenset(args), + defeats=frozenset(defeats), + ) + + +class TestGroundedConcrete: + """Concrete examples for grounded extension.""" + + def test_empty_framework(self): + """Empty AF has empty grounded extension.""" + assert grounded_extension(af(set(), set())) == frozenset() + + def test_unattacked_wins(self): + """A attacks B, nothing attacks A. Grounded = {A}.""" + assert grounded_extension(af({"A", "B"}, {("A", "B")})) == frozenset({"A"}) + + def test_nixon_diamond(self): + """A attacks B, B attacks A. Grounded = {} (mutual defeat).""" + assert grounded_extension(af({"A", "B"}, {("A", "B"), ("B", "A")})) == frozenset() + + def test_reinstatement(self): + """A attacks B, B attacks C. A reinstates C. Grounded = {A, C}.""" + result = grounded_extension(af({"A", "B", "C"}, {("A", "B"), ("B", "C")})) + assert result == frozenset({"A", "C"}) + + def test_odd_cycle(self): + """A->B->C->A. Grounded = {} (no unattacked argument).""" + result = grounded_extension( + af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}) + ) + assert result == frozenset() + + def test_self_attacker(self): + """A attacks itself. Grounded = {}.""" + assert grounded_extension(af({"A"}, {("A", "A")})) == frozenset() + + def test_floating_defeat(self): + """A attacks B and C, B and C attack each other. Grounded = {A}.""" + result = grounded_extension( + af({"A", "B", "C"}, {("A", "B"), ("A", "C"), ("B", "C"), ("C", "B")}) + ) + assert result == frozenset({"A"}) + + def test_no_attacks(self): + """No attacks means all arguments are in grounded extension.""" + assert grounded_extension(af({"A", "B", "C"}, set())) == frozenset({"A", "B", "C"}) + + def test_chain_of_four(self): + """A->B->C->D. Grounded = {A, C}. A and C are defended.""" + result = grounded_extension( + af({"A", "B", "C", "D"}, {("A", "B"), ("B", "C"), ("C", "D")}) + ) + assert result == frozenset({"A", "C"}) + + def test_grounded_ignores_attack_metadata(self): + """Grounded semantics always uses defeats, even when attacks are present.""" + fw = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset(), + attacks=frozenset({("A", "B")}), + ) + assert grounded_extension(fw) == frozenset({"A", "B"}) + + +class TestFrameworkValidation: + def test_defeats_must_reference_declared_arguments(self) -> None: + with pytest.raises(ValueError, match="defeats"): + ArgumentationFramework( + arguments=frozenset({"A"}), + defeats=frozenset({("A", "B")}), + ) + + def test_attacks_must_reference_declared_arguments(self) -> None: + with pytest.raises(ValueError, match="attacks"): + ArgumentationFramework( + arguments=frozenset({"A"}), + defeats=frozenset(), + attacks=frozenset({("B", "A")}), + ) + + +class TestPreferredConcrete: + """Concrete examples for preferred extensions.""" + + def test_nixon_diamond(self): + """Nixon diamond has two preferred extensions: {A} and {B}.""" + exts = preferred_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) + assert len(exts) == 2 + assert frozenset({"A"}) in exts + assert frozenset({"B"}) in exts + + def test_unattacked_wins(self): + """A attacks B. Single preferred = {A}.""" + exts = preferred_extensions(af({"A", "B"}, {("A", "B")})) + assert exts == [frozenset({"A"})] + + def test_reinstatement(self): + """A->B->C. Single preferred = {A, C}.""" + exts = preferred_extensions( + af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) + ) + assert exts == [frozenset({"A", "C"})] + + def test_self_attacker(self): + """Self-attacking A. Preferred = [{}] (empty set is maximal admissible).""" + exts = preferred_extensions(af({"A"}, {("A", "A")})) + assert exts == [frozenset()] + + def test_no_attacks(self): + """No attacks. Single preferred = all arguments.""" + exts = preferred_extensions(af({"A", "B"}, set())) + assert exts == [frozenset({"A", "B"})] + + +class TestStableConcrete: + """Concrete examples for stable extensions.""" + + def test_nixon_diamond(self): + """Nixon diamond: two stable extensions {A} and {B}.""" + exts = stable_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) + assert len(exts) == 2 + assert frozenset({"A"}) in exts + assert frozenset({"B"}) in exts + + def test_odd_cycle_no_stable(self): + """A->B->C->A. No stable extension exists.""" + exts = stable_extensions( + af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}) + ) + assert exts == [] + + def test_self_attacker_no_stable(self): + """Self-attacking A. No stable extension (can't defeat A from empty set).""" + exts = stable_extensions(af({"A"}, {("A", "A")})) + assert exts == [] + + def test_unattacked_wins(self): + """A attacks B. Stable = [{A}].""" + exts = stable_extensions(af({"A", "B"}, {("A", "B")})) + assert exts == [frozenset({"A"})] + + def test_no_attacks(self): + """No attacks. Stable = [all args] (defeats all outsiders vacuously).""" + exts = stable_extensions(af({"A", "B"}, set())) + assert exts == [frozenset({"A", "B"})] + + def test_attack_metadata_blocks_preference_filtered_joint_stable_extension(self): + """Stable sets are attack-conflict-free and defeat every outsider.""" + framework = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset(), + attacks=frozenset({("A", "B")}), + ) + + assert stable_extensions(framework) == [] + + +class TestRangeSemanticsConcrete: + """Concrete examples for range-based semantics.""" + + def test_range_of_extension_is_extension_plus_defeated_arguments(self): + """Grounded in Caminada 2011 page-image page 3, Definition 2.3.""" + assert range_of( + frozenset({"A", "C"}), + frozenset({("A", "B"), ("B", "C")}), + ) == frozenset({"A", "B", "C"}) + + def test_semi_stable_coincides_with_stable_when_stable_exists(self): + """Semi-stable should agree with stable on stable-friendly AFs.""" + framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) + + assert set(semi_stable_extensions(framework)) == set( + stable_extensions(framework) + ) + + def test_semi_stable_is_complete_with_maximal_range_when_no_stable_exists(self): + """Odd cycles have no stable extension but still have semi-stable ones.""" + framework = af( + {"A", "B", "C"}, + {("A", "B"), ("B", "C"), ("C", "A")}, + ) + + assert stable_extensions(framework) == [] + assert semi_stable_extensions(framework) == [frozenset()] + + def test_stage_extensions_are_conflict_free_sets_with_maximal_range(self): + """Stage does not require completeness, so an odd cycle has singleton stages.""" + framework = af( + {"A", "B", "C"}, + {("A", "B"), ("B", "C"), ("C", "A")}, + ) + + assert set(stage_extensions(framework)) == { + frozenset({"A"}), + frozenset({"B"}), + frozenset({"C"}), + } + + def test_dispatch_supports_semi_stable_and_stage(self): + framework = af( + {"A", "B", "C"}, + {("A", "B"), ("B", "C"), ("C", "A")}, + ) + + assert extensions(framework, semantics="semi-stable") == (frozenset(),) + assert extensions(framework, semantics="stage") == ( + frozenset({"A"}), + frozenset({"B"}), + frozenset({"C"}), + ) + + +class TestIdealConcrete: + """Concrete examples for ideal semantics.""" + + def test_ideal_can_be_less_skeptical_than_grounded(self): + """Grounded in Dung, Mancarella, Toni 2007 page-image page 4.""" + framework = af( + {"a", "b", "c", "d"}, + { + ("a", "a"), + ("a", "b"), + ("b", "a"), + ("c", "d"), + ("d", "c"), + }, + ) + + assert grounded_extension(framework) == frozenset() + assert set(preferred_extensions(framework)) == { + frozenset({"b", "c"}), + frozenset({"b", "d"}), + } + assert ideal_extension(framework) == frozenset({"b"}) + + def test_ideal_can_be_proper_subset_of_preferred_intersection(self): + """Grounded in Dung, Mancarella, Toni 2007 page-image pages 4-5.""" + framework = af( + {"a", "b", "c", "d", "e", "f"}, + { + ("a", "a"), + ("a", "b"), + ("b", "a"), + ("c", "d"), + ("d", "c"), + ("c", "e"), + ("d", "e"), + ("e", "f"), + }, + ) + + assert set(preferred_extensions(framework)) == { + frozenset({"b", "c", "f"}), + frozenset({"b", "d", "f"}), + } + assert ideal_extension(framework) == frozenset({"b"}) + + def test_dispatch_supports_ideal(self): + framework = af( + {"a", "b", "c", "d"}, + { + ("a", "a"), + ("a", "b"), + ("b", "a"), + ("c", "d"), + ("d", "c"), + }, + ) + + assert extensions(framework, semantics="ideal") == (frozenset({"b"}),) + + +class TestCF2Concrete: + """Concrete examples for CF2 semantics.""" + + def test_cf2_uses_naive_sets_inside_a_single_scc(self): + """Grounded in Gaggl and Woltran 2013 page-image pages 3 and 5.""" + framework = af( + {"a", "b", "c"}, + {("a", "b"), ("b", "c"), ("c", "a")}, + ) + + assert set(cf2_extensions(framework)) == { + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + } + + def test_cf2_respects_component_defeat_across_sccs(self): + """A selected upstream argument removes downstream arguments it defeats.""" + framework = af({"a", "b"}, {("a", "b")}) + + assert cf2_extensions(framework) == [frozenset({"a"})] + + def test_cf2_matches_gaggl_woltran_2013_example_2_8(self): + """Grounded in Gaggl and Woltran 2013 page-image page 5.""" + framework = af( + {"a", "b", "c", "d", "e", "f", "g", "h", "i"}, + { + ("a", "b"), + ("b", "c"), + ("c", "a"), + ("b", "d"), + ("b", "e"), + ("d", "f"), + ("e", "f"), + ("f", "e"), + ("f", "g"), + ("g", "h"), + ("h", "i"), + ("i", "f"), + }, + ) + + assert set(cf2_extensions(framework)) == { + frozenset({"a", "d", "e", "g", "i"}), + frozenset({"b", "f", "h"}), + frozenset({"b", "g", "i"}), + frozenset({"c", "d", "e", "g", "i"}), + } + + def test_dispatch_supports_cf2(self): + framework = af( + {"a", "b", "c"}, + {("a", "b"), ("b", "c"), ("c", "a")}, + ) + + assert extensions(framework, semantics="cf2") == ( + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + ) + + +class TestCitationDocumentation: + """Documentation pins semantics choices that combine literature definitions.""" + + def test_stable_extension_attack_defeat_choice_is_cited(self): + text = Path("CITATIONS.md").read_text(encoding="utf-8") + + assert "stable_extensions" in text + assert "Dung 1995" in text + assert "Modgil" in text + assert "Prakken" in text + assert "attack-conflict-free" in text + assert "defeat every argument outside" in text + + +class TestCompleteConcrete: + """Concrete examples for complete extensions.""" + + def test_nixon_diamond(self): + """Nixon diamond: three complete extensions: {}, {A}, {B}.""" + exts = complete_extensions(af({"A", "B"}, {("A", "B"), ("B", "A")})) + assert len(exts) == 3 + assert frozenset() in exts + assert frozenset({"A"}) in exts + assert frozenset({"B"}) in exts + + def test_no_attacks(self): + """No attacks. Single complete = all arguments.""" + exts = complete_extensions(af({"A", "B"}, set())) + assert exts == [frozenset({"A", "B"})] + + def test_complete_extensions_exposes_exact_enumeration_budget(self): + framework = af( + {"A", "B", "C", "D"}, + {("A", "B"), ("B", "A"), ("C", "D"), ("D", "C")}, + ) + + with pytest.raises(ExactEnumerationExceeded, match="complete labellings"): + complete_extensions(framework, max_candidates=3) + + +class TestHelpers: + """Tests for helper functions.""" + + def test_attackers_of(self): + defeats = frozenset({("A", "B"), ("C", "B"), ("A", "C")}) + assert attackers_of("B", defeats) == frozenset({"A", "C"}) + assert attackers_of("C", defeats) == frozenset({"A"}) + assert attackers_of("A", defeats) == frozenset() + + def test_conflict_free_yes(self): + defeats = frozenset({("A", "B")}) + assert conflict_free(frozenset({"A"}), defeats) is True + assert conflict_free(frozenset({"B"}), defeats) is True + + def test_conflict_free_no(self): + defeats = frozenset({("A", "B")}) + assert conflict_free(frozenset({"A", "B"}), defeats) is False + + def test_conflict_free_self_attack(self): + defeats = frozenset({("A", "A")}) + assert conflict_free(frozenset({"A"}), defeats) is False + + def test_defends(self): + all_args = frozenset({"A", "B", "C"}) + defeats = frozenset({("A", "B"), ("B", "C")}) + # A defends C because A attacks B (C's only attacker) + assert defends(frozenset({"A"}), "C", all_args, defeats) is True + # Empty set doesn't defend C (B attacks C, nothing counter-attacks B) + assert defends(frozenset(), "C", all_args, defeats) is False + + def test_characteristic_fn(self): + all_args = frozenset({"A", "B", "C"}) + defeats = frozenset({("A", "B"), ("B", "C")}) + # F({}) = {A} (A has no attackers, so defended by any set) + assert characteristic_fn(frozenset(), all_args, defeats) == frozenset({"A"}) + # F({A}) = {A, C} (A defends itself + C) + assert characteristic_fn(frozenset({"A"}), all_args, defeats) == frozenset({"A", "C"}) + + def test_admissible(self): + all_args = frozenset({"A", "B", "C"}) + defeats = frozenset({("A", "B"), ("B", "C")}) + assert admissible(frozenset({"A", "C"}), all_args, defeats) is True + assert admissible(frozenset({"B"}), all_args, defeats) is False # B attacked by A, can't defend + + +# ── Property tests ────────────────────────────────────────────────── + + +_PROP_SETTINGS = settings(deadline=None) + + +class TestDungDefinitionProperties: + """Property tests for Dung 1995 definitions.""" + + pytestmark = pytest.mark.property + + @given(framework=argumentation_frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_conflict_free_matches_dung_1995_page_326_definition(self, framework, data): + """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. + + Dung's conflict-free definition excludes every set that contains both + sides of an attack relation. + """ + candidate = _draw_subset(data, framework.arguments) + + expected = not any( + attacker in candidate and target in candidate + for attacker, target in framework.defeats + ) + + assert conflict_free(candidate, framework.defeats) is expected + + @given(framework=argumentation_frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_admissible_matches_dung_1995_page_326_definition(self, framework, data): + """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. + + An admissible set is conflict-free and every member is acceptable with + respect to that set. + """ + candidate = _draw_subset(data, framework.arguments) + + expected = conflict_free(candidate, framework.defeats) and all( + defends(candidate, argument, framework.arguments, framework.defeats) + for argument in candidate + ) + + assert admissible(candidate, framework.arguments, framework.defeats) is expected + + @given(argumentation_frameworks(max_args=6)) + @_PROP_SETTINGS + def test_fundamental_lemma_dung_1995_page_327(self, framework): + """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. + + If an admissible set S defends A and B, then S plus A remains + admissible and still defends B. + """ + for candidate in _all_subsets(framework.arguments): + if not admissible(candidate, framework.arguments, framework.defeats): + continue + acceptable = characteristic_fn(candidate, framework.arguments, framework.defeats) + for accepted_argument in acceptable: + enlarged = candidate | {accepted_argument} + assert admissible(enlarged, framework.arguments, framework.defeats) + for still_accepted in acceptable: + assert defends( + enlarged, + still_accepted, + framework.arguments, + framework.defeats, + ) + + +class TestGroundedProperties: + """Property tests for grounded extension (Dung 1995 theorems).""" + + pytestmark = pytest.mark.property + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_conflict_free(self, framework): + """P1: Grounded extension is conflict-free. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. + """ + ext = grounded_extension(framework) + assert conflict_free(ext, framework.defeats) + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_admissible(self, framework): + """P2: Grounded extension is admissible. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-005.png`. + """ + ext = grounded_extension(framework) + assert admissible(ext, framework.arguments, framework.defeats) + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_fixed_point(self, framework): + """P7: Grounded is fixed point of F. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. + """ + ext = grounded_extension(framework) + assert characteristic_fn(ext, framework.arguments, framework.defeats) == ext + + @given(argumentation_frameworks(max_args=6)) + @_PROP_SETTINGS + def test_grounded_is_least_fixed_point_dung_1995_page_329(self, framework): + """Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`.""" + grounded = grounded_extension(framework) + fixed_points = [ + candidate + for candidate in _all_subsets(framework.arguments) + if characteristic_fn(candidate, framework.arguments, framework.defeats) == candidate + ] + + assert fixed_points + assert all(grounded <= fixed_point for fixed_point in fixed_points) + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_subset_of_every_preferred(self, framework): + """P3: Grounded is contained in every preferred extension. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. + """ + grounded = grounded_extension(framework) + for pref in preferred_extensions(framework): + assert grounded <= pref + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_no_attacks_means_all_in(self, framework): + """P10: Empty defeat set → grounded = all arguments.""" + assume(len(framework.defeats) == 0) + assert grounded_extension(framework) == framework.arguments + + +class TestPreferredProperties: + """Property tests for preferred extensions.""" + + pytestmark = pytest.mark.property + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_all_admissible(self, framework): + """P4: Every preferred extension is admissible. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. + """ + for ext in preferred_extensions(framework): + assert admissible(ext, framework.arguments, framework.defeats) + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_maximal_admissible(self, framework): + """P12: Preferred extensions are maximal admissible sets.""" + prefs = preferred_extensions(framework) + for ext in prefs: + # No proper superset of ext should be admissible + for arg in framework.arguments - ext: + candidate = ext | {arg} + if admissible(candidate, framework.arguments, framework.defeats): + # candidate is admissible and strictly larger — ext is not maximal + # But it must be a subset of some other preferred extension + assert any(candidate <= other for other in prefs) + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_at_least_one(self, framework): + """Every AF has at least one preferred extension. + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-006.png`. + """ + assert len(preferred_extensions(framework)) >= 1 + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_are_complete(self, framework): + """P5/P9: Every preferred extension is a complete extension.""" + prefs = set(preferred_extensions(framework)) + comps = set(complete_extensions(framework)) + for p in prefs: + assert p in comps + + +class TestStableProperties: + """Property tests for stable extensions.""" + + pytestmark = pytest.mark.property + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_implies_preferred(self, framework): + """P5: Every stable extension is a preferred extension (Thm 13).""" + stables = set(stable_extensions(framework)) + prefs = set(preferred_extensions(framework)) + for s in stables: + assert s in prefs + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_conflict_free_and_defeats_all_outsiders(self, framework): + """P6: Stable = conflict-free + defeats all outsiders (Def 12).""" + for ext in stable_extensions(framework): + assert conflict_free(ext, framework.defeats) + outsiders = framework.arguments - ext + for out in outsiders: + assert any((a, out) in framework.defeats for a in ext) + + +class TestCompleteProperties: + """Property tests for complete extensions.""" + + pytestmark = pytest.mark.property + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_are_fixed_points(self, framework): + """P8: Complete extensions are fixed points of F (Def 10).""" + for ext in complete_extensions(framework): + assert characteristic_fn(ext, framework.arguments, framework.defeats) == ext + + @given(argumentation_frameworks()) + @_PROP_SETTINGS + def test_grounded_is_least_complete(self, framework): + """Grounded is the least (smallest) complete extension.""" + grounded = grounded_extension(framework) + for ext in complete_extensions(framework): + assert grounded <= ext + + +class TestNewSemanticsProperties: + """Property coverage for non-Dung-1995 semantics added in this workstream.""" + + pytestmark = pytest.mark.property + + @given(argumentation_frameworks(max_args=5)) + @_PROP_SETTINGS + def test_semi_stable_extensions_are_complete_with_maximal_range(self, framework): + completes = complete_extensions(framework) + complete_ranges = { + complete: _definition_range(complete, framework.defeats) + for complete in completes + } + + for extension in semi_stable_extensions(framework): + assert extension in completes + extension_range = complete_ranges[extension] + assert not any( + extension_range < other_range + for other_range in complete_ranges.values() + ) + + @given(argumentation_frameworks(max_args=5)) + @_PROP_SETTINGS + def test_stage_extensions_are_conflict_free_with_maximal_range(self, framework): + conflict_free_sets = [ + candidate + for candidate in _all_subsets(framework.arguments) + if conflict_free(candidate, framework.defeats) + ] + ranges = { + candidate: _definition_range(candidate, framework.defeats) + for candidate in conflict_free_sets + } + + for extension in stage_extensions(framework): + assert extension in conflict_free_sets + extension_range = ranges[extension] + assert not any( + extension_range < other_range + for other_range in ranges.values() + ) + + @given(argumentation_frameworks(max_args=5)) + @_PROP_SETTINGS + def test_ideal_is_maximal_admissible_subset_of_every_preferred(self, framework): + ideal = ideal_extension(framework) + preferred = preferred_extensions(framework) + + assert admissible(ideal, framework.arguments, framework.defeats) + assert all(ideal <= extension for extension in preferred) + + for candidate in _all_subsets(framework.arguments): + if not admissible(candidate, framework.arguments, framework.defeats): + continue + if all(candidate <= extension for extension in preferred): + assert candidate <= ideal + + @given(argumentation_frameworks(max_args=5)) + @_PROP_SETTINGS + def test_cf2_extensions_are_conflict_free(self, framework): + for extension in cf2_extensions(framework): + assert conflict_free(extension, framework.defeats) + + +class TestCharacteristicFnProperties: + """Property tests for the characteristic function.""" + + pytestmark = pytest.mark.property + + @given(framework=argumentation_frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_monotone(self, framework, data): + """F is monotone: if S1 is a subset of S2, then F(S1) is a subset of F(S2). + + Grounded in `papers/Dung_1995_AcceptabilityArguments/pngs/page-008.png`. + """ + args = framework.arguments + defeats = framework.defeats + lower = _draw_subset(data, args) + remaining = args - lower + extra = ( + frozenset(data.draw(st.sets(st.sampled_from(sorted(remaining)), max_size=len(remaining)))) + if remaining + else frozenset() + ) + upper = lower | extra + + assert characteristic_fn(lower, args, defeats) <= characteristic_fn(upper, args, defeats) + + +class TestSingleDungPath: + def test_complete_labelling_path_handles_small_framework(self): + framework = af({"A", "B"}, {("A", "B")}) + + assert set(complete_extensions(framework)) == { + frozenset({"A"}), + } + + def test_complete_labelling_path_handles_larger_framework(self): + args = {f"a{i}" for i in range(7)} + framework = af(args, {(f"a{i}", f"a{(i + 1) % 7}") for i in range(7)}) + + assert complete_extensions(framework) == [frozenset()] + + def test_preferred_labelling_path_handles_reinstatement(self): + framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) + + assert preferred_extensions(framework) == [frozenset({"A", "C"})] + + def test_stable_labelling_path_handles_odd_cycle(self): + framework = af( + {f"a{i}" for i in range(7)}, + {(f"a{i}", f"a{(i + 1) % 7}") for i in range(7)}, + ) + + assert stable_extensions(framework) == [] + + +# ── Attacks ≠ Defeats property tests (F27) ───────────────────────── + + +@st.composite +def af_with_attacks_superset(draw, max_args=6): + """Generate AFs where attacks is a superset of defeats. + + Some attacks are filtered by preference, producing a strict + subset as defeats. This exercises the post-hoc attack-CF + pruning path in grounded_extension (dung.py lines 126-150). + """ + args = draw( + st.frozensets( + st.text(alphabet="abcdef", min_size=1, max_size=2), + min_size=1, + max_size=max_args, + ) + ) + arg_list = sorted(args) + # Generate all attacks first + all_attacks = draw( + st.frozensets( + st.tuples( + st.sampled_from(arg_list), + st.sampled_from(arg_list), + ), + max_size=len(arg_list) ** 2, + ) + ) + # Defeats is a subset of attacks (some attacks filtered by preference) + defeats = draw( + st.frozensets( + st.sampled_from(sorted(all_attacks)) if all_attacks else st.nothing(), + max_size=len(all_attacks), + ) + ) + return ArgumentationFramework( + arguments=args, + defeats=defeats, + attacks=all_attacks, + ) diff --git a/tests/test_dung_extensions_workstream.py b/tests/core/test_dung_extensions_workstream.py similarity index 95% rename from tests/test_dung_extensions_workstream.py rename to tests/core/test_dung_extensions_workstream.py index 3bab612..e754abc 100644 --- a/tests/test_dung_extensions_workstream.py +++ b/tests/core/test_dung_extensions_workstream.py @@ -1,173 +1,173 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.bipolar import ( - BipolarArgumentationFramework, - bipolar_complete_extensions, - bipolar_grounded_extension, - characteristic_fn as bipolar_characteristic_fn, -) -from argumentation.dung import ( - ArgumentationFramework, - admissible, - complete_extensions, - eager_extension, - indirect_attacks, - preferred_extensions, - conflict_free, - prudent_admissible, - prudent_conflict_free, - prudent_grounded_extension, - prudent_preferred_extensions, - semi_stable_extensions, - stage2_extensions, - stage_extensions, -) -from argumentation.labelling import complete_labellings - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def test_no_z3_dung_module_or_solver_surface() -> None: - """Codex 2.17 deletion gate: no Dung extension-semantics Z3 backend remains.""" - root = Path("src/argumentation") - assert not (root / "dung_z3.py").exists() - forbidden = ( - "argumentation.dung_z3", - "_AUTO_BACKEND_MAX_ARGS", - "z3_complete_extensions", - "z3_preferred_extensions", - "z3_stable_extensions", - "backend=\"z3\"", - ) - for path in root.rglob("*.py"): - text = path.read_text(encoding="utf-8") - for needle in forbidden: - assert needle not in text, f"{needle} remains in {path}" - - -def test_semi_stable_floating_reinstatement_caminada_2006_page_8() -> None: - """Caminada 2006, p. 8: floating `A`/`B` choices reinstate `D`.""" - framework = af( - {"A", "B", "C", "D"}, - {("A", "B"), ("B", "A"), ("A", "C"), ("B", "C"), ("C", "D")}, - ) - - assert set(semi_stable_extensions(framework)) == { - frozenset({"A", "D"}), - frozenset({"B", "D"}), - } - - -def test_eager_selects_largest_admissible_subset_of_semi_stable_intersection() -> None: - """Caminada 2006 p. 8 plus Caminada 2007 eager: reject undefended common `D`.""" - framework = af( - {"A", "B", "C", "D"}, - {("A", "B"), ("B", "A"), ("A", "C"), ("B", "C"), ("C", "D")}, - ) - - assert eager_extension(framework) == frozenset() - assert admissible(eager_extension(framework), framework.arguments, framework.defeats) - - -def test_stage2_collapses_to_stage_on_single_scc_gaggl_2013_pages_927_929() -> None: - """Gaggl and Woltran 2013, pp. 927-929: SCC base case is local semantics.""" - framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) - - assert set(stage2_extensions(framework)) == set(stage_extensions(framework)) - - -def test_prudent_conflict_free_excludes_odd_indirect_attack_coste_marquis_2005_pages_1_2() -> None: - """Coste-Marquis et al. 2005, pp. 1-2: indirect attacks are odd paths.""" - framework = af({"a", "b", "c", "d"}, {("a", "b"), ("b", "c"), ("c", "d")}) - - assert ("a", "c") not in indirect_attacks(framework) - assert ("a", "d") in indirect_attacks(framework) - assert prudent_conflict_free(framework, frozenset({"a", "c"})) is True - assert prudent_conflict_free(framework, frozenset({"a", "d"})) is False - - -def test_prudent_example_af1_coste_marquis_2005_pages_1_3() -> None: - """Coste-Marquis et al. 2005, pp. 1-3: AF1 has prudent extension {i,n}.""" - framework = af( - {"a", "b", "c", "e", "n", "i"}, - {("b", "a"), ("c", "a"), ("n", "c"), ("i", "b"), ("e", "c"), ("i", "e")}, - ) - - assert ("i", "a") in indirect_attacks(framework) - assert prudent_conflict_free(framework, frozenset({"a", "i", "n"})) is False - assert prudent_preferred_extensions(framework) == [frozenset({"i", "n"})] - assert prudent_grounded_extension(framework) == frozenset({"i", "n"}) - - -def test_bipolar_grounded_and_complete_cayrol_2005_pages_383_386() -> None: - """Cayrol 2005, pp. 383-386: use set-defeat in the characteristic function.""" - framework = BipolarArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("b", "c")}), - supports=frozenset({("a", "b")}), - ) - - assert bipolar_characteristic_fn(frozenset(), framework) == frozenset({"a", "b"}) - assert bipolar_grounded_extension(framework) == frozenset({"a", "b"}) - assert frozenset({"a", "b"}) in bipolar_complete_extensions(framework) - - -@st.composite -def small_afs(draw): - args = draw(st.frozensets(st.sampled_from(tuple("abcde")), min_size=1, max_size=5)) - attacks = draw( - st.frozensets( - st.tuples(st.sampled_from(sorted(args)), st.sampled_from(sorted(args))), - max_size=len(args) ** 2, - ) - ) - return ArgumentationFramework(arguments=args, defeats=attacks) - - -@pytest.mark.property -@given(small_afs()) -@settings(deadline=None, max_examples=40) -def test_eager_is_unique_and_admissible(framework: ArgumentationFramework) -> None: - extension = eager_extension(framework) - - assert extension <= framework.arguments - assert admissible(extension, framework.arguments, framework.defeats) - - -@pytest.mark.property -@given(small_afs()) -@settings(deadline=None, max_examples=40) -def test_prudent_preferred_extensions_are_prudent_admissible( - framework: ArgumentationFramework, -) -> None: - for extension in prudent_preferred_extensions(framework): - assert prudent_admissible(framework, extension) - - -@pytest.mark.property -@given(small_afs()) -@settings(deadline=None, max_examples=40) -def test_stage2_extensions_are_conflict_free(framework: ArgumentationFramework) -> None: - for extension in stage2_extensions(framework): - assert extension <= framework.arguments - assert conflict_free(extension, framework.defeats) - - -@pytest.mark.property -@given(small_afs()) -@settings(deadline=None, max_examples=40) -def test_complete_extensions_match_labelling_projection( - framework: ArgumentationFramework, -) -> None: - assert set(complete_extensions(framework)) == { - labelling.extension - for labelling in complete_labellings(framework) - } +from __future__ import annotations + +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.bipolar import ( + BipolarArgumentationFramework, + bipolar_complete_extensions, + bipolar_grounded_extension, + characteristic_fn as bipolar_characteristic_fn, +) +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + complete_extensions, + eager_extension, + indirect_attacks, + preferred_extensions, + conflict_free, + prudent_admissible, + prudent_conflict_free, + prudent_grounded_extension, + prudent_preferred_extensions, + semi_stable_extensions, + stage2_extensions, + stage_extensions, +) +from argumentation.core.labelling import complete_labellings + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def test_no_z3_dung_module_or_solver_surface() -> None: + """Codex 2.17 deletion gate: no Dung extension-semantics Z3 backend remains.""" + root = Path("src/argumentation") + assert not (root / "dung_z3.py").exists() + forbidden = ( + "argumentation.dung_z3", + "_AUTO_BACKEND_MAX_ARGS", + "z3_complete_extensions", + "z3_preferred_extensions", + "z3_stable_extensions", + "backend=\"z3\"", + ) + for path in root.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for needle in forbidden: + assert needle not in text, f"{needle} remains in {path}" + + +def test_semi_stable_floating_reinstatement_caminada_2006_page_8() -> None: + """Caminada 2006, p. 8: floating `A`/`B` choices reinstate `D`.""" + framework = af( + {"A", "B", "C", "D"}, + {("A", "B"), ("B", "A"), ("A", "C"), ("B", "C"), ("C", "D")}, + ) + + assert set(semi_stable_extensions(framework)) == { + frozenset({"A", "D"}), + frozenset({"B", "D"}), + } + + +def test_eager_selects_largest_admissible_subset_of_semi_stable_intersection() -> None: + """Caminada 2006 p. 8 plus Caminada 2007 eager: reject undefended common `D`.""" + framework = af( + {"A", "B", "C", "D"}, + {("A", "B"), ("B", "A"), ("A", "C"), ("B", "C"), ("C", "D")}, + ) + + assert eager_extension(framework) == frozenset() + assert admissible(eager_extension(framework), framework.arguments, framework.defeats) + + +def test_stage2_collapses_to_stage_on_single_scc_gaggl_2013_pages_927_929() -> None: + """Gaggl and Woltran 2013, pp. 927-929: SCC base case is local semantics.""" + framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) + + assert set(stage2_extensions(framework)) == set(stage_extensions(framework)) + + +def test_prudent_conflict_free_excludes_odd_indirect_attack_coste_marquis_2005_pages_1_2() -> None: + """Coste-Marquis et al. 2005, pp. 1-2: indirect attacks are odd paths.""" + framework = af({"a", "b", "c", "d"}, {("a", "b"), ("b", "c"), ("c", "d")}) + + assert ("a", "c") not in indirect_attacks(framework) + assert ("a", "d") in indirect_attacks(framework) + assert prudent_conflict_free(framework, frozenset({"a", "c"})) is True + assert prudent_conflict_free(framework, frozenset({"a", "d"})) is False + + +def test_prudent_example_af1_coste_marquis_2005_pages_1_3() -> None: + """Coste-Marquis et al. 2005, pp. 1-3: AF1 has prudent extension {i,n}.""" + framework = af( + {"a", "b", "c", "e", "n", "i"}, + {("b", "a"), ("c", "a"), ("n", "c"), ("i", "b"), ("e", "c"), ("i", "e")}, + ) + + assert ("i", "a") in indirect_attacks(framework) + assert prudent_conflict_free(framework, frozenset({"a", "i", "n"})) is False + assert prudent_preferred_extensions(framework) == [frozenset({"i", "n"})] + assert prudent_grounded_extension(framework) == frozenset({"i", "n"}) + + +def test_bipolar_grounded_and_complete_cayrol_2005_pages_383_386() -> None: + """Cayrol 2005, pp. 383-386: use set-defeat in the characteristic function.""" + framework = BipolarArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("b", "c")}), + supports=frozenset({("a", "b")}), + ) + + assert bipolar_characteristic_fn(frozenset(), framework) == frozenset({"a", "b"}) + assert bipolar_grounded_extension(framework) == frozenset({"a", "b"}) + assert frozenset({"a", "b"}) in bipolar_complete_extensions(framework) + + +@st.composite +def small_afs(draw): + args = draw(st.frozensets(st.sampled_from(tuple("abcde")), min_size=1, max_size=5)) + attacks = draw( + st.frozensets( + st.tuples(st.sampled_from(sorted(args)), st.sampled_from(sorted(args))), + max_size=len(args) ** 2, + ) + ) + return ArgumentationFramework(arguments=args, defeats=attacks) + + +@pytest.mark.property +@given(small_afs()) +@settings(deadline=None, max_examples=40) +def test_eager_is_unique_and_admissible(framework: ArgumentationFramework) -> None: + extension = eager_extension(framework) + + assert extension <= framework.arguments + assert admissible(extension, framework.arguments, framework.defeats) + + +@pytest.mark.property +@given(small_afs()) +@settings(deadline=None, max_examples=40) +def test_prudent_preferred_extensions_are_prudent_admissible( + framework: ArgumentationFramework, +) -> None: + for extension in prudent_preferred_extensions(framework): + assert prudent_admissible(framework, extension) + + +@pytest.mark.property +@given(small_afs()) +@settings(deadline=None, max_examples=40) +def test_stage2_extensions_are_conflict_free(framework: ArgumentationFramework) -> None: + for extension in stage2_extensions(framework): + assert extension <= framework.arguments + assert conflict_free(extension, framework.defeats) + + +@pytest.mark.property +@given(small_afs()) +@settings(deadline=None, max_examples=40) +def test_complete_extensions_match_labelling_projection( + framework: ArgumentationFramework, +) -> None: + assert set(complete_extensions(framework)) == { + labelling.extension + for labelling in complete_labellings(framework) + } diff --git a/tests/test_dung_ideal_admissibility.py b/tests/core/test_dung_ideal_admissibility.py similarity index 92% rename from tests/test_dung_ideal_admissibility.py rename to tests/core/test_dung_ideal_admissibility.py index 39023b8..e7b2186 100644 --- a/tests/test_dung_ideal_admissibility.py +++ b/tests/core/test_dung_ideal_admissibility.py @@ -1,66 +1,66 @@ -from __future__ import annotations - -from hypothesis import given, settings - -from argumentation.dung import ( - ArgumentationFramework, - admissible, - ideal_extension, - preferred_extensions, -) -from tests.test_dung import argumentation_frameworks - - -def test_admissibility_is_not_downward_closed() -> None: - """A proper subset of an admissible set need not defend its members.""" - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c", "d", "e"}), - defeats=frozenset( - { - ("e", "a"), - ("c", "a"), - ("b", "e"), - ("b", "c"), - ("d", "e"), - ("d", "c"), - } - ), - ) - - assert admissible(frozenset({"a", "b"}), framework.arguments, framework.defeats) - assert admissible(frozenset({"a", "d"}), framework.arguments, framework.defeats) - assert not admissible(frozenset({"a"}), framework.arguments, framework.defeats) - - -def test_ideal_extension_requires_joint_mutual_defense() -> None: - """Regression for single-argument greedy ideal construction.""" - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "x", "y"}), - defeats=frozenset( - { - ("x", "a"), - ("x", "x"), - ("b", "x"), - ("y", "b"), - ("y", "y"), - ("a", "y"), - } - ), - ) - - assert tuple(preferred_extensions(framework)) == (frozenset({"a", "b"}),) - assert not admissible(frozenset({"a"}), framework.arguments, framework.defeats) - assert not admissible(frozenset({"b"}), framework.arguments, framework.defeats) - assert admissible(frozenset({"a", "b"}), framework.arguments, framework.defeats) - assert ideal_extension(framework) == frozenset({"a", "b"}) - - -@given(argumentation_frameworks(max_args=5)) -@settings(deadline=None) -def test_ideal_extension_is_admissible_and_below_every_preferred( - framework: ArgumentationFramework, -) -> None: - ideal = ideal_extension(framework) - - assert admissible(ideal, framework.arguments, framework.defeats) - assert all(ideal <= preferred for preferred in preferred_extensions(framework)) +from __future__ import annotations + +from hypothesis import given, settings + +from argumentation.core.dung import ( + ArgumentationFramework, + admissible, + ideal_extension, + preferred_extensions, +) +from tests.core.test_dung import argumentation_frameworks + + +def test_admissibility_is_not_downward_closed() -> None: + """A proper subset of an admissible set need not defend its members.""" + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c", "d", "e"}), + defeats=frozenset( + { + ("e", "a"), + ("c", "a"), + ("b", "e"), + ("b", "c"), + ("d", "e"), + ("d", "c"), + } + ), + ) + + assert admissible(frozenset({"a", "b"}), framework.arguments, framework.defeats) + assert admissible(frozenset({"a", "d"}), framework.arguments, framework.defeats) + assert not admissible(frozenset({"a"}), framework.arguments, framework.defeats) + + +def test_ideal_extension_requires_joint_mutual_defense() -> None: + """Regression for single-argument greedy ideal construction.""" + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "x", "y"}), + defeats=frozenset( + { + ("x", "a"), + ("x", "x"), + ("b", "x"), + ("y", "b"), + ("y", "y"), + ("a", "y"), + } + ), + ) + + assert tuple(preferred_extensions(framework)) == (frozenset({"a", "b"}),) + assert not admissible(frozenset({"a"}), framework.arguments, framework.defeats) + assert not admissible(frozenset({"b"}), framework.arguments, framework.defeats) + assert admissible(frozenset({"a", "b"}), framework.arguments, framework.defeats) + assert ideal_extension(framework) == frozenset({"a", "b"}) + + +@given(argumentation_frameworks(max_args=5)) +@settings(deadline=None) +def test_ideal_extension_is_admissible_and_below_every_preferred( + framework: ArgumentationFramework, +) -> None: + ideal = ideal_extension(framework) + + assert admissible(ideal, framework.arguments, framework.defeats) + assert all(ideal <= preferred for preferred in preferred_extensions(framework)) diff --git a/tests/test_grounded_perf_equivalence.py b/tests/core/test_grounded_perf_equivalence.py similarity index 95% rename from tests/test_grounded_perf_equivalence.py rename to tests/core/test_grounded_perf_equivalence.py index 06ba5f7..6404d6b 100644 --- a/tests/test_grounded_perf_equivalence.py +++ b/tests/core/test_grounded_perf_equivalence.py @@ -1,213 +1,213 @@ -"""Grounded-extension equivalence and scaling coverage.""" - -from __future__ import annotations - -import random -import time - -from argumentation.bipolar import ( - BipolarArgumentationFramework, - bipolar_grounded_extension, - derived_set_defeats, -) -from argumentation.dung import ArgumentationFramework, grounded_extension - - -def _af( - arguments: set[str], - defeats: set[tuple[str, str]], -) -> ArgumentationFramework: - return ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(defeats), - ) - - -def _baf( - arguments: set[str], - defeats: set[tuple[str, str]], - supports: set[tuple[str, str]], -) -> BipolarArgumentationFramework: - return BipolarArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(defeats), - supports=frozenset(supports), - ) - - -def _reference_defends( - candidate: frozenset[str], - argument: str, - defeats: frozenset[tuple[str, str]], -) -> bool: - attackers = frozenset( - attacker - for attacker, target in defeats - if target == argument - ) - return all( - any((defender, attacker) in defeats for defender in candidate) - for attacker in attackers - ) - - -def _reference_grounded( - arguments: frozenset[str], - defeats: frozenset[tuple[str, str]], -) -> frozenset[str]: - current: frozenset[str] = frozenset() - while True: - next_current = frozenset( - argument - for argument in arguments - if _reference_defends(current, argument, defeats) - ) - if next_current == current: - return current - current = next_current - - -def _reference_dung_grounded( - framework: ArgumentationFramework, -) -> frozenset[str]: - return _reference_grounded(framework.arguments, framework.defeats) - - -def _reference_bipolar_grounded( - framework: BipolarArgumentationFramework, -) -> frozenset[str]: - return _reference_grounded(framework.arguments, derived_set_defeats(framework)) - - -def _random_dung_frameworks() -> list[ArgumentationFramework]: - rng = random.Random(20260512) - frameworks: list[ArgumentationFramework] = [] - for index in range(50): - size = rng.randint(1, 30) - density = 0.02 + (index % 10) * 0.035 - arguments = {f"a{index}_{node}" for node in range(size)} - defeats = { - (source, target) - for source in arguments - for target in arguments - if rng.random() < density - } - frameworks.append(_af(arguments, defeats)) - return frameworks - - -def _random_bipolar_frameworks() -> list[BipolarArgumentationFramework]: - rng = random.Random(20260513) - frameworks: list[BipolarArgumentationFramework] = [] - for index in range(50): - size = rng.randint(1, 12) - attack_density = 0.03 + (index % 6) * 0.025 - support_density = 0.02 + (index % 5) * 0.03 - arguments = {f"b{index}_{node}" for node in range(size)} - pairs = [(source, target) for source in arguments for target in arguments] - defeats = { - pair - for pair in pairs - if rng.random() < attack_density - } - supports = { - pair - for pair in pairs - if pair not in defeats and rng.random() < support_density - } - frameworks.append(_baf(arguments, defeats, supports)) - return frameworks - - -def test_dung_grounded_matches_reference_fixpoint_on_small_corpus() -> None: - frameworks = [ - _af(set(), set()), - _af({"A"}, set()), - _af({"A", "B"}, {("A", "B"), ("B", "A")}), - _af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}), - _af({"A", "B", "C", "D"}, {("A", "B"), ("B", "C"), ("C", "D")}), - _af( - {"Tweety", "Bird", "Penguin", "Flies"}, - { - ("Penguin", "Flies"), - ("Bird", "Penguin"), - ("Tweety", "Bird"), - }, - ), - _af({"A", "B", "C", "D"}, {("B", "A"), ("C", "A"), ("B", "C"), ("C", "B")}), - _af({"A"}, {("A", "A")}), - _af({"A", "B", "C"}, {("A", "B"), ("A", "C"), ("B", "C"), ("C", "B")}), - _af({"A", "B", "C"}, set()), - ] - frameworks.extend(_random_dung_frameworks()) - - for framework in frameworks: - assert grounded_extension(framework) == _reference_dung_grounded(framework) - - -def test_bipolar_grounded_matches_reference_fixpoint_on_small_corpus() -> None: - frameworks = [ - _baf(set(), set(), set()), - _baf({"A"}, set(), set()), - _baf({"A", "B"}, {("A", "B")}, set()), - _baf({"A", "B"}, {("A", "B"), ("B", "A")}, set()), - _baf({"A", "B"}, set(), {("A", "B")}), - _baf({"A", "B", "C"}, {("B", "C")}, {("A", "B")}), - _baf({"A", "B", "C"}, {("A", "B")}, {("B", "C")}), - _baf({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}, set()), - _baf({"A"}, set(), {("A", "A")}), - _baf( - {"A", "B", "C", "D", "F"}, - {("C", "D")}, - {("A", "B"), ("B", "C"), ("F", "D")}, - ), - ] - frameworks.extend(_random_bipolar_frameworks()) - - for framework in frameworks: - assert bipolar_grounded_extension(framework) == _reference_bipolar_grounded(framework) - - -def test_dung_grounded_scales_on_sparse_50k_node_graph() -> None: - group_count = 8_334 - arguments: set[str] = set() - defeats: set[tuple[str, str]] = set() - expected: set[str] = set() - - for group in range(group_count): - base = group * 6 - chain_in = f"a{base}" - chain_out = f"a{base + 1}" - chain_reinstated = f"a{base + 2}" - cycle_a = f"a{base + 3}" - cycle_b = f"a{base + 4}" - cycle_c = f"a{base + 5}" - arguments.update( - { - chain_in, - chain_out, - chain_reinstated, - cycle_a, - cycle_b, - cycle_c, - } - ) - defeats.update( - { - (chain_in, chain_out), - (chain_out, chain_reinstated), - (cycle_a, cycle_b), - (cycle_b, cycle_c), - (cycle_c, cycle_a), - } - ) - expected.update({chain_in, chain_reinstated}) - - framework = _af(arguments, defeats) - - started = time.perf_counter() - result = grounded_extension(framework) - elapsed = time.perf_counter() - started - - assert result == expected - assert elapsed < 1.0 +"""Grounded-extension equivalence and scaling coverage.""" + +from __future__ import annotations + +import random +import time + +from argumentation.core.bipolar import ( + BipolarArgumentationFramework, + bipolar_grounded_extension, + derived_set_defeats, +) +from argumentation.core.dung import ArgumentationFramework, grounded_extension + + +def _af( + arguments: set[str], + defeats: set[tuple[str, str]], +) -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(defeats), + ) + + +def _baf( + arguments: set[str], + defeats: set[tuple[str, str]], + supports: set[tuple[str, str]], +) -> BipolarArgumentationFramework: + return BipolarArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(defeats), + supports=frozenset(supports), + ) + + +def _reference_defends( + candidate: frozenset[str], + argument: str, + defeats: frozenset[tuple[str, str]], +) -> bool: + attackers = frozenset( + attacker + for attacker, target in defeats + if target == argument + ) + return all( + any((defender, attacker) in defeats for defender in candidate) + for attacker in attackers + ) + + +def _reference_grounded( + arguments: frozenset[str], + defeats: frozenset[tuple[str, str]], +) -> frozenset[str]: + current: frozenset[str] = frozenset() + while True: + next_current = frozenset( + argument + for argument in arguments + if _reference_defends(current, argument, defeats) + ) + if next_current == current: + return current + current = next_current + + +def _reference_dung_grounded( + framework: ArgumentationFramework, +) -> frozenset[str]: + return _reference_grounded(framework.arguments, framework.defeats) + + +def _reference_bipolar_grounded( + framework: BipolarArgumentationFramework, +) -> frozenset[str]: + return _reference_grounded(framework.arguments, derived_set_defeats(framework)) + + +def _random_dung_frameworks() -> list[ArgumentationFramework]: + rng = random.Random(20260512) + frameworks: list[ArgumentationFramework] = [] + for index in range(50): + size = rng.randint(1, 30) + density = 0.02 + (index % 10) * 0.035 + arguments = {f"a{index}_{node}" for node in range(size)} + defeats = { + (source, target) + for source in arguments + for target in arguments + if rng.random() < density + } + frameworks.append(_af(arguments, defeats)) + return frameworks + + +def _random_bipolar_frameworks() -> list[BipolarArgumentationFramework]: + rng = random.Random(20260513) + frameworks: list[BipolarArgumentationFramework] = [] + for index in range(50): + size = rng.randint(1, 12) + attack_density = 0.03 + (index % 6) * 0.025 + support_density = 0.02 + (index % 5) * 0.03 + arguments = {f"b{index}_{node}" for node in range(size)} + pairs = [(source, target) for source in arguments for target in arguments] + defeats = { + pair + for pair in pairs + if rng.random() < attack_density + } + supports = { + pair + for pair in pairs + if pair not in defeats and rng.random() < support_density + } + frameworks.append(_baf(arguments, defeats, supports)) + return frameworks + + +def test_dung_grounded_matches_reference_fixpoint_on_small_corpus() -> None: + frameworks = [ + _af(set(), set()), + _af({"A"}, set()), + _af({"A", "B"}, {("A", "B"), ("B", "A")}), + _af({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}), + _af({"A", "B", "C", "D"}, {("A", "B"), ("B", "C"), ("C", "D")}), + _af( + {"Tweety", "Bird", "Penguin", "Flies"}, + { + ("Penguin", "Flies"), + ("Bird", "Penguin"), + ("Tweety", "Bird"), + }, + ), + _af({"A", "B", "C", "D"}, {("B", "A"), ("C", "A"), ("B", "C"), ("C", "B")}), + _af({"A"}, {("A", "A")}), + _af({"A", "B", "C"}, {("A", "B"), ("A", "C"), ("B", "C"), ("C", "B")}), + _af({"A", "B", "C"}, set()), + ] + frameworks.extend(_random_dung_frameworks()) + + for framework in frameworks: + assert grounded_extension(framework) == _reference_dung_grounded(framework) + + +def test_bipolar_grounded_matches_reference_fixpoint_on_small_corpus() -> None: + frameworks = [ + _baf(set(), set(), set()), + _baf({"A"}, set(), set()), + _baf({"A", "B"}, {("A", "B")}, set()), + _baf({"A", "B"}, {("A", "B"), ("B", "A")}, set()), + _baf({"A", "B"}, set(), {("A", "B")}), + _baf({"A", "B", "C"}, {("B", "C")}, {("A", "B")}), + _baf({"A", "B", "C"}, {("A", "B")}, {("B", "C")}), + _baf({"A", "B", "C"}, {("A", "B"), ("B", "C"), ("C", "A")}, set()), + _baf({"A"}, set(), {("A", "A")}), + _baf( + {"A", "B", "C", "D", "F"}, + {("C", "D")}, + {("A", "B"), ("B", "C"), ("F", "D")}, + ), + ] + frameworks.extend(_random_bipolar_frameworks()) + + for framework in frameworks: + assert bipolar_grounded_extension(framework) == _reference_bipolar_grounded(framework) + + +def test_dung_grounded_scales_on_sparse_50k_node_graph() -> None: + group_count = 8_334 + arguments: set[str] = set() + defeats: set[tuple[str, str]] = set() + expected: set[str] = set() + + for group in range(group_count): + base = group * 6 + chain_in = f"a{base}" + chain_out = f"a{base + 1}" + chain_reinstated = f"a{base + 2}" + cycle_a = f"a{base + 3}" + cycle_b = f"a{base + 4}" + cycle_c = f"a{base + 5}" + arguments.update( + { + chain_in, + chain_out, + chain_reinstated, + cycle_a, + cycle_b, + cycle_c, + } + ) + defeats.update( + { + (chain_in, chain_out), + (chain_out, chain_reinstated), + (cycle_a, cycle_b), + (cycle_b, cycle_c), + (cycle_c, cycle_a), + } + ) + expected.update({chain_in, chain_reinstated}) + + framework = _af(arguments, defeats) + + started = time.perf_counter() + result = grounded_extension(framework) + elapsed = time.perf_counter() - started + + assert result == expected + assert elapsed < 1.0 diff --git a/tests/test_labelling.py b/tests/core/test_labelling.py similarity index 93% rename from tests/test_labelling.py rename to tests/core/test_labelling.py index 823b68e..ec2e8c3 100644 --- a/tests/test_labelling.py +++ b/tests/core/test_labelling.py @@ -1,132 +1,132 @@ -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -from argumentation.dung import ( - ArgumentationFramework, - complete_extensions, - grounded_extension, - preferred_extensions, - stable_extensions, -) -from argumentation.labelling import Label, Labelling -from argumentation.labelling import ExactEnumerationExceeded, _all_subsets, complete_labellings - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -class TestLabellingValueObject: - def test_labels_are_the_three_standard_statuses(self) -> None: - assert Label.IN.value == "in" - assert Label.OUT.value == "out" - assert Label.UNDEC.value == "undec" - - def test_labelling_is_immutable_and_normalizes_statuses(self) -> None: - labelling = Labelling.from_statuses( - arguments=frozenset({"a", "b", "c"}), - statuses={"a": Label.IN, "b": "out", "c": Label.UNDEC}, - ) - - assert labelling.in_arguments == frozenset({"a"}) - assert labelling.out_arguments == frozenset({"b"}) - assert labelling.undecided_arguments == frozenset({"c"}) - with pytest.raises(FrozenInstanceError): - labelling.statuses = {} # type: ignore[misc] - - def test_labelling_rejects_missing_extra_and_unknown_statuses(self) -> None: - with pytest.raises(ValueError, match="exactly"): - Labelling.from_statuses( - arguments=frozenset({"a", "b"}), - statuses={"a": Label.IN}, - ) - with pytest.raises(ValueError, match="exactly"): - Labelling.from_statuses( - arguments=frozenset({"a"}), - statuses={"a": Label.IN, "b": Label.OUT}, - ) - with pytest.raises(ValueError, match="label"): - Labelling.from_statuses( - arguments=frozenset({"a"}), - statuses={"a": "accepted"}, - ) - - def test_range_is_in_arguments_plus_out_arguments(self) -> None: - labelling = Labelling.from_statuses( - arguments=frozenset({"a", "b", "c"}), - statuses={"a": Label.IN, "b": Label.OUT, "c": Label.UNDEC}, - ) - - assert labelling.range == frozenset({"a", "b"}) - assert labelling.extension == frozenset({"a"}) - - -class TestExtensionLabellingConversion: - def test_extension_conversion_marks_defeated_outsiders_out(self) -> None: - framework = af( - {"a", "b", "c"}, - {("a", "b"), ("b", "c")}, - ) - - labelling = Labelling.from_extension(framework, frozenset({"a", "c"})) - - assert labelling.in_arguments == frozenset({"a", "c"}) - assert labelling.out_arguments == frozenset({"b"}) - assert labelling.undecided_arguments == frozenset() - assert labelling.extension == frozenset({"a", "c"}) - - def test_extension_conversion_leaves_unattacked_outsiders_undecided(self) -> None: - framework = af( - {"a", "b"}, - {("a", "b"), ("b", "a")}, - ) - - labelling = Labelling.from_extension(framework, frozenset()) - - assert labelling.in_arguments == frozenset() - assert labelling.out_arguments == frozenset() - assert labelling.undecided_arguments == frozenset({"a", "b"}) - - def test_extension_conversion_rejects_unknown_arguments(self) -> None: - framework = af({"a"}, set()) - - with pytest.raises(ValueError, match="extension"): - Labelling.from_extension(framework, frozenset({"missing"})) - - def test_complete_grounded_preferred_and_stable_round_trip(self) -> None: - framework = af( - {"a", "b", "c"}, - {("a", "b"), ("b", "a"), ("b", "c")}, - ) - extension_families = [ - [grounded_extension(framework)], - complete_extensions(framework), - preferred_extensions(framework), - stable_extensions(framework), - ] - - for extensions in extension_families: - assert extensions - for extension in extensions: - labelling = Labelling.from_extension(framework, extension) - assert labelling.extension == extension - - -class TestCompleteLabellingEnumerationBudget: - def test_subset_generation_is_lazy(self) -> None: - subsets = _all_subsets(frozenset(str(index) for index in range(30))) - - assert not isinstance(subsets, list) - assert next(subsets) == frozenset() - - def test_cyclic_complete_labellings_stop_at_candidate_budget(self) -> None: - framework = af( - {"a", "b", "c", "d"}, - {("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")}, - ) - - with pytest.raises(ExactEnumerationExceeded, match="complete labellings"): - complete_labellings(framework, max_candidates=3) +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + grounded_extension, + preferred_extensions, + stable_extensions, +) +from argumentation.core.labelling import Label, Labelling +from argumentation.core.labelling import ExactEnumerationExceeded, _all_subsets, complete_labellings + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +class TestLabellingValueObject: + def test_labels_are_the_three_standard_statuses(self) -> None: + assert Label.IN.value == "in" + assert Label.OUT.value == "out" + assert Label.UNDEC.value == "undec" + + def test_labelling_is_immutable_and_normalizes_statuses(self) -> None: + labelling = Labelling.from_statuses( + arguments=frozenset({"a", "b", "c"}), + statuses={"a": Label.IN, "b": "out", "c": Label.UNDEC}, + ) + + assert labelling.in_arguments == frozenset({"a"}) + assert labelling.out_arguments == frozenset({"b"}) + assert labelling.undecided_arguments == frozenset({"c"}) + with pytest.raises(FrozenInstanceError): + labelling.statuses = {} # type: ignore[misc] + + def test_labelling_rejects_missing_extra_and_unknown_statuses(self) -> None: + with pytest.raises(ValueError, match="exactly"): + Labelling.from_statuses( + arguments=frozenset({"a", "b"}), + statuses={"a": Label.IN}, + ) + with pytest.raises(ValueError, match="exactly"): + Labelling.from_statuses( + arguments=frozenset({"a"}), + statuses={"a": Label.IN, "b": Label.OUT}, + ) + with pytest.raises(ValueError, match="label"): + Labelling.from_statuses( + arguments=frozenset({"a"}), + statuses={"a": "accepted"}, + ) + + def test_range_is_in_arguments_plus_out_arguments(self) -> None: + labelling = Labelling.from_statuses( + arguments=frozenset({"a", "b", "c"}), + statuses={"a": Label.IN, "b": Label.OUT, "c": Label.UNDEC}, + ) + + assert labelling.range == frozenset({"a", "b"}) + assert labelling.extension == frozenset({"a"}) + + +class TestExtensionLabellingConversion: + def test_extension_conversion_marks_defeated_outsiders_out(self) -> None: + framework = af( + {"a", "b", "c"}, + {("a", "b"), ("b", "c")}, + ) + + labelling = Labelling.from_extension(framework, frozenset({"a", "c"})) + + assert labelling.in_arguments == frozenset({"a", "c"}) + assert labelling.out_arguments == frozenset({"b"}) + assert labelling.undecided_arguments == frozenset() + assert labelling.extension == frozenset({"a", "c"}) + + def test_extension_conversion_leaves_unattacked_outsiders_undecided(self) -> None: + framework = af( + {"a", "b"}, + {("a", "b"), ("b", "a")}, + ) + + labelling = Labelling.from_extension(framework, frozenset()) + + assert labelling.in_arguments == frozenset() + assert labelling.out_arguments == frozenset() + assert labelling.undecided_arguments == frozenset({"a", "b"}) + + def test_extension_conversion_rejects_unknown_arguments(self) -> None: + framework = af({"a"}, set()) + + with pytest.raises(ValueError, match="extension"): + Labelling.from_extension(framework, frozenset({"missing"})) + + def test_complete_grounded_preferred_and_stable_round_trip(self) -> None: + framework = af( + {"a", "b", "c"}, + {("a", "b"), ("b", "a"), ("b", "c")}, + ) + extension_families = [ + [grounded_extension(framework)], + complete_extensions(framework), + preferred_extensions(framework), + stable_extensions(framework), + ] + + for extensions in extension_families: + assert extensions + for extension in extensions: + labelling = Labelling.from_extension(framework, extension) + assert labelling.extension == extension + + +class TestCompleteLabellingEnumerationBudget: + def test_subset_generation_is_lazy(self) -> None: + subsets = _all_subsets(frozenset(str(index) for index in range(30))) + + assert not isinstance(subsets, list) + assert next(subsets) == frozenset() + + def test_cyclic_complete_labellings_stop_at_candidate_budget(self) -> None: + framework = af( + {"a", "b", "c", "d"}, + {("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")}, + ) + + with pytest.raises(ExactEnumerationExceeded, match="complete labellings"): + complete_labellings(framework, max_candidates=3) diff --git a/tests/test_labelling_operational.py b/tests/core/test_labelling_operational.py similarity index 91% rename from tests/test_labelling_operational.py rename to tests/core/test_labelling_operational.py index 555da87..f00843e 100644 --- a/tests/test_labelling_operational.py +++ b/tests/core/test_labelling_operational.py @@ -1,72 +1,72 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework, complete_extensions, grounded_extension -from argumentation.labelling import ( - Label, - Labelling, - complete_labellings, - grounded_labelling, - legally_in, - legally_out, - preferred_labellings, - stable_labellings, -) - - -pytestmark = pytest.mark.unit - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def test_legally_in_predicate_caminada_2006_page_3() -> None: - """Caminada 2006, p. 3: IN iff every defeater is OUT.""" - framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) - labelling = Labelling.from_statuses( - arguments=framework.arguments, - statuses={"A": Label.IN, "B": Label.OUT, "C": Label.IN}, - ) - - assert legally_in(labelling, framework, "A") is True - assert legally_in(labelling, framework, "C") is True - assert legally_in(labelling, framework, "B") is False - - -def test_legally_out_predicate_caminada_2006_page_3() -> None: - """Caminada 2006, p. 3: OUT iff some defeater is IN.""" - framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) - labelling = Labelling.from_statuses( - arguments=framework.arguments, - statuses={"A": Label.IN, "B": Label.OUT, "C": Label.IN}, - ) - - assert legally_out(labelling, framework, "B") is True - assert legally_out(labelling, framework, "A") is False - assert legally_out(labelling, framework, "C") is False - - -def test_complete_labellings_match_complete_extensions_caminada_2006_pages_3_4() -> None: - """Caminada 2006, pp. 3-4: Lab2Ext bridges complete labellings/extensions.""" - framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) - - assert {labelling.extension for labelling in complete_labellings(framework)} == set( - complete_extensions(framework) - ) - - -def test_grounded_preferred_and_stable_labelling_characterisations() -> None: - """Caminada 2006, pp. 4-5: stable=no UNDEC, preferred=max IN, grounded=min IN.""" - framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) - - assert grounded_labelling(framework).extension == grounded_extension(framework) - assert {labelling.extension for labelling in preferred_labellings(framework)} == { - frozenset({"A"}), - frozenset({"B"}), - } - assert {labelling.extension for labelling in stable_labellings(framework)} == { - frozenset({"A"}), - frozenset({"B"}), - } +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework, complete_extensions, grounded_extension +from argumentation.core.labelling import ( + Label, + Labelling, + complete_labellings, + grounded_labelling, + legally_in, + legally_out, + preferred_labellings, + stable_labellings, +) + + +pytestmark = pytest.mark.unit + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def test_legally_in_predicate_caminada_2006_page_3() -> None: + """Caminada 2006, p. 3: IN iff every defeater is OUT.""" + framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) + labelling = Labelling.from_statuses( + arguments=framework.arguments, + statuses={"A": Label.IN, "B": Label.OUT, "C": Label.IN}, + ) + + assert legally_in(labelling, framework, "A") is True + assert legally_in(labelling, framework, "C") is True + assert legally_in(labelling, framework, "B") is False + + +def test_legally_out_predicate_caminada_2006_page_3() -> None: + """Caminada 2006, p. 3: OUT iff some defeater is IN.""" + framework = af({"A", "B", "C"}, {("A", "B"), ("B", "C")}) + labelling = Labelling.from_statuses( + arguments=framework.arguments, + statuses={"A": Label.IN, "B": Label.OUT, "C": Label.IN}, + ) + + assert legally_out(labelling, framework, "B") is True + assert legally_out(labelling, framework, "A") is False + assert legally_out(labelling, framework, "C") is False + + +def test_complete_labellings_match_complete_extensions_caminada_2006_pages_3_4() -> None: + """Caminada 2006, pp. 3-4: Lab2Ext bridges complete labellings/extensions.""" + framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) + + assert {labelling.extension for labelling in complete_labellings(framework)} == set( + complete_extensions(framework) + ) + + +def test_grounded_preferred_and_stable_labelling_characterisations() -> None: + """Caminada 2006, pp. 4-5: stable=no UNDEC, preferred=max IN, grounded=min IN.""" + framework = af({"A", "B"}, {("A", "B"), ("B", "A")}) + + assert grounded_labelling(framework).extension == grounded_extension(framework) + assert {labelling.extension for labelling in preferred_labellings(framework)} == { + frozenset({"A"}), + frozenset({"B"}), + } + assert {labelling.extension for labelling in stable_labellings(framework)} == { + frozenset({"A"}), + frozenset({"B"}), + } diff --git a/tests/test_preference.py b/tests/core/test_preference.py similarity index 95% rename from tests/test_preference.py rename to tests/core/test_preference.py index 07b0b26..0cbb1c0 100644 --- a/tests/test_preference.py +++ b/tests/core/test_preference.py @@ -1,104 +1,104 @@ -"""Tests for generic preference helpers.""" - -from __future__ import annotations - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.preference import ( - defeat_holds, - strict_partial_order_closure, - strictly_weaker, -) - - -_strengths = st.floats( - min_value=0.0, - max_value=10.0, - allow_nan=False, - allow_infinity=False, -) -_strength_sets = st.lists(_strengths, min_size=1, max_size=5) -_comparisons = st.sampled_from(["elitist", "democratic"]) -_PROP_SETTINGS = settings(deadline=None) - - -def test_strict_partial_order_closure_adds_transitive_pairs() -> None: - assert strict_partial_order_closure({("r1", "r2"), ("r2", "r3")}) == frozenset( - { - ("r1", "r2"), - ("r2", "r3"), - ("r1", "r3"), - } - ) - - -def test_strict_partial_order_closure_rejects_cycles() -> None: - with pytest.raises(ValueError, match="cycle"): - strict_partial_order_closure({("r1", "r2"), ("r2", "r1")}) - - -class TestStrictlyWeakerConcrete: - def test_elitist_weaker(self) -> None: - assert strictly_weaker([1, 5], [3, 4], "elitist") is True - - def test_elitist_not_weaker(self) -> None: - assert strictly_weaker([3, 5], [3, 4], "elitist") is False - - def test_democratic_weaker(self) -> None: - assert strictly_weaker([1, 2], [3, 4], "democratic") is True - - def test_democratic_not_weaker(self) -> None: - assert strictly_weaker([1, 5], [3, 4], "democratic") is False - - def test_empty_left_set_is_not_strictly_weaker(self) -> None: - assert strictly_weaker([], [3, 4], "elitist") is False - assert strictly_weaker([], [3, 4], "democratic") is False - - def test_ws_o_arg_non_empty_set_is_strictly_weaker_than_empty_boundary(self) -> None: - """Bug 6: Modgil-Prakken Def 19 makes non-empty gamma < empty gamma'.""" - assert strictly_weaker([1, 2], [], "elitist") is True - assert strictly_weaker([1, 2], [], "democratic") is True - - -class TestDefeatHoldsConcrete: - def test_undercut_always_defeats(self) -> None: - assert defeat_holds("undercuts", [0.1], [0.9], "elitist") is True - assert defeat_holds("undercuts", [0.1], [0.9], "democratic") is True - - def test_supersedes_always_defeats(self) -> None: - assert defeat_holds("supersedes", [0.1], [0.9], "elitist") is True - - def test_rebut_blocked_when_weaker(self) -> None: - assert defeat_holds("rebuts", [1], [5], "elitist") is False - - def test_rebut_succeeds_when_equal(self) -> None: - assert defeat_holds("rebuts", [3], [3], "elitist") is True - - -@given(_strength_sets, _comparisons) -@_PROP_SETTINGS -def test_strictly_weaker_irreflexive(strengths: list[float], mode: str) -> None: - assert strictly_weaker(strengths, strengths, mode) is False - - -@given(_strength_sets, _strength_sets, _comparisons) -@_PROP_SETTINGS -def test_strictly_weaker_asymmetric( - left: list[float], - right: list[float], - mode: str, -) -> None: - if strictly_weaker(left, right, mode): - assert strictly_weaker(right, left, mode) is False - - -@given(_strength_sets, _strength_sets, _comparisons) -@_PROP_SETTINGS -def test_undercuts_always_defeat( - left: list[float], - right: list[float], - mode: str, -) -> None: - assert defeat_holds("undercuts", left, right, mode) is True +"""Tests for generic preference helpers.""" + +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.preference import ( + defeat_holds, + strict_partial_order_closure, + strictly_weaker, +) + + +_strengths = st.floats( + min_value=0.0, + max_value=10.0, + allow_nan=False, + allow_infinity=False, +) +_strength_sets = st.lists(_strengths, min_size=1, max_size=5) +_comparisons = st.sampled_from(["elitist", "democratic"]) +_PROP_SETTINGS = settings(deadline=None) + + +def test_strict_partial_order_closure_adds_transitive_pairs() -> None: + assert strict_partial_order_closure({("r1", "r2"), ("r2", "r3")}) == frozenset( + { + ("r1", "r2"), + ("r2", "r3"), + ("r1", "r3"), + } + ) + + +def test_strict_partial_order_closure_rejects_cycles() -> None: + with pytest.raises(ValueError, match="cycle"): + strict_partial_order_closure({("r1", "r2"), ("r2", "r1")}) + + +class TestStrictlyWeakerConcrete: + def test_elitist_weaker(self) -> None: + assert strictly_weaker([1, 5], [3, 4], "elitist") is True + + def test_elitist_not_weaker(self) -> None: + assert strictly_weaker([3, 5], [3, 4], "elitist") is False + + def test_democratic_weaker(self) -> None: + assert strictly_weaker([1, 2], [3, 4], "democratic") is True + + def test_democratic_not_weaker(self) -> None: + assert strictly_weaker([1, 5], [3, 4], "democratic") is False + + def test_empty_left_set_is_not_strictly_weaker(self) -> None: + assert strictly_weaker([], [3, 4], "elitist") is False + assert strictly_weaker([], [3, 4], "democratic") is False + + def test_ws_o_arg_non_empty_set_is_strictly_weaker_than_empty_boundary(self) -> None: + """Bug 6: Modgil-Prakken Def 19 makes non-empty gamma < empty gamma'.""" + assert strictly_weaker([1, 2], [], "elitist") is True + assert strictly_weaker([1, 2], [], "democratic") is True + + +class TestDefeatHoldsConcrete: + def test_undercut_always_defeats(self) -> None: + assert defeat_holds("undercuts", [0.1], [0.9], "elitist") is True + assert defeat_holds("undercuts", [0.1], [0.9], "democratic") is True + + def test_supersedes_always_defeats(self) -> None: + assert defeat_holds("supersedes", [0.1], [0.9], "elitist") is True + + def test_rebut_blocked_when_weaker(self) -> None: + assert defeat_holds("rebuts", [1], [5], "elitist") is False + + def test_rebut_succeeds_when_equal(self) -> None: + assert defeat_holds("rebuts", [3], [3], "elitist") is True + + +@given(_strength_sets, _comparisons) +@_PROP_SETTINGS +def test_strictly_weaker_irreflexive(strengths: list[float], mode: str) -> None: + assert strictly_weaker(strengths, strengths, mode) is False + + +@given(_strength_sets, _strength_sets, _comparisons) +@_PROP_SETTINGS +def test_strictly_weaker_asymmetric( + left: list[float], + right: list[float], + mode: str, +) -> None: + if strictly_weaker(left, right, mode): + assert strictly_weaker(right, left, mode) is False + + +@given(_strength_sets, _strength_sets, _comparisons) +@_PROP_SETTINGS +def test_undercuts_always_defeat( + left: list[float], + right: list[float], + mode: str, +) -> None: + assert defeat_holds("undercuts", left, right, mode) is True diff --git a/tests/test_preprocessing.py b/tests/core/test_preprocessing.py similarity index 94% rename from tests/test_preprocessing.py rename to tests/core/test_preprocessing.py index a5fd6c7..111d249 100644 --- a/tests/test_preprocessing.py +++ b/tests/core/test_preprocessing.py @@ -1,252 +1,252 @@ -"""Oracle-equivalence tests for the Wave A AF preprocessing layer. - -The preprocessing layer (:mod:`argumentation.preprocessing`) must be exactly -semantics-preserving: solving the reduced AF and lifting the answer back must -equal solving the original AF, for every supported semantics. These tests pin -that against the brute-force reference semantics in :mod:`argumentation.dung` -and against the unsimplified SAT path. -""" - -from __future__ import annotations - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.af_sat import ( - find_complete_extension, - find_ideal_extension, - find_preferred_extension, - find_semi_stable_extension, - find_stable_extension, - find_stage_extension, - is_preferred_skeptically_accepted, -) -from argumentation.dung import ( - ArgumentationFramework, - grounded_extension, - preferred_extensions, - semi_stable_extensions, - stable_extensions, - stage_extensions, -) -from argumentation.dung import complete_extensions as native_complete_extensions -from argumentation.dung import ideal_extension as native_ideal_extension -from argumentation.preprocessing import ( - AfSimplification, - is_symmetric_irreflexive, - isolated_arguments, - simplify_af, -) - -z3 = pytest.importorskip("z3") # noqa: F841 - - -def _af(args, defeats): - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -# A battery of small, deliberately diverse AFs. -_BATTERY: list[ArgumentationFramework] = [ - _af({"a"}, set()), # single isolated argument - _af({"a"}, {("a", "a")}), # single self-attacker - _af({"a", "b"}, {("a", "b")}), # one-way attack: non-trivial grounded - _af({"a", "b"}, {("a", "b"), ("b", "a")}), # symmetric 2-cycle - _af({"a", "b", "c"}, {("a", "b"), ("b", "c")}), # acyclic chain - _af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}), # 3-cycle - _af({"a", "b", "c", "d"}, {("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")}), # two 2-cycles - _af({"a", "b", "c"}, {("a", "a"), ("a", "b"), ("b", "c")}), # self-attacker with out-edges - _af({"a", "b", "c", "d"}, {("a", "a"), ("b", "b")}), # only self-loop sinks (+ isolated) - _af({"a", "b", "c"}, {("a", "a"), ("b", "c"), ("c", "a")}), # stage breaks naive grounded reduct - _af( - {"a", "b", "c", "d", "e"}, - {("a", "b"), ("b", "c"), ("c", "b"), ("d", "e"), ("e", "d")}, - ), # grounded {a}, then a 2-cycle and another 2-cycle - _af( - {"a", "b", "c", "d"}, - {("a", "c"), ("b", "a"), ("c", "b"), ("a", "d")}, - ), # 3-cycle plus a dominated leaf -] - - -@st.composite -def _random_af(draw, max_args: int = 4): - args = draw( - st.frozensets( - st.text(alphabet="abcd", min_size=1, max_size=2), - min_size=1, - max_size=max_args, - ) - ) - arg_list = sorted(args) - defeats = draw( - st.frozensets( - st.tuples(st.sampled_from(arg_list), st.sampled_from(arg_list)), - max_size=len(arg_list) ** 2, - ) - ) - return ArgumentationFramework(arguments=args, defeats=defeats) - - -# ── Direct invariants of simplify_af ──────────────────────────────── - - -def _check_simplification_shape(framework: ArgumentationFramework, simplification: AfSimplification) -> None: - assert simplification.original is framework - assert simplification.fixed_in <= framework.arguments - assert simplification.removed_out <= framework.arguments - assert simplification.fixed_in.isdisjoint(simplification.removed_out) - removed = simplification.fixed_in | simplification.removed_out - assert simplification.residual.arguments == framework.arguments - removed - # Residual carries exactly the attacks among its surviving arguments. - for attacker, target in framework.defeats: - if attacker in simplification.residual.arguments and target in simplification.residual.arguments: - assert (attacker, target) in simplification.residual.defeats - for attacker, target in simplification.residual.defeats: - assert (attacker, target) in framework.defeats - - -@pytest.mark.parametrize("framework", _BATTERY) -def test_simplify_af_shape_on_battery(framework: ArgumentationFramework) -> None: - for semantics in ("complete", "preferred", "stable", "semi_stable", "stage", "grounded", "ideal", None): - simplification = simplify_af(framework, semantics=semantics) - _check_simplification_shape(framework, simplification) - - -@given(_random_af(max_args=5)) -@settings(deadline=None, max_examples=80) -def test_simplify_af_shape_random(framework: ArgumentationFramework) -> None: - for semantics in ("complete", "preferred", "stable", "semi_stable", "stage", "grounded", "ideal"): - _check_simplification_shape(framework, simplify_af(framework, semantics=semantics)) - - -def test_grounded_reduct_fixes_grounded_in_and_attacked_out() -> None: - framework = _af({"a", "b", "c"}, {("a", "b"), ("b", "c")}) - simplification = simplify_af(framework, semantics="complete") - assert simplification.fixed_in == frozenset({"a", "c"}) - assert simplification.removed_out == frozenset({"b"}) - assert not simplification.residual.arguments - - -def test_self_loop_sink_removed_except_for_stable() -> None: - framework = _af({"a", "b"}, {("a", "a")}) - # b is unattacked -> grounded; a is a pure self-loop sink. - non_stable = simplify_af(framework, semantics="complete") - assert "a" in non_stable.removed_out - stable_view = simplify_af(framework, semantics="stable") - assert "a" not in stable_view.removed_out - assert "a" in stable_view.residual.arguments - - -def test_diagnostics() -> None: - framework = _af({"a", "b", "c"}, {("a", "b"), ("b", "a")}) - assert isolated_arguments(framework) == frozenset({"c"}) - assert is_symmetric_irreflexive(_af({"a", "b"}, {("a", "b"), ("b", "a")})) - assert not is_symmetric_irreflexive(_af({"a"}, {("a", "a")})) - assert not is_symmetric_irreflexive(_af({"a", "b"}, {("a", "b")})) - assert not is_symmetric_irreflexive(_af({"a"}, set())) - - -# ── Oracle equivalence: simplified SAT path == brute-force reference ─ - - -def _native_extensions(framework: ArgumentationFramework, semantics: str) -> set[frozenset[str]]: - if semantics == "complete": - return set(native_complete_extensions(framework)) - if semantics == "preferred": - return set(preferred_extensions(framework)) - if semantics == "stable": - return set(stable_extensions(framework)) - if semantics == "semi_stable": - return set(semi_stable_extensions(framework)) - if semantics == "stage": - return set(stage_extensions(framework)) - if semantics == "grounded": - return {grounded_extension(framework)} - if semantics == "ideal": - return {native_ideal_extension(framework)} - raise AssertionError(semantics) - - -_FINDERS = { - "complete": find_complete_extension, - "preferred": find_preferred_extension, - "stable": find_stable_extension, - "semi_stable": find_semi_stable_extension, - "stage": find_stage_extension, -} - - -def _assert_finder_matches_oracle(framework: ArgumentationFramework, semantics: str) -> None: - finder = _FINDERS[semantics] - native = _native_extensions(framework, semantics) - with_simplify = finder(framework, simplify=True) - without_simplify = finder(framework, simplify=False) - # Existence agrees with the oracle and with the unsimplified path. - assert (with_simplify is None) == (not native) - assert (without_simplify is None) == (not native) - if native: - assert with_simplify in native - assert without_simplify in native - # require_in / require_out: existence agrees with the oracle, witness valid. - for query in sorted(framework.arguments): - with_query = {extension for extension in native if query in extension} - wit_in = finder(framework, require_in=query, simplify=True) - assert (wit_in is None) == (not with_query) - if with_query: - assert wit_in in with_query - without_query = {extension for extension in native if query not in extension} - wit_out = finder(framework, require_out=query, simplify=True) - assert (wit_out is None) == (not without_query) - if without_query: - assert wit_out in without_query - - -@pytest.mark.parametrize("framework", _BATTERY) -@pytest.mark.parametrize("semantics", sorted(_FINDERS)) -def test_finder_matches_oracle_on_battery(framework: ArgumentationFramework, semantics: str) -> None: - _assert_finder_matches_oracle(framework, semantics) - - -@given(_random_af(max_args=4)) -@settings(deadline=None, max_examples=120) -def test_finder_matches_oracle_random(framework: ArgumentationFramework) -> None: - for semantics in _FINDERS: - _assert_finder_matches_oracle(framework, semantics) - - -@pytest.mark.parametrize("framework", _BATTERY) -def test_ideal_matches_oracle_on_battery(framework: ArgumentationFramework) -> None: - expected = native_ideal_extension(framework) - assert find_ideal_extension(framework, simplify=True) == expected - assert find_ideal_extension(framework, simplify=False) == expected - - -@given(_random_af(max_args=4)) -@settings(deadline=None, max_examples=120) -def test_ideal_matches_oracle_random(framework: ArgumentationFramework) -> None: - expected = native_ideal_extension(framework) - assert find_ideal_extension(framework, simplify=True) == expected - - -def _native_skeptical_preferred(framework: ArgumentationFramework, query: str) -> bool: - extensions = preferred_extensions(framework) - if not extensions: - return True # vacuously skeptically accepted (no preferred extension) - return all(query in extension for extension in extensions) - - -@pytest.mark.parametrize("framework", _BATTERY) -def test_skeptical_preferred_matches_oracle_on_battery(framework: ArgumentationFramework) -> None: - for query in sorted(framework.arguments): - expected = _native_skeptical_preferred(framework, query) - assert is_preferred_skeptically_accepted(framework, query, simplify=True) == expected - assert is_preferred_skeptically_accepted(framework, query, simplify=False) == expected - - -@given(_random_af(max_args=4)) -@settings(deadline=None, max_examples=120) -def test_skeptical_preferred_matches_oracle_random(framework: ArgumentationFramework) -> None: - for query in sorted(framework.arguments): - expected = _native_skeptical_preferred(framework, query) - assert is_preferred_skeptically_accepted(framework, query, simplify=True) == expected +"""Oracle-equivalence tests for the Wave A AF preprocessing layer. + +The preprocessing layer (:mod:`argumentation.core.preprocessing`) must be exactly +semantics-preserving: solving the reduced AF and lifting the answer back must +equal solving the original AF, for every supported semantics. These tests pin +that against the brute-force reference semantics in :mod:`argumentation.core.dung` +and against the unsimplified SAT path. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.solving.af_sat import ( + find_complete_extension, + find_ideal_extension, + find_preferred_extension, + find_semi_stable_extension, + find_stable_extension, + find_stage_extension, + is_preferred_skeptically_accepted, +) +from argumentation.core.dung import ( + ArgumentationFramework, + grounded_extension, + preferred_extensions, + semi_stable_extensions, + stable_extensions, + stage_extensions, +) +from argumentation.core.dung import complete_extensions as native_complete_extensions +from argumentation.core.dung import ideal_extension as native_ideal_extension +from argumentation.core.preprocessing import ( + AfSimplification, + is_symmetric_irreflexive, + isolated_arguments, + simplify_af, +) + +z3 = pytest.importorskip("z3") # noqa: F841 + + +def _af(args, defeats): + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +# A battery of small, deliberately diverse AFs. +_BATTERY: list[ArgumentationFramework] = [ + _af({"a"}, set()), # single isolated argument + _af({"a"}, {("a", "a")}), # single self-attacker + _af({"a", "b"}, {("a", "b")}), # one-way attack: non-trivial grounded + _af({"a", "b"}, {("a", "b"), ("b", "a")}), # symmetric 2-cycle + _af({"a", "b", "c"}, {("a", "b"), ("b", "c")}), # acyclic chain + _af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}), # 3-cycle + _af({"a", "b", "c", "d"}, {("a", "b"), ("b", "a"), ("c", "d"), ("d", "c")}), # two 2-cycles + _af({"a", "b", "c"}, {("a", "a"), ("a", "b"), ("b", "c")}), # self-attacker with out-edges + _af({"a", "b", "c", "d"}, {("a", "a"), ("b", "b")}), # only self-loop sinks (+ isolated) + _af({"a", "b", "c"}, {("a", "a"), ("b", "c"), ("c", "a")}), # stage breaks naive grounded reduct + _af( + {"a", "b", "c", "d", "e"}, + {("a", "b"), ("b", "c"), ("c", "b"), ("d", "e"), ("e", "d")}, + ), # grounded {a}, then a 2-cycle and another 2-cycle + _af( + {"a", "b", "c", "d"}, + {("a", "c"), ("b", "a"), ("c", "b"), ("a", "d")}, + ), # 3-cycle plus a dominated leaf +] + + +@st.composite +def _random_af(draw, max_args: int = 4): + args = draw( + st.frozensets( + st.text(alphabet="abcd", min_size=1, max_size=2), + min_size=1, + max_size=max_args, + ) + ) + arg_list = sorted(args) + defeats = draw( + st.frozensets( + st.tuples(st.sampled_from(arg_list), st.sampled_from(arg_list)), + max_size=len(arg_list) ** 2, + ) + ) + return ArgumentationFramework(arguments=args, defeats=defeats) + + +# ── Direct invariants of simplify_af ──────────────────────────────── + + +def _check_simplification_shape(framework: ArgumentationFramework, simplification: AfSimplification) -> None: + assert simplification.original is framework + assert simplification.fixed_in <= framework.arguments + assert simplification.removed_out <= framework.arguments + assert simplification.fixed_in.isdisjoint(simplification.removed_out) + removed = simplification.fixed_in | simplification.removed_out + assert simplification.residual.arguments == framework.arguments - removed + # Residual carries exactly the attacks among its surviving arguments. + for attacker, target in framework.defeats: + if attacker in simplification.residual.arguments and target in simplification.residual.arguments: + assert (attacker, target) in simplification.residual.defeats + for attacker, target in simplification.residual.defeats: + assert (attacker, target) in framework.defeats + + +@pytest.mark.parametrize("framework", _BATTERY) +def test_simplify_af_shape_on_battery(framework: ArgumentationFramework) -> None: + for semantics in ("complete", "preferred", "stable", "semi_stable", "stage", "grounded", "ideal", None): + simplification = simplify_af(framework, semantics=semantics) + _check_simplification_shape(framework, simplification) + + +@given(_random_af(max_args=5)) +@settings(deadline=None, max_examples=80) +def test_simplify_af_shape_random(framework: ArgumentationFramework) -> None: + for semantics in ("complete", "preferred", "stable", "semi_stable", "stage", "grounded", "ideal"): + _check_simplification_shape(framework, simplify_af(framework, semantics=semantics)) + + +def test_grounded_reduct_fixes_grounded_in_and_attacked_out() -> None: + framework = _af({"a", "b", "c"}, {("a", "b"), ("b", "c")}) + simplification = simplify_af(framework, semantics="complete") + assert simplification.fixed_in == frozenset({"a", "c"}) + assert simplification.removed_out == frozenset({"b"}) + assert not simplification.residual.arguments + + +def test_self_loop_sink_removed_except_for_stable() -> None: + framework = _af({"a", "b"}, {("a", "a")}) + # b is unattacked -> grounded; a is a pure self-loop sink. + non_stable = simplify_af(framework, semantics="complete") + assert "a" in non_stable.removed_out + stable_view = simplify_af(framework, semantics="stable") + assert "a" not in stable_view.removed_out + assert "a" in stable_view.residual.arguments + + +def test_diagnostics() -> None: + framework = _af({"a", "b", "c"}, {("a", "b"), ("b", "a")}) + assert isolated_arguments(framework) == frozenset({"c"}) + assert is_symmetric_irreflexive(_af({"a", "b"}, {("a", "b"), ("b", "a")})) + assert not is_symmetric_irreflexive(_af({"a"}, {("a", "a")})) + assert not is_symmetric_irreflexive(_af({"a", "b"}, {("a", "b")})) + assert not is_symmetric_irreflexive(_af({"a"}, set())) + + +# ── Oracle equivalence: simplified SAT path == brute-force reference ─ + + +def _native_extensions(framework: ArgumentationFramework, semantics: str) -> set[frozenset[str]]: + if semantics == "complete": + return set(native_complete_extensions(framework)) + if semantics == "preferred": + return set(preferred_extensions(framework)) + if semantics == "stable": + return set(stable_extensions(framework)) + if semantics == "semi_stable": + return set(semi_stable_extensions(framework)) + if semantics == "stage": + return set(stage_extensions(framework)) + if semantics == "grounded": + return {grounded_extension(framework)} + if semantics == "ideal": + return {native_ideal_extension(framework)} + raise AssertionError(semantics) + + +_FINDERS = { + "complete": find_complete_extension, + "preferred": find_preferred_extension, + "stable": find_stable_extension, + "semi_stable": find_semi_stable_extension, + "stage": find_stage_extension, +} + + +def _assert_finder_matches_oracle(framework: ArgumentationFramework, semantics: str) -> None: + finder = _FINDERS[semantics] + native = _native_extensions(framework, semantics) + with_simplify = finder(framework, simplify=True) + without_simplify = finder(framework, simplify=False) + # Existence agrees with the oracle and with the unsimplified path. + assert (with_simplify is None) == (not native) + assert (without_simplify is None) == (not native) + if native: + assert with_simplify in native + assert without_simplify in native + # require_in / require_out: existence agrees with the oracle, witness valid. + for query in sorted(framework.arguments): + with_query = {extension for extension in native if query in extension} + wit_in = finder(framework, require_in=query, simplify=True) + assert (wit_in is None) == (not with_query) + if with_query: + assert wit_in in with_query + without_query = {extension for extension in native if query not in extension} + wit_out = finder(framework, require_out=query, simplify=True) + assert (wit_out is None) == (not without_query) + if without_query: + assert wit_out in without_query + + +@pytest.mark.parametrize("framework", _BATTERY) +@pytest.mark.parametrize("semantics", sorted(_FINDERS)) +def test_finder_matches_oracle_on_battery(framework: ArgumentationFramework, semantics: str) -> None: + _assert_finder_matches_oracle(framework, semantics) + + +@given(_random_af(max_args=4)) +@settings(deadline=None, max_examples=120) +def test_finder_matches_oracle_random(framework: ArgumentationFramework) -> None: + for semantics in _FINDERS: + _assert_finder_matches_oracle(framework, semantics) + + +@pytest.mark.parametrize("framework", _BATTERY) +def test_ideal_matches_oracle_on_battery(framework: ArgumentationFramework) -> None: + expected = native_ideal_extension(framework) + assert find_ideal_extension(framework, simplify=True) == expected + assert find_ideal_extension(framework, simplify=False) == expected + + +@given(_random_af(max_args=4)) +@settings(deadline=None, max_examples=120) +def test_ideal_matches_oracle_random(framework: ArgumentationFramework) -> None: + expected = native_ideal_extension(framework) + assert find_ideal_extension(framework, simplify=True) == expected + + +def _native_skeptical_preferred(framework: ArgumentationFramework, query: str) -> bool: + extensions = preferred_extensions(framework) + if not extensions: + return True # vacuously skeptically accepted (no preferred extension) + return all(query in extension for extension in extensions) + + +@pytest.mark.parametrize("framework", _BATTERY) +def test_skeptical_preferred_matches_oracle_on_battery(framework: ArgumentationFramework) -> None: + for query in sorted(framework.arguments): + expected = _native_skeptical_preferred(framework, query) + assert is_preferred_skeptically_accepted(framework, query, simplify=True) == expected + assert is_preferred_skeptically_accepted(framework, query, simplify=False) == expected + + +@given(_random_af(max_args=4)) +@settings(deadline=None, max_examples=120) +def test_skeptical_preferred_matches_oracle_random(framework: ArgumentationFramework) -> None: + for query in sorted(framework.arguments): + expected = _native_skeptical_preferred(framework, query) + assert is_preferred_skeptically_accepted(framework, query, simplify=True) == expected diff --git a/tests/test_scc_recursive.py b/tests/core/test_scc_recursive.py similarity index 95% rename from tests/test_scc_recursive.py rename to tests/core/test_scc_recursive.py index 6042f04..7f105eb 100644 --- a/tests/test_scc_recursive.py +++ b/tests/core/test_scc_recursive.py @@ -1,226 +1,226 @@ -"""Oracle-equivalence tests for the Wave B2 SCC-recursive solver. - -For complete / preferred / stable: the SCC-recursive result (with preprocessing + -SCC decomposition) lifted to the full argument set MUST equal - - the flat path (``decompose=False``), and - - the brute-force reference in ``argumentation.dung`` -exactly -- same set of extensions, same DC/DS answers -- on every AF. -""" - -from __future__ import annotations - -import random - -import pytest - -from argumentation.dung import ( - ArgumentationFramework, - complete_extensions, - preferred_extensions, - stable_extensions, -) -from argumentation.scc_recursive import ( - LAST_SOLVE, - scc_credulously_accepted, - scc_extensions, - scc_skeptically_accepted, -) - -SEMANTICS = ("complete", "preferred", "stable") - - -def _brute(af: ArgumentationFramework, semantics: str) -> set[frozenset[str]]: - table = { - "complete": complete_extensions, - "preferred": preferred_extensions, - "stable": stable_extensions, - } - return set(table[semantics](af)) - - -def _af(arguments, edges) -> ArgumentationFramework: - return ArgumentationFramework( - arguments=frozenset(arguments), defeats=frozenset(edges) - ) - - -# --------------------------------------------------------------------------- # -# Hand-built battery of multi-SCC AFs -# --------------------------------------------------------------------------- # - -HAND_BUILT: list[ArgumentationFramework] = [ - # empty AF - _af([], []), - # single argument, no edges - _af(["a"], []), - # single self-loop (size-1 SCC with self-loop): CO/PR -> {emptyset}, ST -> none - _af(["a"], [("a", "a")]), - # single 3-cycle (one SCC) - _af(["a", "b", "c"], [("a", "b"), ("b", "c"), ("c", "a")]), - # long grounding chain feeding a 2-cycle - _af( - ["a", "b", "c", "d", "e"], - [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e"), ("e", "d")], - ), - # two 2-cycles, one feeding the other (residual has 2 SCCs) - _af( - ["d", "e", "f", "g"], - [("d", "e"), ("e", "d"), ("f", "g"), ("g", "f"), ("e", "f"), ("d", "g")], - ), - # diamond condensation of 2-cycles: A -> {B, C} -> D - _af( - ["a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"], - [ - ("a1", "a2"), ("a2", "a1"), - ("b1", "b2"), ("b2", "b1"), - ("c1", "c2"), ("c2", "c1"), - ("d1", "d2"), ("d2", "d1"), - ("a1", "b1"), ("a2", "c1"), - ("b1", "d1"), ("c1", "d2"), - ], - ), - # parallel independent SCCs: a 2-cycle and a 3-cycle, disconnected - _af( - ["p", "q", "r", "s", "t"], - [("p", "q"), ("q", "p"), ("r", "s"), ("s", "t"), ("t", "r")], - ), - # SCC attacked by an UNDEC upstream argument (the D-set case): odd cycle x->y->z->x - # is fully undec; it attacks a downstream 2-cycle u<->v via z->u. - _af( - ["x", "y", "z", "u", "v"], - [("x", "y"), ("y", "z"), ("z", "x"), ("z", "u"), ("u", "v"), ("v", "u")], - ), - # self-loop inside a larger SCC: a<->b plus (a,a) - _af(["a", "b"], [("a", "b"), ("b", "a"), ("a", "a")]), - # mixed: chain w -> 2-cycle, plus self-loop sink k, plus isolated m - _af( - ["w", "c1", "c2", "k", "m"], - [("w", "c1"), ("c1", "c2"), ("c2", "c1"), ("k", "k")], - ), - # size-3 SCC feeding a size-1: full triangle a<->b<->c<->a then c->d - _af( - ["a", "b", "c", "d"], - [ - ("a", "b"), ("b", "a"), ("b", "c"), ("c", "b"), ("a", "c"), ("c", "a"), - ("c", "d"), - ], - ), -] - - -@pytest.mark.parametrize("af", HAND_BUILT) -@pytest.mark.parametrize("semantics", SEMANTICS) -def test_hand_built_oracle_equivalence(af: ArgumentationFramework, semantics: str) -> None: - reference = _brute(af, semantics) - decomposed = set(scc_extensions(af, semantics)) - flat = set(scc_extensions(af, semantics, decompose=False)) - assert decomposed == reference - assert flat == reference - - # DC/DS for every argument must agree with the reference - for argument in af.arguments: - ref_dc = any(argument in e for e in reference) - ref_ds = all(argument in e for e in reference) - assert scc_credulously_accepted(af, semantics, argument) == ref_dc - assert scc_skeptically_accepted(af, semantics, argument) == ref_ds - - -# --------------------------------------------------------------------------- # -# Random AF battery (>= 150 instances of varied size/density) -# --------------------------------------------------------------------------- # - - -def _random_afs(count: int, seed: int) -> list[ArgumentationFramework]: - rng = random.Random(seed) - out: list[ArgumentationFramework] = [] - for _ in range(count): - n = rng.randint(0, 8) - args = [f"a{i}" for i in range(n)] - density = rng.choice([0.1, 0.2, 0.3, 0.45]) - allow_self_loops = rng.random() < 0.3 - edges = set() - for a in args: - for b in args: - if a == b and not allow_self_loops: - continue - if rng.random() < density: - edges.add((a, b)) - out.append(_af(args, edges)) - return out - - -@pytest.mark.parametrize("af", _random_afs(180, seed=20260512)) -@pytest.mark.parametrize("semantics", SEMANTICS) -def test_random_oracle_equivalence(af: ArgumentationFramework, semantics: str) -> None: - reference = _brute(af, semantics) - decomposed = set(scc_extensions(af, semantics)) - flat = set(scc_extensions(af, semantics, decompose=False)) - assert decomposed == reference - assert flat == reference - - if af.arguments: - argument = sorted(af.arguments)[0] - ref_dc = any(argument in e for e in reference) - ref_ds = all(argument in e for e in reference) - assert scc_credulously_accepted(af, semantics, argument) == ref_dc - assert scc_skeptically_accepted(af, semantics, argument) == ref_ds - - -def test_recursion_path_is_actually_exercised() -> None: - """At least some random instances must trigger the SCC recursion (not just the - flat fast path) -- otherwise the test battery proves nothing about the recursion.""" - recursed = 0 - for af in _random_afs(300, seed=1): - scc_extensions(af, "complete") - if LAST_SOLVE.flat_fast_path is False: - recursed += 1 - assert recursed > 0 - - -# --------------------------------------------------------------------------- # -# Fast-path behaviour for single-SCC and empty-residual inputs -# --------------------------------------------------------------------------- # - - -def test_single_scc_input_takes_flat_path() -> None: - af = _af(["a", "b", "c"], [("a", "b"), ("b", "c"), ("c", "a")]) - result = scc_extensions(af, "complete") - assert LAST_SOLVE.flat_fast_path is True - assert LAST_SOLVE.residual_scc_count == 1 - assert result == [frozenset()] - - -def test_empty_residual_takes_flat_path() -> None: - # A chain with no cycles -> grounded reduct empties the residual entirely. - af = _af(["a", "b", "c", "d"], [("a", "b"), ("b", "c"), ("c", "d")]) - result = scc_extensions(af, "complete") - assert LAST_SOLVE.flat_fast_path is True - assert LAST_SOLVE.residual_size == 0 - # grounded {a, c}, so the unique complete extension is {a, c} - assert result == [frozenset({"a", "c"})] - - -def test_empty_af_takes_flat_path_and_returns_empty_extension() -> None: - af = _af([], []) - for semantics in SEMANTICS: - result = scc_extensions(af, semantics) - assert LAST_SOLVE.flat_fast_path is True - assert result == [frozenset()] - - -def test_decompose_false_opt_out_matches_flat() -> None: - af = _af( - ["d", "e", "f", "g"], - [("d", "e"), ("e", "d"), ("f", "g"), ("g", "f"), ("e", "f")], - ) - for semantics in SEMANTICS: - opted_out = set(scc_extensions(af, semantics, decompose=False)) - assert LAST_SOLVE.flat_fast_path is True - assert opted_out == _brute(af, semantics) - - -def test_rejects_non_scc_recursive_semantics() -> None: - af = _af(["a"], []) - for semantics in ("semi-stable", "stage", "grounded", "ideal", "admissible"): - with pytest.raises(ValueError): - scc_extensions(af, semantics) +"""Oracle-equivalence tests for the Wave B2 SCC-recursive solver. + +For complete / preferred / stable: the SCC-recursive result (with preprocessing + +SCC decomposition) lifted to the full argument set MUST equal + - the flat path (``decompose=False``), and + - the brute-force reference in ``argumentation.core.dung`` +exactly -- same set of extensions, same DC/DS answers -- on every AF. +""" + +from __future__ import annotations + +import random + +import pytest + +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + preferred_extensions, + stable_extensions, +) +from argumentation.core.scc_recursive import ( + LAST_SOLVE, + scc_credulously_accepted, + scc_extensions, + scc_skeptically_accepted, +) + +SEMANTICS = ("complete", "preferred", "stable") + + +def _brute(af: ArgumentationFramework, semantics: str) -> set[frozenset[str]]: + table = { + "complete": complete_extensions, + "preferred": preferred_extensions, + "stable": stable_extensions, + } + return set(table[semantics](af)) + + +def _af(arguments, edges) -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset(arguments), defeats=frozenset(edges) + ) + + +# --------------------------------------------------------------------------- # +# Hand-built battery of multi-SCC AFs +# --------------------------------------------------------------------------- # + +HAND_BUILT: list[ArgumentationFramework] = [ + # empty AF + _af([], []), + # single argument, no edges + _af(["a"], []), + # single self-loop (size-1 SCC with self-loop): CO/PR -> {emptyset}, ST -> none + _af(["a"], [("a", "a")]), + # single 3-cycle (one SCC) + _af(["a", "b", "c"], [("a", "b"), ("b", "c"), ("c", "a")]), + # long grounding chain feeding a 2-cycle + _af( + ["a", "b", "c", "d", "e"], + [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e"), ("e", "d")], + ), + # two 2-cycles, one feeding the other (residual has 2 SCCs) + _af( + ["d", "e", "f", "g"], + [("d", "e"), ("e", "d"), ("f", "g"), ("g", "f"), ("e", "f"), ("d", "g")], + ), + # diamond condensation of 2-cycles: A -> {B, C} -> D + _af( + ["a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"], + [ + ("a1", "a2"), ("a2", "a1"), + ("b1", "b2"), ("b2", "b1"), + ("c1", "c2"), ("c2", "c1"), + ("d1", "d2"), ("d2", "d1"), + ("a1", "b1"), ("a2", "c1"), + ("b1", "d1"), ("c1", "d2"), + ], + ), + # parallel independent SCCs: a 2-cycle and a 3-cycle, disconnected + _af( + ["p", "q", "r", "s", "t"], + [("p", "q"), ("q", "p"), ("r", "s"), ("s", "t"), ("t", "r")], + ), + # SCC attacked by an UNDEC upstream argument (the D-set case): odd cycle x->y->z->x + # is fully undec; it attacks a downstream 2-cycle u<->v via z->u. + _af( + ["x", "y", "z", "u", "v"], + [("x", "y"), ("y", "z"), ("z", "x"), ("z", "u"), ("u", "v"), ("v", "u")], + ), + # self-loop inside a larger SCC: a<->b plus (a,a) + _af(["a", "b"], [("a", "b"), ("b", "a"), ("a", "a")]), + # mixed: chain w -> 2-cycle, plus self-loop sink k, plus isolated m + _af( + ["w", "c1", "c2", "k", "m"], + [("w", "c1"), ("c1", "c2"), ("c2", "c1"), ("k", "k")], + ), + # size-3 SCC feeding a size-1: full triangle a<->b<->c<->a then c->d + _af( + ["a", "b", "c", "d"], + [ + ("a", "b"), ("b", "a"), ("b", "c"), ("c", "b"), ("a", "c"), ("c", "a"), + ("c", "d"), + ], + ), +] + + +@pytest.mark.parametrize("af", HAND_BUILT) +@pytest.mark.parametrize("semantics", SEMANTICS) +def test_hand_built_oracle_equivalence(af: ArgumentationFramework, semantics: str) -> None: + reference = _brute(af, semantics) + decomposed = set(scc_extensions(af, semantics)) + flat = set(scc_extensions(af, semantics, decompose=False)) + assert decomposed == reference + assert flat == reference + + # DC/DS for every argument must agree with the reference + for argument in af.arguments: + ref_dc = any(argument in e for e in reference) + ref_ds = all(argument in e for e in reference) + assert scc_credulously_accepted(af, semantics, argument) == ref_dc + assert scc_skeptically_accepted(af, semantics, argument) == ref_ds + + +# --------------------------------------------------------------------------- # +# Random AF battery (>= 150 instances of varied size/density) +# --------------------------------------------------------------------------- # + + +def _random_afs(count: int, seed: int) -> list[ArgumentationFramework]: + rng = random.Random(seed) + out: list[ArgumentationFramework] = [] + for _ in range(count): + n = rng.randint(0, 8) + args = [f"a{i}" for i in range(n)] + density = rng.choice([0.1, 0.2, 0.3, 0.45]) + allow_self_loops = rng.random() < 0.3 + edges = set() + for a in args: + for b in args: + if a == b and not allow_self_loops: + continue + if rng.random() < density: + edges.add((a, b)) + out.append(_af(args, edges)) + return out + + +@pytest.mark.parametrize("af", _random_afs(180, seed=20260512)) +@pytest.mark.parametrize("semantics", SEMANTICS) +def test_random_oracle_equivalence(af: ArgumentationFramework, semantics: str) -> None: + reference = _brute(af, semantics) + decomposed = set(scc_extensions(af, semantics)) + flat = set(scc_extensions(af, semantics, decompose=False)) + assert decomposed == reference + assert flat == reference + + if af.arguments: + argument = sorted(af.arguments)[0] + ref_dc = any(argument in e for e in reference) + ref_ds = all(argument in e for e in reference) + assert scc_credulously_accepted(af, semantics, argument) == ref_dc + assert scc_skeptically_accepted(af, semantics, argument) == ref_ds + + +def test_recursion_path_is_actually_exercised() -> None: + """At least some random instances must trigger the SCC recursion (not just the + flat fast path) -- otherwise the test battery proves nothing about the recursion.""" + recursed = 0 + for af in _random_afs(300, seed=1): + scc_extensions(af, "complete") + if LAST_SOLVE.flat_fast_path is False: + recursed += 1 + assert recursed > 0 + + +# --------------------------------------------------------------------------- # +# Fast-path behaviour for single-SCC and empty-residual inputs +# --------------------------------------------------------------------------- # + + +def test_single_scc_input_takes_flat_path() -> None: + af = _af(["a", "b", "c"], [("a", "b"), ("b", "c"), ("c", "a")]) + result = scc_extensions(af, "complete") + assert LAST_SOLVE.flat_fast_path is True + assert LAST_SOLVE.residual_scc_count == 1 + assert result == [frozenset()] + + +def test_empty_residual_takes_flat_path() -> None: + # A chain with no cycles -> grounded reduct empties the residual entirely. + af = _af(["a", "b", "c", "d"], [("a", "b"), ("b", "c"), ("c", "d")]) + result = scc_extensions(af, "complete") + assert LAST_SOLVE.flat_fast_path is True + assert LAST_SOLVE.residual_size == 0 + # grounded {a, c}, so the unique complete extension is {a, c} + assert result == [frozenset({"a", "c"})] + + +def test_empty_af_takes_flat_path_and_returns_empty_extension() -> None: + af = _af([], []) + for semantics in SEMANTICS: + result = scc_extensions(af, semantics) + assert LAST_SOLVE.flat_fast_path is True + assert result == [frozenset()] + + +def test_decompose_false_opt_out_matches_flat() -> None: + af = _af( + ["d", "e", "f", "g"], + [("d", "e"), ("e", "d"), ("f", "g"), ("g", "f"), ("e", "f")], + ) + for semantics in SEMANTICS: + opted_out = set(scc_extensions(af, semantics, decompose=False)) + assert LAST_SOLVE.flat_fast_path is True + assert opted_out == _brute(af, semantics) + + +def test_rejects_non_scc_recursive_semantics() -> None: + af = _af(["a"], []) + for semantics in ("semi-stable", "stage", "grounded", "ideal", "admissible"): + with pytest.raises(ValueError): + scc_extensions(af, semantics) diff --git a/tests/dynamics/__init__.py b/tests/dynamics/__init__.py new file mode 100644 index 0000000..cdc2426 --- /dev/null +++ b/tests/dynamics/__init__.py @@ -0,0 +1 @@ +"""Tests for the dynamics layer.""" diff --git a/tests/test_af_revision.py b/tests/dynamics/test_af_revision.py similarity index 95% rename from tests/test_af_revision.py rename to tests/dynamics/test_af_revision.py index 6444832..4c2e138 100644 --- a/tests/test_af_revision.py +++ b/tests/dynamics/test_af_revision.py @@ -1,369 +1,369 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.af_revision import ( - AFChangeKind, - AFKernelSemantics, - ExtensionRevisionState, - NoStableExtensionsError, - UnknownArgumentRank, - _classify_extension_change, - _extend_state, - baumann_2015_kernel, - baumann_2015_kernel_union_expand, - cayrol_2014_classify_grounded_argument_addition, - diller_2015_revise_by_formula, - diller_2015_revise_by_framework, - stable_kernel, -) -from argumentation.dung import ArgumentationFramework, grounded_extension, stable_extensions - - -pytestmark = pytest.mark.property - -ARGUMENTS = frozenset({"a", "b", "c"}) - - -class Formula(Protocol): - def evaluate(self, extension: frozenset[str]) -> bool: ... - - def atoms(self) -> frozenset[str]: ... - - -@dataclass(frozen=True) -class Atom: - name: str - - def evaluate(self, extension: frozenset[str]) -> bool: - return self.name in extension - - def atoms(self) -> frozenset[str]: - return frozenset((self.name,)) - - def or_(self, other: Formula) -> Formula: - return Or((self, other)) - - -@dataclass(frozen=True) -class Not: - formula: Formula - - def evaluate(self, extension: frozenset[str]) -> bool: - return not self.formula.evaluate(extension) - - def atoms(self) -> frozenset[str]: - return self.formula.atoms() - - -@dataclass(frozen=True) -class And: - formulas: tuple[Formula, ...] - - def evaluate(self, extension: frozenset[str]) -> bool: - return all(formula.evaluate(extension) for formula in self.formulas) - - def atoms(self) -> frozenset[str]: - return frozenset(atom for formula in self.formulas for atom in formula.atoms()) - - -@dataclass(frozen=True) -class Or: - formulas: tuple[Formula, ...] - - def evaluate(self, extension: frozenset[str]) -> bool: - return any(formula.evaluate(extension) for formula in self.formulas) - - def atoms(self) -> frozenset[str]: - return frozenset(atom for formula in self.formulas for atom in formula.atoms()) - - -def negate(formula: Formula) -> Formula: - return Not(formula) - - -def conjunction(*formulas: Formula) -> Formula: - return And(tuple(formulas)) - - -A = Atom("a") -B = Atom("b") -C = Atom("c") -FORMULAS: tuple[Formula, ...] = ( - A, - B, - C, - negate(A), - conjunction(A, B), - conjunction(A, negate(B)), -) - -st_formula = st.sampled_from(FORMULAS) - - -@st.composite -def st_framework(draw) -> ArgumentationFramework: - pairs = tuple((left, right) for left in sorted(ARGUMENTS) for right in sorted(ARGUMENTS)) - defeats = frozenset(draw(st.sets(st.sampled_from(pairs), max_size=5))) - return ArgumentationFramework(arguments=ARGUMENTS, defeats=defeats) - - -@st.composite -def st_revision_state(draw) -> ExtensionRevisionState: - extensions = tuple(frozenset(ext) for ext in stable_extensions(draw(st_framework()))) - if not extensions: - extensions = (frozenset(),) - ranking = { - candidate: draw(st.integers(min_value=0, max_value=4)) - for candidate in ExtensionRevisionState.all_extensions(ARGUMENTS) - } - return ExtensionRevisionState.from_extensions(ARGUMENTS, extensions, ranking=ranking) - - -def _satisfies(extension: frozenset[str], formula: Formula) -> bool: - return formula.evaluate(extension) - - -@given(st_framework(), st_framework()) -@settings(deadline=None) -def test_baumann_brewka_2015_kernel_union_expansion_success_and_inclusion( - base: ArgumentationFramework, - new: ArgumentationFramework, -) -> None: - expanded = baumann_2015_kernel_union_expand(base, new) - union = ArgumentationFramework( - arguments=base.arguments | new.arguments, - defeats=frozenset(base.defeats | new.defeats), - attacks=frozenset((base.attacks or base.defeats) | (new.attacks or new.defeats)), - ) - - assert base.arguments <= expanded.arguments - assert new.arguments <= expanded.arguments - assert expanded == stable_kernel(union) - assert baumann_2015_kernel_union_expand(expanded, new) == expanded - - -def test_baumann_2015_kernel_union_removes_stable_kernel_redundant_attacks() -> None: - """Baumann 2014 stable kernels delete non-self attacks from self-attackers.""" - base = ArgumentationFramework( - arguments=frozenset({"self_attacker", "target"}), - defeats=frozenset( - { - ("self_attacker", "self_attacker"), - ("self_attacker", "target"), - } - ), - ) - new = ArgumentationFramework(arguments=base.arguments, defeats=frozenset()) - - expanded = baumann_2015_kernel_union_expand(base, new) - - assert ("self_attacker", "self_attacker") in expanded.defeats - assert ("self_attacker", "target") not in expanded.defeats - assert stable_extensions(expanded) == stable_extensions(base) - - -def test_baumann_2015_classical_kernels_are_semantics_specific() -> None: - """Classical Baumann kernels keep self-loops but delete different non-self attacks.""" - - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "x", "y"}), - defeats=frozenset( - { - ("a", "a"), - ("a", "b"), - ("a", "x"), - ("a", "y"), - ("b", "b"), - ("x", "a"), - } - ), - ) - - assert baumann_2015_kernel( - framework, - semantics=AFKernelSemantics.STABLE, - ).defeats == frozenset({("a", "a"), ("b", "b"), ("x", "a")}) - assert baumann_2015_kernel( - framework, - semantics=AFKernelSemantics.ADMISSIBLE, - ).defeats == frozenset({("a", "a"), ("a", "y"), ("b", "b"), ("x", "a")}) - assert baumann_2015_kernel( - framework, - semantics=AFKernelSemantics.GROUNDED, - ).defeats == frozenset({("a", "a"), ("a", "x"), ("a", "y"), ("b", "b")}) - assert baumann_2015_kernel( - framework, - semantics=AFKernelSemantics.COMPLETE, - ).defeats == frozenset( - {("a", "a"), ("a", "x"), ("a", "y"), ("b", "b"), ("x", "a")} - ) - - -def test_cayrol_2010_restrictive_classification_for_strict_extension_shrink() -> None: - before = ( - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - frozenset({"d"}), - ) - after = ( - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - ) - - assert _classify_extension_change(before, after) == AFChangeKind.RESTRICTIVE - - -def test_cayrol_2010_restrictive_classification_allows_two_remaining_extensions() -> None: - before = ( - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - ) - after = ( - frozenset({"a"}), - frozenset({"b"}), - ) - - assert _classify_extension_change(before, after) == AFChangeKind.RESTRICTIVE - - -def test_cayrol_2010_questioning_classification_for_more_extensions() -> None: - before = (frozenset({"accepted"}),) - after = ( - frozenset({"accepted"}), - frozenset(), - ) - - assert _classify_extension_change(before, after) == AFChangeKind.QUESTIONING - - -def test_ws_o_arg_cayrol_2010_decisive_uses_surviving_extension_content() -> None: - """Bug 4: a two-extension family collapsing to one old extension is decisive.""" - before = (frozenset({"a"}), frozenset({"b"})) - after = (frozenset({"a"}),) - - assert _classify_extension_change(before, after) == AFChangeKind.DECISIVE - - -def test_ws_o_arg_cayrol_2010_expansive_does_not_require_equal_cardinality() -> None: - """Bug 4: expansive is content-based, not equal-cardinality-based.""" - before = (frozenset({"a"}),) - after = (frozenset({"a", "b"}), frozenset({"a", "c"})) - - assert _classify_extension_change(before, after) == AFChangeKind.EXPANSIVE - - -def test_ws_o_arg_extension_revision_state_accepts_lazy_ranking() -> None: - """Bug 5: construction must not enumerate the full extension powerset.""" - arguments = frozenset(f"a{i}" for i in range(20)) - calls: list[frozenset[str]] = [] - - def ranking(extension: frozenset[str]) -> int: - calls.append(extension) - return 0 if extension == frozenset({"a0"}) else 1 - - state = ExtensionRevisionState.from_extensions( - arguments, - (frozenset({"a0"}),), - ranking=ranking, - ) - - assert calls == [] - assert state.minimal_extensions( - (frozenset({"a0"}), frozenset({"a1"})), - ) == (frozenset({"a0"}),) - assert set(calls) == {frozenset({"a0"}), frozenset({"a1"})} - - -def test_diller_2015_framework_revision_rejects_no_stable_target() -> None: - no_stable = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c"), ("c", "a")}), - ) - state = ExtensionRevisionState.from_extensions(frozenset(), (frozenset(),)) - - assert tuple(stable_extensions(no_stable)) == () - with pytest.raises(NoStableExtensionsError) as exc_info: - diller_2015_revise_by_framework(state, no_stable, semantics="stable") - - assert exc_info.value.framework == no_stable - - -def test_extend_state_unknown_rank_raises() -> None: - state = object.__new__(ExtensionRevisionState) - object.__setattr__(state, "arguments", frozenset({"a"})) - object.__setattr__(state, "extensions", (frozenset({"a"}),)) - object.__setattr__(state, "ranking", {frozenset({"a"}): 0}) - - with pytest.raises(UnknownArgumentRank) as exc_info: - _extend_state(state, frozenset({"a", "x"})) - - assert "x" in str(exc_info.value) - - -@given(st_revision_state(), st_formula) -@settings(deadline=None) -def test_diller_2015_p_star_1_p_star_6_formula_revision( - state: ExtensionRevisionState, - formula: Formula, -) -> None: - result = diller_2015_revise_by_formula(state, formula) - - assert all(_satisfies(extension, formula) for extension in result.extensions) - if any(_satisfies(extension, formula) for extension in state.all_extensions(state.arguments)): - assert result.extensions - - guard = Atom("__top_guard__") - syntactic_variant = conjunction(formula, guard.or_(negate(guard))) - variant = diller_2015_revise_by_formula(state.with_argument("__top_guard__"), syntactic_variant) - projected = tuple(frozenset(arg for arg in ext if arg != "__top_guard__") for ext in variant.extensions) - assert frozenset(projected) == frozenset(result.extensions) - - satisfying = tuple( - extension - for extension in state.all_extensions(state.arguments) - if _satisfies(extension, formula) - ) - assert result.extensions == state.minimal_extensions(satisfying) - - -@given(st_revision_state(), st_framework()) -@settings(deadline=None) -def test_diller_2015_a_star_1_a_star_6_framework_revision( - state: ExtensionRevisionState, - framework: ArgumentationFramework, -) -> None: - target_extensions = tuple(stable_extensions(framework)) - if not target_extensions: - with pytest.raises(NoStableExtensionsError): - diller_2015_revise_by_framework(state, framework, semantics="stable") - return - - result = diller_2015_revise_by_framework(state, framework, semantics="stable") - - assert frozenset(result.extensions) <= frozenset(target_extensions) - if frozenset(state.extensions) & frozenset(target_extensions): - assert frozenset(result.extensions) == frozenset(state.extensions) & frozenset(target_extensions) - if target_extensions: - assert result.extensions - assert result.extensions == state.minimal_extensions(target_extensions) - - -@given(st_framework()) -@settings(deadline=None) -def test_cayrol_2014_grounded_addition_is_never_restrictive_or_questioning( - framework: ArgumentationFramework, -) -> None: - added = "z" - attacks = frozenset({(added, target) for target in grounded_extension(framework)}) - kind = cayrol_2014_classify_grounded_argument_addition(framework, added, attacks) - - assert kind not in {AFChangeKind.RESTRICTIVE, AFChangeKind.QUESTIONING} +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.dynamics.af_revision import ( + AFChangeKind, + AFKernelSemantics, + ExtensionRevisionState, + NoStableExtensionsError, + UnknownArgumentRank, + _classify_extension_change, + _extend_state, + baumann_2015_kernel, + baumann_2015_kernel_union_expand, + cayrol_2014_classify_grounded_argument_addition, + diller_2015_revise_by_formula, + diller_2015_revise_by_framework, + stable_kernel, +) +from argumentation.core.dung import ArgumentationFramework, grounded_extension, stable_extensions + + +pytestmark = pytest.mark.property + +ARGUMENTS = frozenset({"a", "b", "c"}) + + +class Formula(Protocol): + def evaluate(self, extension: frozenset[str]) -> bool: ... + + def atoms(self) -> frozenset[str]: ... + + +@dataclass(frozen=True) +class Atom: + name: str + + def evaluate(self, extension: frozenset[str]) -> bool: + return self.name in extension + + def atoms(self) -> frozenset[str]: + return frozenset((self.name,)) + + def or_(self, other: Formula) -> Formula: + return Or((self, other)) + + +@dataclass(frozen=True) +class Not: + formula: Formula + + def evaluate(self, extension: frozenset[str]) -> bool: + return not self.formula.evaluate(extension) + + def atoms(self) -> frozenset[str]: + return self.formula.atoms() + + +@dataclass(frozen=True) +class And: + formulas: tuple[Formula, ...] + + def evaluate(self, extension: frozenset[str]) -> bool: + return all(formula.evaluate(extension) for formula in self.formulas) + + def atoms(self) -> frozenset[str]: + return frozenset(atom for formula in self.formulas for atom in formula.atoms()) + + +@dataclass(frozen=True) +class Or: + formulas: tuple[Formula, ...] + + def evaluate(self, extension: frozenset[str]) -> bool: + return any(formula.evaluate(extension) for formula in self.formulas) + + def atoms(self) -> frozenset[str]: + return frozenset(atom for formula in self.formulas for atom in formula.atoms()) + + +def negate(formula: Formula) -> Formula: + return Not(formula) + + +def conjunction(*formulas: Formula) -> Formula: + return And(tuple(formulas)) + + +A = Atom("a") +B = Atom("b") +C = Atom("c") +FORMULAS: tuple[Formula, ...] = ( + A, + B, + C, + negate(A), + conjunction(A, B), + conjunction(A, negate(B)), +) + +st_formula = st.sampled_from(FORMULAS) + + +@st.composite +def st_framework(draw) -> ArgumentationFramework: + pairs = tuple((left, right) for left in sorted(ARGUMENTS) for right in sorted(ARGUMENTS)) + defeats = frozenset(draw(st.sets(st.sampled_from(pairs), max_size=5))) + return ArgumentationFramework(arguments=ARGUMENTS, defeats=defeats) + + +@st.composite +def st_revision_state(draw) -> ExtensionRevisionState: + extensions = tuple(frozenset(ext) for ext in stable_extensions(draw(st_framework()))) + if not extensions: + extensions = (frozenset(),) + ranking = { + candidate: draw(st.integers(min_value=0, max_value=4)) + for candidate in ExtensionRevisionState.all_extensions(ARGUMENTS) + } + return ExtensionRevisionState.from_extensions(ARGUMENTS, extensions, ranking=ranking) + + +def _satisfies(extension: frozenset[str], formula: Formula) -> bool: + return formula.evaluate(extension) + + +@given(st_framework(), st_framework()) +@settings(deadline=None) +def test_baumann_brewka_2015_kernel_union_expansion_success_and_inclusion( + base: ArgumentationFramework, + new: ArgumentationFramework, +) -> None: + expanded = baumann_2015_kernel_union_expand(base, new) + union = ArgumentationFramework( + arguments=base.arguments | new.arguments, + defeats=frozenset(base.defeats | new.defeats), + attacks=frozenset((base.attacks or base.defeats) | (new.attacks or new.defeats)), + ) + + assert base.arguments <= expanded.arguments + assert new.arguments <= expanded.arguments + assert expanded == stable_kernel(union) + assert baumann_2015_kernel_union_expand(expanded, new) == expanded + + +def test_baumann_2015_kernel_union_removes_stable_kernel_redundant_attacks() -> None: + """Baumann 2014 stable kernels delete non-self attacks from self-attackers.""" + base = ArgumentationFramework( + arguments=frozenset({"self_attacker", "target"}), + defeats=frozenset( + { + ("self_attacker", "self_attacker"), + ("self_attacker", "target"), + } + ), + ) + new = ArgumentationFramework(arguments=base.arguments, defeats=frozenset()) + + expanded = baumann_2015_kernel_union_expand(base, new) + + assert ("self_attacker", "self_attacker") in expanded.defeats + assert ("self_attacker", "target") not in expanded.defeats + assert stable_extensions(expanded) == stable_extensions(base) + + +def test_baumann_2015_classical_kernels_are_semantics_specific() -> None: + """Classical Baumann kernels keep self-loops but delete different non-self attacks.""" + + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "x", "y"}), + defeats=frozenset( + { + ("a", "a"), + ("a", "b"), + ("a", "x"), + ("a", "y"), + ("b", "b"), + ("x", "a"), + } + ), + ) + + assert baumann_2015_kernel( + framework, + semantics=AFKernelSemantics.STABLE, + ).defeats == frozenset({("a", "a"), ("b", "b"), ("x", "a")}) + assert baumann_2015_kernel( + framework, + semantics=AFKernelSemantics.ADMISSIBLE, + ).defeats == frozenset({("a", "a"), ("a", "y"), ("b", "b"), ("x", "a")}) + assert baumann_2015_kernel( + framework, + semantics=AFKernelSemantics.GROUNDED, + ).defeats == frozenset({("a", "a"), ("a", "x"), ("a", "y"), ("b", "b")}) + assert baumann_2015_kernel( + framework, + semantics=AFKernelSemantics.COMPLETE, + ).defeats == frozenset( + {("a", "a"), ("a", "x"), ("a", "y"), ("b", "b"), ("x", "a")} + ) + + +def test_cayrol_2010_restrictive_classification_for_strict_extension_shrink() -> None: + before = ( + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + frozenset({"d"}), + ) + after = ( + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + ) + + assert _classify_extension_change(before, after) == AFChangeKind.RESTRICTIVE + + +def test_cayrol_2010_restrictive_classification_allows_two_remaining_extensions() -> None: + before = ( + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + ) + after = ( + frozenset({"a"}), + frozenset({"b"}), + ) + + assert _classify_extension_change(before, after) == AFChangeKind.RESTRICTIVE + + +def test_cayrol_2010_questioning_classification_for_more_extensions() -> None: + before = (frozenset({"accepted"}),) + after = ( + frozenset({"accepted"}), + frozenset(), + ) + + assert _classify_extension_change(before, after) == AFChangeKind.QUESTIONING + + +def test_ws_o_arg_cayrol_2010_decisive_uses_surviving_extension_content() -> None: + """Bug 4: a two-extension family collapsing to one old extension is decisive.""" + before = (frozenset({"a"}), frozenset({"b"})) + after = (frozenset({"a"}),) + + assert _classify_extension_change(before, after) == AFChangeKind.DECISIVE + + +def test_ws_o_arg_cayrol_2010_expansive_does_not_require_equal_cardinality() -> None: + """Bug 4: expansive is content-based, not equal-cardinality-based.""" + before = (frozenset({"a"}),) + after = (frozenset({"a", "b"}), frozenset({"a", "c"})) + + assert _classify_extension_change(before, after) == AFChangeKind.EXPANSIVE + + +def test_ws_o_arg_extension_revision_state_accepts_lazy_ranking() -> None: + """Bug 5: construction must not enumerate the full extension powerset.""" + arguments = frozenset(f"a{i}" for i in range(20)) + calls: list[frozenset[str]] = [] + + def ranking(extension: frozenset[str]) -> int: + calls.append(extension) + return 0 if extension == frozenset({"a0"}) else 1 + + state = ExtensionRevisionState.from_extensions( + arguments, + (frozenset({"a0"}),), + ranking=ranking, + ) + + assert calls == [] + assert state.minimal_extensions( + (frozenset({"a0"}), frozenset({"a1"})), + ) == (frozenset({"a0"}),) + assert set(calls) == {frozenset({"a0"}), frozenset({"a1"})} + + +def test_diller_2015_framework_revision_rejects_no_stable_target() -> None: + no_stable = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c"), ("c", "a")}), + ) + state = ExtensionRevisionState.from_extensions(frozenset(), (frozenset(),)) + + assert tuple(stable_extensions(no_stable)) == () + with pytest.raises(NoStableExtensionsError) as exc_info: + diller_2015_revise_by_framework(state, no_stable, semantics="stable") + + assert exc_info.value.framework == no_stable + + +def test_extend_state_unknown_rank_raises() -> None: + state = object.__new__(ExtensionRevisionState) + object.__setattr__(state, "arguments", frozenset({"a"})) + object.__setattr__(state, "extensions", (frozenset({"a"}),)) + object.__setattr__(state, "ranking", {frozenset({"a"}): 0}) + + with pytest.raises(UnknownArgumentRank) as exc_info: + _extend_state(state, frozenset({"a", "x"})) + + assert "x" in str(exc_info.value) + + +@given(st_revision_state(), st_formula) +@settings(deadline=None) +def test_diller_2015_p_star_1_p_star_6_formula_revision( + state: ExtensionRevisionState, + formula: Formula, +) -> None: + result = diller_2015_revise_by_formula(state, formula) + + assert all(_satisfies(extension, formula) for extension in result.extensions) + if any(_satisfies(extension, formula) for extension in state.all_extensions(state.arguments)): + assert result.extensions + + guard = Atom("__top_guard__") + syntactic_variant = conjunction(formula, guard.or_(negate(guard))) + variant = diller_2015_revise_by_formula(state.with_argument("__top_guard__"), syntactic_variant) + projected = tuple(frozenset(arg for arg in ext if arg != "__top_guard__") for ext in variant.extensions) + assert frozenset(projected) == frozenset(result.extensions) + + satisfying = tuple( + extension + for extension in state.all_extensions(state.arguments) + if _satisfies(extension, formula) + ) + assert result.extensions == state.minimal_extensions(satisfying) + + +@given(st_revision_state(), st_framework()) +@settings(deadline=None) +def test_diller_2015_a_star_1_a_star_6_framework_revision( + state: ExtensionRevisionState, + framework: ArgumentationFramework, +) -> None: + target_extensions = tuple(stable_extensions(framework)) + if not target_extensions: + with pytest.raises(NoStableExtensionsError): + diller_2015_revise_by_framework(state, framework, semantics="stable") + return + + result = diller_2015_revise_by_framework(state, framework, semantics="stable") + + assert frozenset(result.extensions) <= frozenset(target_extensions) + if frozenset(state.extensions) & frozenset(target_extensions): + assert frozenset(result.extensions) == frozenset(state.extensions) & frozenset(target_extensions) + if target_extensions: + assert result.extensions + assert result.extensions == state.minimal_extensions(target_extensions) + + +@given(st_framework()) +@settings(deadline=None) +def test_cayrol_2014_grounded_addition_is_never_restrictive_or_questioning( + framework: ArgumentationFramework, +) -> None: + added = "z" + attacks = frozenset({(added, target) for target in grounded_extension(framework)}) + kind = cayrol_2014_classify_grounded_argument_addition(framework, added, attacks) + + assert kind not in {AFChangeKind.RESTRICTIVE, AFChangeKind.QUESTIONING} diff --git a/tests/test_approximate.py b/tests/dynamics/test_approximate.py similarity index 90% rename from tests/test_approximate.py rename to tests/dynamics/test_approximate.py index 6c8cc94..1658d85 100644 --- a/tests/test_approximate.py +++ b/tests/dynamics/test_approximate.py @@ -1,65 +1,65 @@ -from __future__ import annotations - -from argumentation.approximate import ( - approximate_grounded, - approximate_semi_stable, - k_stable_extensions, -) -from argumentation.dung import ArgumentationFramework, semi_stable_extensions, stable_extensions - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def test_maximum_k_stable_extensions_match_stable_semantics() -> None: - framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) - - assert set(k_stable_extensions(framework, k=2)) == set(stable_extensions(framework)) - - -def test_k_stable_uses_minimum_range_size() -> None: - framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) - - assert set(k_stable_extensions(framework, k=2)) == { - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - } - assert set(k_stable_extensions(framework, k=0)) == { - frozenset(), - frozenset({"a"}), - frozenset({"b"}), - frozenset({"c"}), - } - - -def test_bounded_grounded_iterations_are_monotone_prefixes() -> None: - framework = af({"a", "b", "c", "d"}, {("a", "b"), ("b", "c"), ("c", "d")}) - - one = approximate_grounded(framework, k_iterations=1) - two = approximate_grounded(framework, k_iterations=2) - - assert one.extension == frozenset({"a"}) - assert two.extension == frozenset({"a", "c"}) - assert one.extension <= two.extension - assert two.exact is True - - -def test_approximate_semi_stable_exact_budget_matches_reference() -> None: - framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) - - result = approximate_semi_stable(framework, max_candidates=None) - - assert result.exact is True - assert set(result.extensions) == set(semi_stable_extensions(framework)) - - -def test_approximate_semi_stable_limited_budget_reports_inexact_witnesses() -> None: - framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) - - result = approximate_semi_stable(framework, max_candidates=2) - - assert result.exact is False - assert result.examined_candidates == 2 - assert result.extensions +from __future__ import annotations + +from argumentation.dynamics.approximate import ( + approximate_grounded, + approximate_semi_stable, + k_stable_extensions, +) +from argumentation.core.dung import ArgumentationFramework, semi_stable_extensions, stable_extensions + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def test_maximum_k_stable_extensions_match_stable_semantics() -> None: + framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) + + assert set(k_stable_extensions(framework, k=2)) == set(stable_extensions(framework)) + + +def test_k_stable_uses_minimum_range_size() -> None: + framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) + + assert set(k_stable_extensions(framework, k=2)) == { + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + } + assert set(k_stable_extensions(framework, k=0)) == { + frozenset(), + frozenset({"a"}), + frozenset({"b"}), + frozenset({"c"}), + } + + +def test_bounded_grounded_iterations_are_monotone_prefixes() -> None: + framework = af({"a", "b", "c", "d"}, {("a", "b"), ("b", "c"), ("c", "d")}) + + one = approximate_grounded(framework, k_iterations=1) + two = approximate_grounded(framework, k_iterations=2) + + assert one.extension == frozenset({"a"}) + assert two.extension == frozenset({"a", "c"}) + assert one.extension <= two.extension + assert two.exact is True + + +def test_approximate_semi_stable_exact_budget_matches_reference() -> None: + framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) + + result = approximate_semi_stable(framework, max_candidates=None) + + assert result.exact is True + assert set(result.extensions) == set(semi_stable_extensions(framework)) + + +def test_approximate_semi_stable_limited_budget_reports_inexact_witnesses() -> None: + framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) + + result = approximate_semi_stable(framework, max_candidates=2) + + assert result.exact is False + assert result.examined_candidates == 2 + assert result.extensions diff --git a/tests/test_dynamic.py b/tests/dynamics/test_dynamic.py similarity index 94% rename from tests/test_dynamic.py rename to tests/dynamics/test_dynamic.py index f01ffd4..fbb3f08 100644 --- a/tests/test_dynamic.py +++ b/tests/dynamics/test_dynamic.py @@ -1,238 +1,238 @@ -from __future__ import annotations - -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework -from argumentation.dynamic import ( - DynamicRecomputeOracle, - DynamicArgumentationFramework, - DynamicUpdate, - IncrementalDynamicArgumentationFramework, - incremental_extension_update, - apply_update_stream, - parse_update_stream, -) -from argumentation.enforcement import extensions_for - - -def test_dynamic_queries_recompute_after_attack_updates() -> None: - dynamic = DynamicArgumentationFramework( - ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()) - ) - - assert dynamic.query_skeptical("b", semantics="grounded") is True - - dynamic.add_attack("a", "b") - - assert dynamic.query_credulous("b", semantics="preferred") is False - assert dynamic.query_skeptical("a", semantics="grounded") is True - - -def test_dynamic_argument_removal_drops_incident_attacks() -> None: - dynamic = DynamicArgumentationFramework( - ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - ) - - dynamic.remove_argument("a") - - assert dynamic.framework.arguments == frozenset({"b"}) - assert dynamic.framework.defeats == frozenset() - - -def test_parse_and_apply_update_stream() -> None: - updates = parse_update_stream( - """ - add_arg a - add_arg b - add_att a b - del_att a b - """ - ) - dynamic = apply_update_stream( - DynamicArgumentationFramework( - ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - ), - updates, - ) - - assert dynamic.framework.arguments == frozenset({"a", "b"}) - assert dynamic.framework.defeats == frozenset() - - -def test_recompute_oracle_matches_direct_final_framework_for_update_stream() -> None: - initial = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - updates = ( - DynamicUpdate("add_arg", "d"), - DynamicUpdate("add_att", "d", "a"), - DynamicUpdate("del_arg", "b"), - ) - - oracle = DynamicRecomputeOracle(initial) - result = oracle.apply_all(updates) - - assert result.framework == ArgumentationFramework( - arguments=frozenset({"a", "c", "d"}), - defeats=frozenset({("d", "a")}), - ) - assert result.extensions("grounded") == extensions_for(result.framework, "grounded") - - -@settings(max_examples=80) -@given( - add_a=st.booleans(), - add_b=st.booleans(), - add_c=st.booleans(), - del_a=st.booleans(), - add_ab=st.booleans(), - del_ab=st.booleans(), -) -def test_update_stream_operations_match_dynamic_track_set_effects( - add_a: bool, - add_b: bool, - add_c: bool, - del_a: bool, - add_ab: bool, - del_ab: bool, -) -> None: - updates: list[DynamicUpdate] = [] - expected_arguments: set[str] = set() - expected_defeats: set[tuple[str, str]] = set() - for enabled, argument in ((add_a, "a"), (add_b, "b"), (add_c, "c")): - if enabled: - updates.append(DynamicUpdate("add_arg", argument)) - expected_arguments.add(argument) - if add_ab and {"a", "b"} <= expected_arguments: - updates.append(DynamicUpdate("add_att", "a", "b")) - expected_defeats.add(("a", "b")) - if del_ab: - updates.append(DynamicUpdate("del_att", "a", "b")) - expected_defeats.discard(("a", "b")) - if del_a: - updates.append(DynamicUpdate("del_arg", "a")) - expected_arguments.discard("a") - expected_defeats = { - defeat for defeat in expected_defeats if "a" not in defeat - } - - oracle = DynamicRecomputeOracle( - ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - ) - - assert oracle.apply_all(tuple(updates)).framework == ArgumentationFramework( - arguments=frozenset(expected_arguments), - defeats=frozenset(expected_defeats), - ) - - -def example_6_framework() -> ArgumentationFramework: - return ArgumentationFramework( - arguments=frozenset({"a", "b", "c", "d", "e"}), - defeats=frozenset( - { - ("a", "b"), - ("b", "c"), - ("b", "d"), - ("c", "d"), - ("c", "e"), - ("e", "c"), - } - ), - ) - - -def test_incremental_algorithm_reuses_extension_for_irrelevant_stable_update() -> None: - framework = example_6_framework() - - result = incremental_extension_update( - framework, - DynamicUpdate("add_att", "d", "d"), - semantics="stable", - initial_extension=frozenset({"a", "c"}), - ) - - assert result.extension == frozenset({"a", "c"}) - assert result.influenced == frozenset() - assert result.used_incremental is True - assert result.fallback_reason is None - - -def test_incremental_algorithm_falls_back_when_stable_reduced_af_has_no_extension() -> None: - framework = example_6_framework() - - result = incremental_extension_update( - framework, - DynamicUpdate("add_att", "d", "d"), - semantics="stable", - initial_extension=frozenset({"a", "d", "e"}), - ) - - assert result.influenced == frozenset({"d"}) - assert result.reduced_framework == ArgumentationFramework( - arguments=frozenset({"d"}), - defeats=frozenset({("d", "d")}), - ) - assert result.used_incremental is False - assert result.fallback_reason == "reduced_solver_no_extension" - assert result.extension in extensions_for(result.updated_framework, "stable") - - -def test_incremental_algorithm_combines_reduced_preferred_extension_without_fallback() -> None: - framework = example_6_framework() - - result = incremental_extension_update( - framework, - DynamicUpdate("add_att", "d", "d"), - semantics="preferred", - initial_extension=frozenset({"a", "d", "e"}), - ) - - assert result.influenced == frozenset({"d"}) - assert result.reduced_extension == frozenset() - assert result.extension == frozenset({"a", "e"}) - assert result.used_incremental is True - assert result.fallback_reason is None - assert result.extension in extensions_for(result.updated_framework, "preferred") - - -def test_incremental_state_queries_expose_witnesses_and_counterexamples() -> None: - dynamic = IncrementalDynamicArgumentationFramework( - example_6_framework(), - semantics="stable", - current_extension=frozenset({"a", "d", "e"}), - ) - - result = dynamic.apply(DynamicUpdate("add_att", "d", "d")) - - assert result.used_incremental is False - assert result.fallback_reason == "reduced_solver_no_extension" - assert dynamic.current_extension == frozenset({"a", "c"}) - - credulous = dynamic.query_credulous("c") - skeptical = dynamic.query_skeptical("d") - - assert credulous.accepted is True - assert credulous.witness == frozenset({"a", "c"}) - assert skeptical.accepted is False - assert skeptical.counterexample == frozenset({"a", "c"}) - - -def test_incremental_state_reports_honest_recompute_for_unsupported_update_kind() -> None: - dynamic = IncrementalDynamicArgumentationFramework( - ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()), - semantics="grounded", - current_extension=frozenset({"a"}), - ) - - result = dynamic.apply(DynamicUpdate("add_arg", "b")) - - assert result.used_incremental is False - assert result.fallback_reason == "unsupported_update_kind" - assert result.updated_framework.arguments == frozenset({"a", "b"}) - assert dynamic.current_extension == frozenset({"a", "b"}) +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework +from argumentation.dynamics.dynamic import ( + DynamicRecomputeOracle, + DynamicArgumentationFramework, + DynamicUpdate, + IncrementalDynamicArgumentationFramework, + incremental_extension_update, + apply_update_stream, + parse_update_stream, +) +from argumentation.dynamics.enforcement import extensions_for + + +def test_dynamic_queries_recompute_after_attack_updates() -> None: + dynamic = DynamicArgumentationFramework( + ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()) + ) + + assert dynamic.query_skeptical("b", semantics="grounded") is True + + dynamic.add_attack("a", "b") + + assert dynamic.query_credulous("b", semantics="preferred") is False + assert dynamic.query_skeptical("a", semantics="grounded") is True + + +def test_dynamic_argument_removal_drops_incident_attacks() -> None: + dynamic = DynamicArgumentationFramework( + ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + ) + + dynamic.remove_argument("a") + + assert dynamic.framework.arguments == frozenset({"b"}) + assert dynamic.framework.defeats == frozenset() + + +def test_parse_and_apply_update_stream() -> None: + updates = parse_update_stream( + """ + add_arg a + add_arg b + add_att a b + del_att a b + """ + ) + dynamic = apply_update_stream( + DynamicArgumentationFramework( + ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + ), + updates, + ) + + assert dynamic.framework.arguments == frozenset({"a", "b"}) + assert dynamic.framework.defeats == frozenset() + + +def test_recompute_oracle_matches_direct_final_framework_for_update_stream() -> None: + initial = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + updates = ( + DynamicUpdate("add_arg", "d"), + DynamicUpdate("add_att", "d", "a"), + DynamicUpdate("del_arg", "b"), + ) + + oracle = DynamicRecomputeOracle(initial) + result = oracle.apply_all(updates) + + assert result.framework == ArgumentationFramework( + arguments=frozenset({"a", "c", "d"}), + defeats=frozenset({("d", "a")}), + ) + assert result.extensions("grounded") == extensions_for(result.framework, "grounded") + + +@settings(max_examples=80) +@given( + add_a=st.booleans(), + add_b=st.booleans(), + add_c=st.booleans(), + del_a=st.booleans(), + add_ab=st.booleans(), + del_ab=st.booleans(), +) +def test_update_stream_operations_match_dynamic_track_set_effects( + add_a: bool, + add_b: bool, + add_c: bool, + del_a: bool, + add_ab: bool, + del_ab: bool, +) -> None: + updates: list[DynamicUpdate] = [] + expected_arguments: set[str] = set() + expected_defeats: set[tuple[str, str]] = set() + for enabled, argument in ((add_a, "a"), (add_b, "b"), (add_c, "c")): + if enabled: + updates.append(DynamicUpdate("add_arg", argument)) + expected_arguments.add(argument) + if add_ab and {"a", "b"} <= expected_arguments: + updates.append(DynamicUpdate("add_att", "a", "b")) + expected_defeats.add(("a", "b")) + if del_ab: + updates.append(DynamicUpdate("del_att", "a", "b")) + expected_defeats.discard(("a", "b")) + if del_a: + updates.append(DynamicUpdate("del_arg", "a")) + expected_arguments.discard("a") + expected_defeats = { + defeat for defeat in expected_defeats if "a" not in defeat + } + + oracle = DynamicRecomputeOracle( + ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + ) + + assert oracle.apply_all(tuple(updates)).framework == ArgumentationFramework( + arguments=frozenset(expected_arguments), + defeats=frozenset(expected_defeats), + ) + + +def example_6_framework() -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset({"a", "b", "c", "d", "e"}), + defeats=frozenset( + { + ("a", "b"), + ("b", "c"), + ("b", "d"), + ("c", "d"), + ("c", "e"), + ("e", "c"), + } + ), + ) + + +def test_incremental_algorithm_reuses_extension_for_irrelevant_stable_update() -> None: + framework = example_6_framework() + + result = incremental_extension_update( + framework, + DynamicUpdate("add_att", "d", "d"), + semantics="stable", + initial_extension=frozenset({"a", "c"}), + ) + + assert result.extension == frozenset({"a", "c"}) + assert result.influenced == frozenset() + assert result.used_incremental is True + assert result.fallback_reason is None + + +def test_incremental_algorithm_falls_back_when_stable_reduced_af_has_no_extension() -> None: + framework = example_6_framework() + + result = incremental_extension_update( + framework, + DynamicUpdate("add_att", "d", "d"), + semantics="stable", + initial_extension=frozenset({"a", "d", "e"}), + ) + + assert result.influenced == frozenset({"d"}) + assert result.reduced_framework == ArgumentationFramework( + arguments=frozenset({"d"}), + defeats=frozenset({("d", "d")}), + ) + assert result.used_incremental is False + assert result.fallback_reason == "reduced_solver_no_extension" + assert result.extension in extensions_for(result.updated_framework, "stable") + + +def test_incremental_algorithm_combines_reduced_preferred_extension_without_fallback() -> None: + framework = example_6_framework() + + result = incremental_extension_update( + framework, + DynamicUpdate("add_att", "d", "d"), + semantics="preferred", + initial_extension=frozenset({"a", "d", "e"}), + ) + + assert result.influenced == frozenset({"d"}) + assert result.reduced_extension == frozenset() + assert result.extension == frozenset({"a", "e"}) + assert result.used_incremental is True + assert result.fallback_reason is None + assert result.extension in extensions_for(result.updated_framework, "preferred") + + +def test_incremental_state_queries_expose_witnesses_and_counterexamples() -> None: + dynamic = IncrementalDynamicArgumentationFramework( + example_6_framework(), + semantics="stable", + current_extension=frozenset({"a", "d", "e"}), + ) + + result = dynamic.apply(DynamicUpdate("add_att", "d", "d")) + + assert result.used_incremental is False + assert result.fallback_reason == "reduced_solver_no_extension" + assert dynamic.current_extension == frozenset({"a", "c"}) + + credulous = dynamic.query_credulous("c") + skeptical = dynamic.query_skeptical("d") + + assert credulous.accepted is True + assert credulous.witness == frozenset({"a", "c"}) + assert skeptical.accepted is False + assert skeptical.counterexample == frozenset({"a", "c"}) + + +def test_incremental_state_reports_honest_recompute_for_unsupported_update_kind() -> None: + dynamic = IncrementalDynamicArgumentationFramework( + ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()), + semantics="grounded", + current_extension=frozenset({"a"}), + ) + + result = dynamic.apply(DynamicUpdate("add_arg", "b")) + + assert result.used_incremental is False + assert result.fallback_reason == "unsupported_update_kind" + assert result.updated_framework.arguments == frozenset({"a", "b"}) + assert dynamic.current_extension == frozenset({"a", "b"}) diff --git a/tests/test_enforcement.py b/tests/dynamics/test_enforcement.py similarity index 95% rename from tests/test_enforcement.py rename to tests/dynamics/test_enforcement.py index 19406bd..563c6d3 100644 --- a/tests/test_enforcement.py +++ b/tests/dynamics/test_enforcement.py @@ -1,224 +1,224 @@ -from __future__ import annotations - -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework, stable_extensions -from argumentation.enforcement import ( - AFEdit, - apply_edit, - enforce_credulous, - enforce_extension, - enforce_expansion_credulous, - enforce_liberal_expansion_extension, - enforce_skeptical, - extensions_for, - is_normal_expansion, - is_strong_expansion, - is_weak_expansion, -) - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def test_credulous_enforcement_returns_minimal_hamming_change() -> None: - framework = af({"a", "b"}, {("a", "b")}) - - result = enforce_credulous(framework, "b", semantics="preferred") - - assert result.cost == 1 - assert "b" in result.accepted_arguments - assert any("b" in extension for extension in result.extensions) - assert result.witness_framework == apply_edit(framework, result.edit) - - -def test_skeptical_enforcement_makes_argument_accepted_in_every_extension() -> None: - framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) - - result = enforce_skeptical(framework, "a", semantics="stable") - - assert result.cost == 1 - assert result.accepted_arguments == frozenset({"a"}) - assert result.extensions == (frozenset({"a"}),) - - -def test_extension_enforcement_makes_target_a_stable_extension() -> None: - framework = af({"a", "b"}, {("a", "b")}) - - result = enforce_extension(framework, frozenset({"b"}), semantics="stable") - - assert result.cost == 1 - assert frozenset({"b"}) in stable_extensions(result.witness_framework) - - -def test_apply_edit_can_add_and_remove_arguments_and_defeats() -> None: - framework = af({"a", "b"}, {("a", "b")}) - edit = AFEdit( - add_arguments=frozenset({"c"}), - remove_arguments=frozenset({"a"}), - add_defeats=frozenset({("c", "b")}), - remove_defeats=frozenset({("a", "b")}), - ) - - assert apply_edit(framework, edit) == af({"b", "c"}, {("c", "b")}) - - -def test_fixed_argument_extension_enforcement_matches_wallner_example_strict() -> None: - framework = af( - {"a", "b", "c", "d"}, - {("b", "a"), ("b", "c"), ("c", "a"), ("c", "d"), ("d", "b")}, - ) - - result = enforce_extension( - framework, - frozenset({"a"}), - semantics="complete", - variant="strict", - max_cost=2, - ) - - assert result.cost == 2 - assert result.edit.remove_defeats == frozenset({("b", "a"), ("c", "a")}) - assert result.edit.add_defeats == frozenset() - assert frozenset({"a"}) in result.extensions - - -def test_fixed_argument_extension_enforcement_matches_wallner_example_non_strict() -> None: - framework = af( - {"a", "b", "c", "d"}, - {("b", "a"), ("b", "c"), ("c", "a"), ("c", "d"), ("d", "b")}, - ) - - result = enforce_extension( - framework, - frozenset({"a"}), - semantics="complete", - variant="non-strict", - max_cost=1, - ) - - assert result.cost == 1 - assert len(result.edit.add_defeats) == 1 - assert result.edit.remove_defeats == frozenset() - assert any(frozenset({"a"}) < extension for extension in result.extensions) - - -def test_expansion_credulous_enforcement_rejects_old_attack_deletion() -> None: - framework = af({"a", "b"}, {("a", "b")}) - - result = enforce_expansion_credulous( - framework, - "b", - semantics="preferred", - kind="normal", - candidate_new_arguments=frozenset({"x1"}), - max_new_arguments=1, - max_added_defeats=1, - ) - - assert result.cost == 2 - assert result.expansion.new_arguments == frozenset({"x1"}) - assert result.expansion.added_defeats == frozenset({("x1", "a")}) - assert framework.defeats <= result.witness_framework.defeats - assert result.witness_framework.defeats & {("b", "a")} == frozenset() - assert is_normal_expansion(framework, result.witness_framework) - assert any("b" in extension for extension in result.extensions) - - -@settings(max_examples=100) -@given( - old_defeat_ab=st.booleans(), - old_defeat_ba=st.booleans(), - old_old_add_ab=st.booleans(), - old_old_add_ba=st.booleans(), - old_to_new=st.booleans(), - new_to_old=st.booleans(), - new_to_new=st.booleans(), -) -def test_normal_expansion_iff_old_material_preserved_and_only_new_interactions_added( - old_defeat_ab: bool, - old_defeat_ba: bool, - old_old_add_ab: bool, - old_old_add_ba: bool, - old_to_new: bool, - new_to_old: bool, - new_to_new: bool, -) -> None: - old_defeats = { - defeat - for include, defeat in ( - (old_defeat_ab, ("a", "b")), - (old_defeat_ba, ("b", "a")), - ) - if include - } - original = af({"a", "b"}, old_defeats) - expanded_defeats = set(old_defeats) - if old_old_add_ab: - expanded_defeats.add(("a", "b")) - if old_old_add_ba: - expanded_defeats.add(("b", "a")) - if old_to_new: - expanded_defeats.add(("a", "x")) - if new_to_old: - expanded_defeats.add(("x", "a")) - if new_to_new: - expanded_defeats.add(("x", "x")) - expanded = af({"a", "b", "x"}, expanded_defeats) - - added_defeats = expanded.defeats - original.defeats - expected = original.defeats <= expanded.defeats and all( - source == "x" or target == "x" for source, target in added_defeats - ) - - assert is_normal_expansion(original, expanded) is expected - - -def test_strong_and_weak_expansion_restrict_attack_direction_between_old_and_new() -> None: - original = af({"a"}, set()) - - old_attacks_new = af({"a", "x"}, {("a", "x")}) - new_attacks_old = af({"a", "x"}, {("x", "a")}) - new_attacks_new = af({"a", "x"}, {("x", "x")}) - - assert is_normal_expansion(original, old_attacks_new) - assert is_normal_expansion(original, new_attacks_old) - assert is_normal_expansion(original, new_attacks_new) - - assert not is_strong_expansion(original, old_attacks_new) - assert is_weak_expansion(original, old_attacks_new) - - assert is_strong_expansion(original, new_attacks_old) - assert not is_weak_expansion(original, new_attacks_old) - - assert is_strong_expansion(original, new_attacks_new) - assert is_weak_expansion(original, new_attacks_new) - - -def test_liberal_enforcement_uses_explicit_target_semantics() -> None: - framework = af( - {"a1", "a2", "a3", "a4", "a5"}, - {("a1", "a2"), ("a3", "a2"), ("a3", "a4"), ("a4", "a3"), ("a4", "a5"), ("a5", "a5")}, - ) - target = frozenset({"a1", "a3"}) - - assert target not in extensions_for(framework, "stable") - assert target in extensions_for(framework, "preferred") - - result = enforce_liberal_expansion_extension( - framework, - target, - source_semantics="stable", - target_semantics="preferred", - variant="strict", - candidate_new_arguments=frozenset(), - max_new_arguments=0, - max_added_defeats=0, - ) - - assert result.source_semantics == "stable" - assert result.semantics == "preferred" - assert result.cost == 0 - assert result.witness_framework == framework +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework, stable_extensions +from argumentation.dynamics.enforcement import ( + AFEdit, + apply_edit, + enforce_credulous, + enforce_extension, + enforce_expansion_credulous, + enforce_liberal_expansion_extension, + enforce_skeptical, + extensions_for, + is_normal_expansion, + is_strong_expansion, + is_weak_expansion, +) + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def test_credulous_enforcement_returns_minimal_hamming_change() -> None: + framework = af({"a", "b"}, {("a", "b")}) + + result = enforce_credulous(framework, "b", semantics="preferred") + + assert result.cost == 1 + assert "b" in result.accepted_arguments + assert any("b" in extension for extension in result.extensions) + assert result.witness_framework == apply_edit(framework, result.edit) + + +def test_skeptical_enforcement_makes_argument_accepted_in_every_extension() -> None: + framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) + + result = enforce_skeptical(framework, "a", semantics="stable") + + assert result.cost == 1 + assert result.accepted_arguments == frozenset({"a"}) + assert result.extensions == (frozenset({"a"}),) + + +def test_extension_enforcement_makes_target_a_stable_extension() -> None: + framework = af({"a", "b"}, {("a", "b")}) + + result = enforce_extension(framework, frozenset({"b"}), semantics="stable") + + assert result.cost == 1 + assert frozenset({"b"}) in stable_extensions(result.witness_framework) + + +def test_apply_edit_can_add_and_remove_arguments_and_defeats() -> None: + framework = af({"a", "b"}, {("a", "b")}) + edit = AFEdit( + add_arguments=frozenset({"c"}), + remove_arguments=frozenset({"a"}), + add_defeats=frozenset({("c", "b")}), + remove_defeats=frozenset({("a", "b")}), + ) + + assert apply_edit(framework, edit) == af({"b", "c"}, {("c", "b")}) + + +def test_fixed_argument_extension_enforcement_matches_wallner_example_strict() -> None: + framework = af( + {"a", "b", "c", "d"}, + {("b", "a"), ("b", "c"), ("c", "a"), ("c", "d"), ("d", "b")}, + ) + + result = enforce_extension( + framework, + frozenset({"a"}), + semantics="complete", + variant="strict", + max_cost=2, + ) + + assert result.cost == 2 + assert result.edit.remove_defeats == frozenset({("b", "a"), ("c", "a")}) + assert result.edit.add_defeats == frozenset() + assert frozenset({"a"}) in result.extensions + + +def test_fixed_argument_extension_enforcement_matches_wallner_example_non_strict() -> None: + framework = af( + {"a", "b", "c", "d"}, + {("b", "a"), ("b", "c"), ("c", "a"), ("c", "d"), ("d", "b")}, + ) + + result = enforce_extension( + framework, + frozenset({"a"}), + semantics="complete", + variant="non-strict", + max_cost=1, + ) + + assert result.cost == 1 + assert len(result.edit.add_defeats) == 1 + assert result.edit.remove_defeats == frozenset() + assert any(frozenset({"a"}) < extension for extension in result.extensions) + + +def test_expansion_credulous_enforcement_rejects_old_attack_deletion() -> None: + framework = af({"a", "b"}, {("a", "b")}) + + result = enforce_expansion_credulous( + framework, + "b", + semantics="preferred", + kind="normal", + candidate_new_arguments=frozenset({"x1"}), + max_new_arguments=1, + max_added_defeats=1, + ) + + assert result.cost == 2 + assert result.expansion.new_arguments == frozenset({"x1"}) + assert result.expansion.added_defeats == frozenset({("x1", "a")}) + assert framework.defeats <= result.witness_framework.defeats + assert result.witness_framework.defeats & {("b", "a")} == frozenset() + assert is_normal_expansion(framework, result.witness_framework) + assert any("b" in extension for extension in result.extensions) + + +@settings(max_examples=100) +@given( + old_defeat_ab=st.booleans(), + old_defeat_ba=st.booleans(), + old_old_add_ab=st.booleans(), + old_old_add_ba=st.booleans(), + old_to_new=st.booleans(), + new_to_old=st.booleans(), + new_to_new=st.booleans(), +) +def test_normal_expansion_iff_old_material_preserved_and_only_new_interactions_added( + old_defeat_ab: bool, + old_defeat_ba: bool, + old_old_add_ab: bool, + old_old_add_ba: bool, + old_to_new: bool, + new_to_old: bool, + new_to_new: bool, +) -> None: + old_defeats = { + defeat + for include, defeat in ( + (old_defeat_ab, ("a", "b")), + (old_defeat_ba, ("b", "a")), + ) + if include + } + original = af({"a", "b"}, old_defeats) + expanded_defeats = set(old_defeats) + if old_old_add_ab: + expanded_defeats.add(("a", "b")) + if old_old_add_ba: + expanded_defeats.add(("b", "a")) + if old_to_new: + expanded_defeats.add(("a", "x")) + if new_to_old: + expanded_defeats.add(("x", "a")) + if new_to_new: + expanded_defeats.add(("x", "x")) + expanded = af({"a", "b", "x"}, expanded_defeats) + + added_defeats = expanded.defeats - original.defeats + expected = original.defeats <= expanded.defeats and all( + source == "x" or target == "x" for source, target in added_defeats + ) + + assert is_normal_expansion(original, expanded) is expected + + +def test_strong_and_weak_expansion_restrict_attack_direction_between_old_and_new() -> None: + original = af({"a"}, set()) + + old_attacks_new = af({"a", "x"}, {("a", "x")}) + new_attacks_old = af({"a", "x"}, {("x", "a")}) + new_attacks_new = af({"a", "x"}, {("x", "x")}) + + assert is_normal_expansion(original, old_attacks_new) + assert is_normal_expansion(original, new_attacks_old) + assert is_normal_expansion(original, new_attacks_new) + + assert not is_strong_expansion(original, old_attacks_new) + assert is_weak_expansion(original, old_attacks_new) + + assert is_strong_expansion(original, new_attacks_old) + assert not is_weak_expansion(original, new_attacks_old) + + assert is_strong_expansion(original, new_attacks_new) + assert is_weak_expansion(original, new_attacks_new) + + +def test_liberal_enforcement_uses_explicit_target_semantics() -> None: + framework = af( + {"a1", "a2", "a3", "a4", "a5"}, + {("a1", "a2"), ("a3", "a2"), ("a3", "a4"), ("a4", "a3"), ("a4", "a5"), ("a5", "a5")}, + ) + target = frozenset({"a1", "a3"}) + + assert target not in extensions_for(framework, "stable") + assert target in extensions_for(framework, "preferred") + + result = enforce_liberal_expansion_extension( + framework, + target, + source_semantics="stable", + target_semantics="preferred", + variant="strict", + candidate_new_arguments=frozenset(), + max_new_arguments=0, + max_added_defeats=0, + ) + + assert result.source_semantics == "stable" + assert result.semantics == "preferred" + assert result.cost == 0 + assert result.witness_framework == framework diff --git a/tests/test_optimization.py b/tests/dynamics/test_optimization.py similarity index 94% rename from tests/test_optimization.py rename to tests/dynamics/test_optimization.py index 99c77fa..a6dc6d5 100644 --- a/tests/test_optimization.py +++ b/tests/dynamics/test_optimization.py @@ -1,193 +1,193 @@ -from __future__ import annotations - -from itertools import permutations - -import pytest -from hypothesis import given -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework, admissible, conflict_free -from argumentation.optimization import ( - OptimizationFeature, - OptimizationObjective, - OptimizationPolicy, - optimize_framework, -) - - -@st.composite -def small_af_with_candidates(draw: st.DrawFn) -> tuple[ArgumentationFramework, frozenset[str]]: - arguments = frozenset( - draw( - st.sets( - st.sampled_from(tuple(f"a{index}" for index in range(8))), - min_size=1, - max_size=8, - ) - ) - ) - possible_defeats = tuple((left, right) for left in arguments for right in arguments) - defeats = frozenset(draw(st.sets(st.sampled_from(possible_defeats), max_size=len(possible_defeats)))) - candidates = frozenset( - draw(st.sets(st.sampled_from(tuple(sorted(arguments))), min_size=1, max_size=len(arguments))) - ) - return ArgumentationFramework(arguments=arguments, defeats=defeats), candidates - - -@given(problem=small_af_with_candidates()) -def test_conflict_free_policy_selects_conflict_free_arguments( - problem: tuple[ArgumentationFramework, frozenset[str]], -) -> None: - """Dung 1995 p.326 Def. 5: no selected A,B may satisfy attacks(A,B).""" - framework, candidates = problem - result = optimize_framework( - framework, - OptimizationPolicy(semantics="conflict_free", candidates=candidates), - (), - ) - - assert result.status in {"optimal", "unsat"} - if result.status == "optimal": - assert result.selected_candidate in candidates - assert conflict_free(result.selected_arguments, framework.defeats) - - -@given(problem=small_af_with_candidates()) -def test_admissible_policy_selects_admissible_arguments( - problem: tuple[ArgumentationFramework, frozenset[str]], -) -> None: - """Dung 1995 p.326 Def. 6: admissible means conflict-free and defended.""" - framework, candidates = problem - result = optimize_framework( - framework, - OptimizationPolicy(semantics="admissible", candidates=candidates), - (), - ) - - assert result.status in {"optimal", "unsat"} - if result.status == "optimal": - assert result.selected_candidate in candidates - assert admissible(result.selected_arguments, framework.arguments, framework.defeats) - - -def test_admissible_policy_rejects_undefended_candidate() -> None: - """Dung 1995 p.326 Def. 6: a selected argument must be acceptable w.r.t. S.""" - framework = ArgumentationFramework( - arguments=frozenset({"candidate", "attacker"}), - defeats=frozenset({("attacker", "candidate")}), - ) - - result = optimize_framework( - framework, - OptimizationPolicy(semantics="admissible", candidates=frozenset({"candidate"})), - (), - ) - - assert result.status == "unsat" - assert result.selected_candidate is None - - -@given( - primary_left=st.integers(min_value=-20, max_value=20), - primary_right=st.integers(min_value=-20, max_value=20), - secondary_left=st.integers(min_value=-20, max_value=20), - secondary_right=st.integers(min_value=-20, max_value=20), -) -def test_lexicographic_objective_priority_dominates_lower_tier_score( - primary_left: int, - primary_right: int, - secondary_left: int, - secondary_right: int, -) -> None: - """Bjorner-Phan 2014 p.7 and Sebastiani-Trentin 2015 p.450: lex optimizes higher-priority objectives first.""" - framework = ArgumentationFramework( - arguments=frozenset({"left", "right"}), - defeats=frozenset(), - ) - result = optimize_framework( - framework, - OptimizationPolicy( - semantics="conflict_free", - candidates=frozenset({"left", "right"}), - objectives=( - OptimizationObjective("primary", direction="maximize", priority=0), - OptimizationObjective("secondary", direction="maximize", priority=1), - ), - ), - ( - OptimizationFeature("left", "primary", primary_left), - OptimizationFeature("right", "primary", primary_right), - OptimizationFeature("left", "secondary", secondary_left), - OptimizationFeature("right", "secondary", secondary_right), - ), - ) - - expected = max( - ("left", "right"), - key=lambda argument: ( - primary_left if argument == "left" else primary_right, - secondary_left if argument == "left" else secondary_right, - -("left", "right").index(argument), - ), - ) - assert result.selected_candidate == expected - - -@given(problem=small_af_with_candidates()) -def test_candidate_selection_is_exactly_one_declared_candidate( - problem: tuple[ArgumentationFramework, frozenset[str]], -) -> None: - """The workstream's OMT semantics requires exactly one declared decision candidate.""" - framework, candidates = problem - result = optimize_framework( - framework, - OptimizationPolicy(semantics="conflict_free", candidates=candidates), - (), - ) - - assert result.status in {"optimal", "unsat"} - if result.status == "optimal": - assert result.selected_candidate in candidates - assert sum(candidate == result.selected_candidate for candidate in candidates) == 1 - - -def test_tie_break_is_stable_under_input_order_permutations() -> None: - """Bjorner-Phan 2014 p.7: after equal objective values, our policy adds deterministic candidate ordering.""" - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset(), - ) - selected = set() - for ordered_candidates in permutations(("c", "b", "a")): - result = optimize_framework( - framework, - OptimizationPolicy( - semantics="conflict_free", - candidates=frozenset(ordered_candidates), - objectives=(OptimizationObjective("score", direction="maximize", priority=0),), - ), - tuple(OptimizationFeature(candidate, "score", 10) for candidate in ordered_candidates), - ) - selected.add(result.selected_candidate) - - assert selected == {"a"} - - -def test_unavailable_z3_is_explicit_status(monkeypatch: pytest.MonkeyPatch) -> None: - """Bjorner-Phan 2014 p.7 motivates a Z3-backed path; unavailable backends must not silently choose.""" - import argumentation.optimization as optimization - - monkeypatch.setattr(optimization, "_import_z3", lambda: None) - framework = ArgumentationFramework( - arguments=frozenset({"a"}), - defeats=frozenset(), - ) - - result = optimize_framework( - framework, - OptimizationPolicy(semantics="conflict_free", candidates=frozenset({"a"})), - (), - ) - - assert result.status == "unavailable" - assert result.selected_candidate is None +from __future__ import annotations + +from itertools import permutations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework, admissible, conflict_free +from argumentation.dynamics.optimization import ( + OptimizationFeature, + OptimizationObjective, + OptimizationPolicy, + optimize_framework, +) + + +@st.composite +def small_af_with_candidates(draw: st.DrawFn) -> tuple[ArgumentationFramework, frozenset[str]]: + arguments = frozenset( + draw( + st.sets( + st.sampled_from(tuple(f"a{index}" for index in range(8))), + min_size=1, + max_size=8, + ) + ) + ) + possible_defeats = tuple((left, right) for left in arguments for right in arguments) + defeats = frozenset(draw(st.sets(st.sampled_from(possible_defeats), max_size=len(possible_defeats)))) + candidates = frozenset( + draw(st.sets(st.sampled_from(tuple(sorted(arguments))), min_size=1, max_size=len(arguments))) + ) + return ArgumentationFramework(arguments=arguments, defeats=defeats), candidates + + +@given(problem=small_af_with_candidates()) +def test_conflict_free_policy_selects_conflict_free_arguments( + problem: tuple[ArgumentationFramework, frozenset[str]], +) -> None: + """Dung 1995 p.326 Def. 5: no selected A,B may satisfy attacks(A,B).""" + framework, candidates = problem + result = optimize_framework( + framework, + OptimizationPolicy(semantics="conflict_free", candidates=candidates), + (), + ) + + assert result.status in {"optimal", "unsat"} + if result.status == "optimal": + assert result.selected_candidate in candidates + assert conflict_free(result.selected_arguments, framework.defeats) + + +@given(problem=small_af_with_candidates()) +def test_admissible_policy_selects_admissible_arguments( + problem: tuple[ArgumentationFramework, frozenset[str]], +) -> None: + """Dung 1995 p.326 Def. 6: admissible means conflict-free and defended.""" + framework, candidates = problem + result = optimize_framework( + framework, + OptimizationPolicy(semantics="admissible", candidates=candidates), + (), + ) + + assert result.status in {"optimal", "unsat"} + if result.status == "optimal": + assert result.selected_candidate in candidates + assert admissible(result.selected_arguments, framework.arguments, framework.defeats) + + +def test_admissible_policy_rejects_undefended_candidate() -> None: + """Dung 1995 p.326 Def. 6: a selected argument must be acceptable w.r.t. S.""" + framework = ArgumentationFramework( + arguments=frozenset({"candidate", "attacker"}), + defeats=frozenset({("attacker", "candidate")}), + ) + + result = optimize_framework( + framework, + OptimizationPolicy(semantics="admissible", candidates=frozenset({"candidate"})), + (), + ) + + assert result.status == "unsat" + assert result.selected_candidate is None + + +@given( + primary_left=st.integers(min_value=-20, max_value=20), + primary_right=st.integers(min_value=-20, max_value=20), + secondary_left=st.integers(min_value=-20, max_value=20), + secondary_right=st.integers(min_value=-20, max_value=20), +) +def test_lexicographic_objective_priority_dominates_lower_tier_score( + primary_left: int, + primary_right: int, + secondary_left: int, + secondary_right: int, +) -> None: + """Bjorner-Phan 2014 p.7 and Sebastiani-Trentin 2015 p.450: lex optimizes higher-priority objectives first.""" + framework = ArgumentationFramework( + arguments=frozenset({"left", "right"}), + defeats=frozenset(), + ) + result = optimize_framework( + framework, + OptimizationPolicy( + semantics="conflict_free", + candidates=frozenset({"left", "right"}), + objectives=( + OptimizationObjective("primary", direction="maximize", priority=0), + OptimizationObjective("secondary", direction="maximize", priority=1), + ), + ), + ( + OptimizationFeature("left", "primary", primary_left), + OptimizationFeature("right", "primary", primary_right), + OptimizationFeature("left", "secondary", secondary_left), + OptimizationFeature("right", "secondary", secondary_right), + ), + ) + + expected = max( + ("left", "right"), + key=lambda argument: ( + primary_left if argument == "left" else primary_right, + secondary_left if argument == "left" else secondary_right, + -("left", "right").index(argument), + ), + ) + assert result.selected_candidate == expected + + +@given(problem=small_af_with_candidates()) +def test_candidate_selection_is_exactly_one_declared_candidate( + problem: tuple[ArgumentationFramework, frozenset[str]], +) -> None: + """The workstream's OMT semantics requires exactly one declared decision candidate.""" + framework, candidates = problem + result = optimize_framework( + framework, + OptimizationPolicy(semantics="conflict_free", candidates=candidates), + (), + ) + + assert result.status in {"optimal", "unsat"} + if result.status == "optimal": + assert result.selected_candidate in candidates + assert sum(candidate == result.selected_candidate for candidate in candidates) == 1 + + +def test_tie_break_is_stable_under_input_order_permutations() -> None: + """Bjorner-Phan 2014 p.7: after equal objective values, our policy adds deterministic candidate ordering.""" + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset(), + ) + selected = set() + for ordered_candidates in permutations(("c", "b", "a")): + result = optimize_framework( + framework, + OptimizationPolicy( + semantics="conflict_free", + candidates=frozenset(ordered_candidates), + objectives=(OptimizationObjective("score", direction="maximize", priority=0),), + ), + tuple(OptimizationFeature(candidate, "score", 10) for candidate in ordered_candidates), + ) + selected.add(result.selected_candidate) + + assert selected == {"a"} + + +def test_unavailable_z3_is_explicit_status(monkeypatch: pytest.MonkeyPatch) -> None: + """Bjorner-Phan 2014 p.7 motivates a Z3-backed path; unavailable backends must not silently choose.""" + import argumentation.dynamics.optimization as optimization + + monkeypatch.setattr(optimization, "_import_z3", lambda: None) + framework = ArgumentationFramework( + arguments=frozenset({"a"}), + defeats=frozenset(), + ) + + result = optimize_framework( + framework, + OptimizationPolicy(semantics="conflict_free", candidates=frozenset({"a"})), + (), + ) + + assert result.status == "unavailable" + assert result.selected_candidate is None diff --git a/tests/frameworks/__init__.py b/tests/frameworks/__init__.py new file mode 100644 index 0000000..97cf534 --- /dev/null +++ b/tests/frameworks/__init__.py @@ -0,0 +1 @@ +"""Tests for the frameworks layer.""" diff --git a/tests/test_adf_acceptance_condition_ast.py b/tests/frameworks/test_adf_acceptance_condition_ast.py similarity index 93% rename from tests/test_adf_acceptance_condition_ast.py rename to tests/frameworks/test_adf_acceptance_condition_ast.py index 61027f9..ddf0064 100644 --- a/tests/test_adf_acceptance_condition_ast.py +++ b/tests/frameworks/test_adf_acceptance_condition_ast.py @@ -1,8 +1,6 @@ from __future__ import annotations -import argumentation - -from argumentation.adf import ( +from argumentation.frameworks.adf import ( AbstractDialecticalFramework, And, Atom, @@ -20,10 +18,6 @@ ) -def test_adf_module_is_exported() -> None: - assert "adf" in argumentation.__all__ - - def test_acceptance_condition_ast_json_and_iccma_round_trip() -> None: condition = And((Atom("a"), Not(Atom("b")), Or((Atom("c"), False_())))) diff --git a/tests/test_adf_brewka_2010_examples.py b/tests/frameworks/test_adf_brewka_2010_examples.py similarity index 97% rename from tests/test_adf_brewka_2010_examples.py rename to tests/frameworks/test_adf_brewka_2010_examples.py index 52f818f..0da0216 100644 --- a/tests/test_adf_brewka_2010_examples.py +++ b/tests/frameworks/test_adf_brewka_2010_examples.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.adf import ( +from argumentation.frameworks.adf import ( AbstractDialecticalFramework, And, Atom, diff --git a/tests/test_adf_brewka_2013_operator.py b/tests/frameworks/test_adf_brewka_2013_operator.py similarity index 98% rename from tests/test_adf_brewka_2013_operator.py rename to tests/frameworks/test_adf_brewka_2013_operator.py index cfe8cc7..da05222 100644 --- a/tests/test_adf_brewka_2013_operator.py +++ b/tests/frameworks/test_adf_brewka_2013_operator.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.adf import ( +from argumentation.frameworks.adf import ( AbstractDialecticalFramework, Atom, Not, diff --git a/tests/test_adf_dung_bridge.py b/tests/frameworks/test_adf_dung_bridge.py similarity index 83% rename from tests/test_adf_dung_bridge.py rename to tests/frameworks/test_adf_dung_bridge.py index 69b6797..ac94270 100644 --- a/tests/test_adf_dung_bridge.py +++ b/tests/frameworks/test_adf_dung_bridge.py @@ -1,34 +1,34 @@ -from __future__ import annotations - -from argumentation.adf import ( - ThreeValued, - adf_to_dung, - dung_to_adf, - grounded_interpretation, - interpretation_from_mapping, -) -from argumentation.dung import ArgumentationFramework, grounded_extension, stable_extensions - - -def test_dung_to_adf_preserves_grounded_single_attack() -> None: - af = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - adf = dung_to_adf(af) - - assert grounded_interpretation(adf) == interpretation_from_mapping( - {"a": ThreeValued.T, "b": ThreeValued.F} - ) - assert adf_to_dung(adf) == af - - -def test_dung_to_adf_preserves_stable_extensions_for_mutual_attack() -> None: - af = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - assert stable_extensions(adf_to_dung(dung_to_adf(af))) == stable_extensions(af) - assert grounded_extension(adf_to_dung(dung_to_adf(af))) == grounded_extension(af) +from __future__ import annotations + +from argumentation.frameworks.adf import ( + ThreeValued, + adf_to_dung, + dung_to_adf, + grounded_interpretation, + interpretation_from_mapping, +) +from argumentation.core.dung import ArgumentationFramework, grounded_extension, stable_extensions + + +def test_dung_to_adf_preserves_grounded_single_attack() -> None: + af = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + adf = dung_to_adf(af) + + assert grounded_interpretation(adf) == interpretation_from_mapping( + {"a": ThreeValued.T, "b": ThreeValued.F} + ) + assert adf_to_dung(adf) == af + + +def test_dung_to_adf_preserves_stable_extensions_for_mutual_attack() -> None: + af = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + assert stable_extensions(adf_to_dung(dung_to_adf(af))) == stable_extensions(af) + assert grounded_extension(adf_to_dung(dung_to_adf(af))) == grounded_extension(af) diff --git a/tests/test_adf_iccma_io.py b/tests/frameworks/test_adf_iccma_io.py similarity index 86% rename from tests/test_adf_iccma_io.py rename to tests/frameworks/test_adf_iccma_io.py index 881f5ef..9473802 100644 --- a/tests/test_adf_iccma_io.py +++ b/tests/frameworks/test_adf_iccma_io.py @@ -2,14 +2,14 @@ import pytest -from argumentation.adf import ( +from argumentation.frameworks.adf import ( AbstractDialecticalFramework, And, Atom, Not, True_, ) -from argumentation.iccma import parse_adf, write_adf +from argumentation.interop.iccma import parse_adf, write_adf def test_adf_iccma_round_trip_preserves_ast_shape() -> None: diff --git a/tests/test_caf.py b/tests/frameworks/test_caf.py similarity index 96% rename from tests/test_caf.py rename to tests/frameworks/test_caf.py index a1e871b..4deca87 100644 --- a/tests/test_caf.py +++ b/tests/frameworks/test_caf.py @@ -1,468 +1,468 @@ -from __future__ import annotations - -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.caf import ( - ClaimAugmentedAF, - claim_range, - claim_level_extensions, - concurrence_holds, - defeated_claims, - extensions, - inherited_extensions, - is_i_maximal, - is_well_formed, -) -from argumentation.dung import ( - ArgumentationFramework, - naive_extensions, - preferred_extensions, - semi_stable_extensions, - stable_extensions, - stage_extensions, -) - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def example_1_caf() -> ClaimAugmentedAF: - return ClaimAugmentedAF( - framework=af( - {"x1", "y1", "z", "x2", "y2"}, - { - ("y1", "x1"), - ("y1", "z"), - ("z", "y1"), - ("z", "x2"), - ("x2", "y2"), - }, - ), - claims={"x1": "x", "x2": "x", "y1": "y", "y2": "y", "z": "z"}, - ) - - -def example_2_caf() -> ClaimAugmentedAF: - return ClaimAugmentedAF( - framework=af( - {"x1", "y1", "x2", "z1", "x3"}, - { - ("y1", "x1"), - ("y1", "x2"), - ("z1", "x2"), - ("z1", "x3"), - }, - ), - claims={"x1": "x", "x2": "x", "x3": "x", "y1": "y", "z1": "z"}, - ) - - -def example_3_caf() -> ClaimAugmentedAF: - return ClaimAugmentedAF( - framework=af( - {"a", "b1", "b2", "c", "d", "e", "f1", "f2"}, - { - ("e", "e"), - ("e", "b2"), - ("c", "e"), - ("c", "b2"), - ("b2", "c"), - ("a", "c"), - ("b1", "c"), - ("a", "b1"), - ("a", "d"), - ("d", "a"), - ("a", "f1"), - ("f1", "f1"), - ("f1", "f2"), - ("f2", "f1"), - ("f2", "f2"), - }, - ), - claims={ - "a": "a", - "b1": "b", - "b2": "b", - "c": "c", - "d": "d", - "e": "e", - "f1": "f", - "f2": "f", - }, - ) - - -def example_4_caf() -> ClaimAugmentedAF: - return ClaimAugmentedAF( - framework=af( - {"a", "b1", "b2", "c", "d"}, - { - ("a", "a"), - ("b1", "a"), - ("b1", "b2"), - ("b2", "b1"), - ("b1", "c"), - ("c", "b1"), - ("c", "d"), - ("d", "d"), - }, - ), - claims={"a": "a", "b1": "b", "b2": "b", "c": "c", "d": "d"}, - ) - - -def example_5_caf() -> ClaimAugmentedAF: - return ClaimAugmentedAF( - framework=af( - {"a", "b", "c1", "c2"}, - { - ("a", "b"), - ("b", "a"), - ("b", "c1"), - ("c1", "c1"), - ("c1", "c2"), - ("c2", "c1"), - ("c2", "c2"), - }, - ), - claims={"a": "a", "b": "b", "c1": "c", "c2": "c"}, - ) - - -def example_6_caf() -> ClaimAugmentedAF: - base = example_5_caf() - return ClaimAugmentedAF( - framework=af( - set(base.framework.arguments) | {"d1", "d2"}, - set(base.framework.defeats) | {("d1", "d2"), ("d2", "d2"), ("b", "d1")}, - ), - claims={**base.claims, "d1": "d", "d2": "d"}, - ) - - -def claim_sets(values: set[frozenset[str]]) -> set[frozenset[str]]: - return values - - -def test_kr2020_example_1_cl_preferred_strengthens_inherited_preferred() -> None: - caf = example_1_caf() - - assert set(inherited_extensions(caf, semantics="preferred")) == claim_sets( - { - frozenset({"x", "y"}), - frozenset({"x", "y", "z"}), - } - ) - assert set(claim_level_extensions(caf, semantics="preferred")) == { - frozenset({"x", "y", "z"}) - } - - -def test_kr2020_example_2_cl_naive_selects_i_maximal_claim_sets() -> None: - caf = example_2_caf() - - assert set(inherited_extensions(caf, semantics="naive")) == claim_sets( - { - frozenset({"x"}), - frozenset({"x", "y"}), - frozenset({"x", "z"}), - frozenset({"y", "z"}), - } - ) - assert set(claim_level_extensions(caf, semantics="naive")) == { - frozenset({"x", "y"}), - frozenset({"x", "z"}), - frozenset({"y", "z"}), - } - assert is_i_maximal(claim_level_extensions(caf, semantics="naive")) is True - - -def test_kr2020_figure_3_cl_stable_relaxes_stable_realization() -> None: - caf = ClaimAugmentedAF( - framework=af({"a1", "a2", "b"}, {("a2", "a2"), ("a2", "a1"), ("a1", "b")}), - claims={"a1": "a", "a2": "a", "b": "b"}, - ) - - assert inherited_extensions(caf, semantics="stable") == () - assert claim_level_extensions(caf, semantics="stable") == (frozenset({"a"}),) - assert claim_level_extensions(caf, semantics="stable-admissible") == () - - -def test_kr2020_example_3_cl_semi_stable_and_i_semi_stable_are_incomparable() -> None: - caf = example_3_caf() - - assert is_well_formed(caf) is True - assert set(claim_level_extensions(caf, semantics="semi-stable")) == { - frozenset({"b", "d"}) - } - assert set(inherited_extensions(caf, semantics="semi-stable")) == { - frozenset({"a"}) - } - - -def test_kr2020_example_4_cl_semi_stable_is_not_i_maximal_for_general_cafs() -> None: - caf = example_4_caf() - - assert is_well_formed(caf) is False - assert inherited_extensions(caf, semantics="stable") == () - assert claim_level_extensions(caf, semantics="stable") == () - assert claim_level_extensions(caf, semantics="stable-admissible") == () - assert set(claim_level_extensions(caf, semantics="semi-stable")) == { - frozenset({"b"}), - frozenset({"b", "c"}), - } - - -def test_kr2020_example_5_cl_stage_and_i_stage_are_incomparable() -> None: - caf = example_5_caf() - - assert set(inherited_extensions(caf, semantics="stage")) == { - frozenset({"b"}) - } - assert set(claim_level_extensions(caf, semantics="stage")) == { - frozenset({"a"}), - frozenset({"b"}), - } - - -def test_kr2020_example_6_stage_claim_level_counterexample() -> None: - caf = example_6_caf() - - assert set(inherited_extensions(caf, semantics="stage")) == { - frozenset({"a", "d"}), - frozenset({"b"}), - } - assert set(claim_level_extensions(caf, semantics="stage")) == { - frozenset({"a", "d"}) - } - - -def test_duplicate_argument_claims_collapse_inherited_extensions() -> None: - caf = ClaimAugmentedAF( - framework=af({"a1", "a2"}, set()), - claims={"a1": "A", "a2": "A"}, - ) - - assert inherited_extensions(caf, semantics="preferred") == (frozenset({"A"}),) - - -def test_claim_level_extensions_maximize_claim_sets() -> None: - caf = ClaimAugmentedAF( - framework=af({"a1", "a2", "b"}, {("a1", "b"), ("b", "a1")}), - claims={"a1": "A", "a2": "A", "b": "B"}, - ) - - assert claim_level_extensions(caf, semantics="naive") == ( - frozenset({"A", "B"}), - ) - - -def test_bijective_claims_have_inherited_claim_level_concurrence() -> None: - caf = ClaimAugmentedAF( - framework=af({"a", "b"}, {("a", "b"), ("b", "a")}), - claims={"a": "A", "b": "B"}, - ) - - assert concurrence_holds(caf, semantics="stable") is True - assert extensions(caf, semantics="stable", view="inherited") == inherited_extensions( - caf, - semantics="stable", - ) - assert extensions(caf, semantics="stable", view="claim_level") == claim_level_extensions( - caf, - semantics="stable", - ) - - -def test_claim_level_stage_discards_range_dominated_claim_sets() -> None: - caf = ClaimAugmentedAF( - framework=af({"a", "b", "c1", "c2"}, {("b", "a"), ("b", "c1"), ("c1", "c1"), ("c2", "c2")}), - claims={"a": "A", "b": "B", "c1": "C", "c2": "C"}, - ) - - assert claim_level_extensions(caf, semantics="stage") == (frozenset({"B"}),) - - -def test_kr2020_definition_3_well_formed_requires_same_outgoing_attacks() -> None: - well_formed = ClaimAugmentedAF( - framework=af({"a1", "a2", "b"}, {("a1", "b"), ("a2", "b")}), - claims={"a1": "A", "a2": "A", "b": "B"}, - ) - not_well_formed = ClaimAugmentedAF( - framework=af({"a1", "a2", "b"}, {("a1", "b")}), - claims={"a1": "A", "a2": "A", "b": "B"}, - ) - - assert is_well_formed(well_formed) is True - assert is_well_formed(not_well_formed) is False - - -def test_aij2023_definition_6_defeated_claims_require_attacking_all_occurrences() -> None: - caf = ClaimAugmentedAF( - framework=af({"a1", "a2", "b"}, {("b", "a1")}), - claims={"a1": "A", "a2": "A", "b": "B"}, - ) - - assert defeated_claims(caf, frozenset({"b"})) == frozenset() - assert claim_range(caf, frozenset({"b"})) == frozenset({"B"}) - - -@st.composite -def cafs(draw: st.DrawFn, *, max_arguments: int = 4, max_claims: int = 3) -> ClaimAugmentedAF: - argument_count = draw(st.integers(min_value=0, max_value=max_arguments)) - arguments = [f"a{index}" for index in range(argument_count)] - possible_defeats = [(source, target) for source in arguments for target in arguments] - defeats = draw(st.sets(st.sampled_from(possible_defeats), max_size=len(possible_defeats))) if possible_defeats else set() - claim_bound = max(1, min(max_claims, max(1, argument_count))) - claim_numbers = draw( - st.lists( - st.integers(min_value=0, max_value=claim_bound - 1), - min_size=argument_count, - max_size=argument_count, - ) - ) - return ClaimAugmentedAF( - framework=ArgumentationFramework(arguments=frozenset(arguments), defeats=frozenset(defeats)), - claims={argument: f"c{claim}" for argument, claim in zip(arguments, claim_numbers, strict=True)}, - ) - - -@st.composite -def well_formed_cafs(draw: st.DrawFn, *, max_arguments: int = 4, max_claims: int = 3) -> ClaimAugmentedAF: - argument_count = draw(st.integers(min_value=0, max_value=max_arguments)) - arguments = [f"a{index}" for index in range(argument_count)] - claim_bound = max(1, min(max_claims, max(1, argument_count))) - claim_numbers = draw( - st.lists( - st.integers(min_value=0, max_value=claim_bound - 1), - min_size=argument_count, - max_size=argument_count, - ) - ) - claims = {argument: f"c{claim}" for argument, claim in zip(arguments, claim_numbers, strict=True)} - claim_ids = sorted(set(claims.values())) - possible_claim_attacks = [(source, target) for source in claim_ids for target in claim_ids] - claim_attacks = draw(st.sets(st.sampled_from(possible_claim_attacks), max_size=len(possible_claim_attacks))) if possible_claim_attacks else set() - defeats = { - (source, target) - for source in arguments - for target in arguments - if (claims[source], claims[target]) in claim_attacks - } - return ClaimAugmentedAF( - framework=ArgumentationFramework(arguments=frozenset(arguments), defeats=frozenset(defeats)), - claims=claims, - ) - - -@st.composite -def unique_claim_cafs(draw: st.DrawFn, *, max_arguments: int = 4) -> ClaimAugmentedAF: - caf = draw(cafs(max_arguments=max_arguments, max_claims=max_arguments)) - return ClaimAugmentedAF( - framework=caf.framework, - claims={argument: argument for argument in caf.framework.arguments}, - ) - - -def project(caf: ClaimAugmentedAF, extensions_: tuple[frozenset[str], ...] | list[frozenset[str]]) -> set[frozenset[str]]: - return {frozenset(caf.claims[argument] for argument in extension) for extension in extensions_} - - -@given(cafs()) -@settings(max_examples=60) -def test_definition_4_inherited_extensions_are_projected_dung_extensions(caf: ClaimAugmentedAF) -> None: - assert set(inherited_extensions(caf, semantics="preferred")) == project( - caf, - preferred_extensions(caf.framework), - ) - - -@given(well_formed_cafs()) -@settings(max_examples=60) -def test_lemma_1_well_formed_same_claim_sets_have_same_defeated_claims(caf: ClaimAugmentedAF) -> None: - subsets = [ - frozenset(argument for index, argument in enumerate(sorted(caf.framework.arguments)) if mask & (1 << index)) - for mask in range(1 << len(caf.framework.arguments)) - ] - for left in subsets: - for right in subsets: - if project(caf, [left]) == project(caf, [right]): - assert defeated_claims(caf, left) == defeated_claims(caf, right) - - -@given(cafs()) -@settings(max_examples=60) -def test_proposition_1_cl_preferred_is_subset_of_i_preferred(caf: ClaimAugmentedAF) -> None: - assert set(claim_level_extensions(caf, semantics="preferred")) <= set( - inherited_extensions(caf, semantics="preferred") - ) - - -@given(cafs()) -@settings(max_examples=60) -def test_proposition_2_cl_preferred_is_i_maximal(caf: ClaimAugmentedAF) -> None: - assert is_i_maximal(claim_level_extensions(caf, semantics="preferred")) is True - - -@given(well_formed_cafs()) -@settings(max_examples=60) -def test_proposition_3_well_formed_preferred_concurrence(caf: ClaimAugmentedAF) -> None: - assert set(claim_level_extensions(caf, semantics="preferred")) == set( - inherited_extensions(caf, semantics="preferred") - ) - - -@given(cafs()) -@settings(max_examples=60) -def test_proposition_5_cl_naive_is_subset_of_i_naive(caf: ClaimAugmentedAF) -> None: - assert set(claim_level_extensions(caf, semantics="naive")) <= set( - inherited_extensions(caf, semantics="naive") - ) - - -@given(cafs()) -@settings(max_examples=60) -def test_proposition_6_cl_naive_is_i_maximal(caf: ClaimAugmentedAF) -> None: - assert is_i_maximal(claim_level_extensions(caf, semantics="naive")) is True - - -@given(well_formed_cafs()) -@settings(max_examples=60) -def test_proposition_8_well_formed_stable_variants_coincide(caf: ClaimAugmentedAF) -> None: - assert set(inherited_extensions(caf, semantics="stable")) == set( - claim_level_extensions(caf, semantics="stable") - ) - assert set(claim_level_extensions(caf, semantics="stable")) == set( - claim_level_extensions(caf, semantics="stable-admissible") - ) - - -@given(well_formed_cafs()) -@settings(max_examples=60) -def test_proposition_10_well_formed_semi_stable_outputs_are_i_maximal(caf: ClaimAugmentedAF) -> None: - assert is_i_maximal(inherited_extensions(caf, semantics="semi-stable")) is True - assert is_i_maximal(claim_level_extensions(caf, semantics="semi-stable")) is True - - -@given(well_formed_cafs()) -@settings(max_examples=60) -def test_proposition_11_well_formed_stage_outputs_are_i_maximal(caf: ClaimAugmentedAF) -> None: - assert is_i_maximal(inherited_extensions(caf, semantics="stage")) is True - assert is_i_maximal(claim_level_extensions(caf, semantics="stage")) is True - - -@given(unique_claim_cafs()) -@settings(max_examples=60) -def test_lemma_3_unique_claims_coincide_with_dung_semantics(caf: ClaimAugmentedAF) -> None: - assert set(inherited_extensions(caf, semantics="preferred")) == project(caf, preferred_extensions(caf.framework)) - assert set(claim_level_extensions(caf, semantics="preferred")) == project(caf, preferred_extensions(caf.framework)) - assert set(inherited_extensions(caf, semantics="naive")) == project(caf, naive_extensions(caf.framework)) - assert set(claim_level_extensions(caf, semantics="naive")) == project(caf, naive_extensions(caf.framework)) - assert set(inherited_extensions(caf, semantics="stable")) == project(caf, stable_extensions(caf.framework)) - assert set(claim_level_extensions(caf, semantics="stable")) == project(caf, stable_extensions(caf.framework)) - assert set(inherited_extensions(caf, semantics="semi-stable")) == project(caf, semi_stable_extensions(caf.framework)) - assert set(claim_level_extensions(caf, semantics="semi-stable")) == project(caf, semi_stable_extensions(caf.framework)) - assert set(inherited_extensions(caf, semantics="stage")) == project(caf, stage_extensions(caf.framework)) - assert set(claim_level_extensions(caf, semantics="stage")) == project(caf, stage_extensions(caf.framework)) +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.frameworks.caf import ( + ClaimAugmentedAF, + claim_range, + claim_level_extensions, + concurrence_holds, + defeated_claims, + extensions, + inherited_extensions, + is_i_maximal, + is_well_formed, +) +from argumentation.core.dung import ( + ArgumentationFramework, + naive_extensions, + preferred_extensions, + semi_stable_extensions, + stable_extensions, + stage_extensions, +) + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def example_1_caf() -> ClaimAugmentedAF: + return ClaimAugmentedAF( + framework=af( + {"x1", "y1", "z", "x2", "y2"}, + { + ("y1", "x1"), + ("y1", "z"), + ("z", "y1"), + ("z", "x2"), + ("x2", "y2"), + }, + ), + claims={"x1": "x", "x2": "x", "y1": "y", "y2": "y", "z": "z"}, + ) + + +def example_2_caf() -> ClaimAugmentedAF: + return ClaimAugmentedAF( + framework=af( + {"x1", "y1", "x2", "z1", "x3"}, + { + ("y1", "x1"), + ("y1", "x2"), + ("z1", "x2"), + ("z1", "x3"), + }, + ), + claims={"x1": "x", "x2": "x", "x3": "x", "y1": "y", "z1": "z"}, + ) + + +def example_3_caf() -> ClaimAugmentedAF: + return ClaimAugmentedAF( + framework=af( + {"a", "b1", "b2", "c", "d", "e", "f1", "f2"}, + { + ("e", "e"), + ("e", "b2"), + ("c", "e"), + ("c", "b2"), + ("b2", "c"), + ("a", "c"), + ("b1", "c"), + ("a", "b1"), + ("a", "d"), + ("d", "a"), + ("a", "f1"), + ("f1", "f1"), + ("f1", "f2"), + ("f2", "f1"), + ("f2", "f2"), + }, + ), + claims={ + "a": "a", + "b1": "b", + "b2": "b", + "c": "c", + "d": "d", + "e": "e", + "f1": "f", + "f2": "f", + }, + ) + + +def example_4_caf() -> ClaimAugmentedAF: + return ClaimAugmentedAF( + framework=af( + {"a", "b1", "b2", "c", "d"}, + { + ("a", "a"), + ("b1", "a"), + ("b1", "b2"), + ("b2", "b1"), + ("b1", "c"), + ("c", "b1"), + ("c", "d"), + ("d", "d"), + }, + ), + claims={"a": "a", "b1": "b", "b2": "b", "c": "c", "d": "d"}, + ) + + +def example_5_caf() -> ClaimAugmentedAF: + return ClaimAugmentedAF( + framework=af( + {"a", "b", "c1", "c2"}, + { + ("a", "b"), + ("b", "a"), + ("b", "c1"), + ("c1", "c1"), + ("c1", "c2"), + ("c2", "c1"), + ("c2", "c2"), + }, + ), + claims={"a": "a", "b": "b", "c1": "c", "c2": "c"}, + ) + + +def example_6_caf() -> ClaimAugmentedAF: + base = example_5_caf() + return ClaimAugmentedAF( + framework=af( + set(base.framework.arguments) | {"d1", "d2"}, + set(base.framework.defeats) | {("d1", "d2"), ("d2", "d2"), ("b", "d1")}, + ), + claims={**base.claims, "d1": "d", "d2": "d"}, + ) + + +def claim_sets(values: set[frozenset[str]]) -> set[frozenset[str]]: + return values + + +def test_kr2020_example_1_cl_preferred_strengthens_inherited_preferred() -> None: + caf = example_1_caf() + + assert set(inherited_extensions(caf, semantics="preferred")) == claim_sets( + { + frozenset({"x", "y"}), + frozenset({"x", "y", "z"}), + } + ) + assert set(claim_level_extensions(caf, semantics="preferred")) == { + frozenset({"x", "y", "z"}) + } + + +def test_kr2020_example_2_cl_naive_selects_i_maximal_claim_sets() -> None: + caf = example_2_caf() + + assert set(inherited_extensions(caf, semantics="naive")) == claim_sets( + { + frozenset({"x"}), + frozenset({"x", "y"}), + frozenset({"x", "z"}), + frozenset({"y", "z"}), + } + ) + assert set(claim_level_extensions(caf, semantics="naive")) == { + frozenset({"x", "y"}), + frozenset({"x", "z"}), + frozenset({"y", "z"}), + } + assert is_i_maximal(claim_level_extensions(caf, semantics="naive")) is True + + +def test_kr2020_figure_3_cl_stable_relaxes_stable_realization() -> None: + caf = ClaimAugmentedAF( + framework=af({"a1", "a2", "b"}, {("a2", "a2"), ("a2", "a1"), ("a1", "b")}), + claims={"a1": "a", "a2": "a", "b": "b"}, + ) + + assert inherited_extensions(caf, semantics="stable") == () + assert claim_level_extensions(caf, semantics="stable") == (frozenset({"a"}),) + assert claim_level_extensions(caf, semantics="stable-admissible") == () + + +def test_kr2020_example_3_cl_semi_stable_and_i_semi_stable_are_incomparable() -> None: + caf = example_3_caf() + + assert is_well_formed(caf) is True + assert set(claim_level_extensions(caf, semantics="semi-stable")) == { + frozenset({"b", "d"}) + } + assert set(inherited_extensions(caf, semantics="semi-stable")) == { + frozenset({"a"}) + } + + +def test_kr2020_example_4_cl_semi_stable_is_not_i_maximal_for_general_cafs() -> None: + caf = example_4_caf() + + assert is_well_formed(caf) is False + assert inherited_extensions(caf, semantics="stable") == () + assert claim_level_extensions(caf, semantics="stable") == () + assert claim_level_extensions(caf, semantics="stable-admissible") == () + assert set(claim_level_extensions(caf, semantics="semi-stable")) == { + frozenset({"b"}), + frozenset({"b", "c"}), + } + + +def test_kr2020_example_5_cl_stage_and_i_stage_are_incomparable() -> None: + caf = example_5_caf() + + assert set(inherited_extensions(caf, semantics="stage")) == { + frozenset({"b"}) + } + assert set(claim_level_extensions(caf, semantics="stage")) == { + frozenset({"a"}), + frozenset({"b"}), + } + + +def test_kr2020_example_6_stage_claim_level_counterexample() -> None: + caf = example_6_caf() + + assert set(inherited_extensions(caf, semantics="stage")) == { + frozenset({"a", "d"}), + frozenset({"b"}), + } + assert set(claim_level_extensions(caf, semantics="stage")) == { + frozenset({"a", "d"}) + } + + +def test_duplicate_argument_claims_collapse_inherited_extensions() -> None: + caf = ClaimAugmentedAF( + framework=af({"a1", "a2"}, set()), + claims={"a1": "A", "a2": "A"}, + ) + + assert inherited_extensions(caf, semantics="preferred") == (frozenset({"A"}),) + + +def test_claim_level_extensions_maximize_claim_sets() -> None: + caf = ClaimAugmentedAF( + framework=af({"a1", "a2", "b"}, {("a1", "b"), ("b", "a1")}), + claims={"a1": "A", "a2": "A", "b": "B"}, + ) + + assert claim_level_extensions(caf, semantics="naive") == ( + frozenset({"A", "B"}), + ) + + +def test_bijective_claims_have_inherited_claim_level_concurrence() -> None: + caf = ClaimAugmentedAF( + framework=af({"a", "b"}, {("a", "b"), ("b", "a")}), + claims={"a": "A", "b": "B"}, + ) + + assert concurrence_holds(caf, semantics="stable") is True + assert extensions(caf, semantics="stable", view="inherited") == inherited_extensions( + caf, + semantics="stable", + ) + assert extensions(caf, semantics="stable", view="claim_level") == claim_level_extensions( + caf, + semantics="stable", + ) + + +def test_claim_level_stage_discards_range_dominated_claim_sets() -> None: + caf = ClaimAugmentedAF( + framework=af({"a", "b", "c1", "c2"}, {("b", "a"), ("b", "c1"), ("c1", "c1"), ("c2", "c2")}), + claims={"a": "A", "b": "B", "c1": "C", "c2": "C"}, + ) + + assert claim_level_extensions(caf, semantics="stage") == (frozenset({"B"}),) + + +def test_kr2020_definition_3_well_formed_requires_same_outgoing_attacks() -> None: + well_formed = ClaimAugmentedAF( + framework=af({"a1", "a2", "b"}, {("a1", "b"), ("a2", "b")}), + claims={"a1": "A", "a2": "A", "b": "B"}, + ) + not_well_formed = ClaimAugmentedAF( + framework=af({"a1", "a2", "b"}, {("a1", "b")}), + claims={"a1": "A", "a2": "A", "b": "B"}, + ) + + assert is_well_formed(well_formed) is True + assert is_well_formed(not_well_formed) is False + + +def test_aij2023_definition_6_defeated_claims_require_attacking_all_occurrences() -> None: + caf = ClaimAugmentedAF( + framework=af({"a1", "a2", "b"}, {("b", "a1")}), + claims={"a1": "A", "a2": "A", "b": "B"}, + ) + + assert defeated_claims(caf, frozenset({"b"})) == frozenset() + assert claim_range(caf, frozenset({"b"})) == frozenset({"B"}) + + +@st.composite +def cafs(draw: st.DrawFn, *, max_arguments: int = 4, max_claims: int = 3) -> ClaimAugmentedAF: + argument_count = draw(st.integers(min_value=0, max_value=max_arguments)) + arguments = [f"a{index}" for index in range(argument_count)] + possible_defeats = [(source, target) for source in arguments for target in arguments] + defeats = draw(st.sets(st.sampled_from(possible_defeats), max_size=len(possible_defeats))) if possible_defeats else set() + claim_bound = max(1, min(max_claims, max(1, argument_count))) + claim_numbers = draw( + st.lists( + st.integers(min_value=0, max_value=claim_bound - 1), + min_size=argument_count, + max_size=argument_count, + ) + ) + return ClaimAugmentedAF( + framework=ArgumentationFramework(arguments=frozenset(arguments), defeats=frozenset(defeats)), + claims={argument: f"c{claim}" for argument, claim in zip(arguments, claim_numbers, strict=True)}, + ) + + +@st.composite +def well_formed_cafs(draw: st.DrawFn, *, max_arguments: int = 4, max_claims: int = 3) -> ClaimAugmentedAF: + argument_count = draw(st.integers(min_value=0, max_value=max_arguments)) + arguments = [f"a{index}" for index in range(argument_count)] + claim_bound = max(1, min(max_claims, max(1, argument_count))) + claim_numbers = draw( + st.lists( + st.integers(min_value=0, max_value=claim_bound - 1), + min_size=argument_count, + max_size=argument_count, + ) + ) + claims = {argument: f"c{claim}" for argument, claim in zip(arguments, claim_numbers, strict=True)} + claim_ids = sorted(set(claims.values())) + possible_claim_attacks = [(source, target) for source in claim_ids for target in claim_ids] + claim_attacks = draw(st.sets(st.sampled_from(possible_claim_attacks), max_size=len(possible_claim_attacks))) if possible_claim_attacks else set() + defeats = { + (source, target) + for source in arguments + for target in arguments + if (claims[source], claims[target]) in claim_attacks + } + return ClaimAugmentedAF( + framework=ArgumentationFramework(arguments=frozenset(arguments), defeats=frozenset(defeats)), + claims=claims, + ) + + +@st.composite +def unique_claim_cafs(draw: st.DrawFn, *, max_arguments: int = 4) -> ClaimAugmentedAF: + caf = draw(cafs(max_arguments=max_arguments, max_claims=max_arguments)) + return ClaimAugmentedAF( + framework=caf.framework, + claims={argument: argument for argument in caf.framework.arguments}, + ) + + +def project(caf: ClaimAugmentedAF, extensions_: tuple[frozenset[str], ...] | list[frozenset[str]]) -> set[frozenset[str]]: + return {frozenset(caf.claims[argument] for argument in extension) for extension in extensions_} + + +@given(cafs()) +@settings(max_examples=60) +def test_definition_4_inherited_extensions_are_projected_dung_extensions(caf: ClaimAugmentedAF) -> None: + assert set(inherited_extensions(caf, semantics="preferred")) == project( + caf, + preferred_extensions(caf.framework), + ) + + +@given(well_formed_cafs()) +@settings(max_examples=60) +def test_lemma_1_well_formed_same_claim_sets_have_same_defeated_claims(caf: ClaimAugmentedAF) -> None: + subsets = [ + frozenset(argument for index, argument in enumerate(sorted(caf.framework.arguments)) if mask & (1 << index)) + for mask in range(1 << len(caf.framework.arguments)) + ] + for left in subsets: + for right in subsets: + if project(caf, [left]) == project(caf, [right]): + assert defeated_claims(caf, left) == defeated_claims(caf, right) + + +@given(cafs()) +@settings(max_examples=60) +def test_proposition_1_cl_preferred_is_subset_of_i_preferred(caf: ClaimAugmentedAF) -> None: + assert set(claim_level_extensions(caf, semantics="preferred")) <= set( + inherited_extensions(caf, semantics="preferred") + ) + + +@given(cafs()) +@settings(max_examples=60) +def test_proposition_2_cl_preferred_is_i_maximal(caf: ClaimAugmentedAF) -> None: + assert is_i_maximal(claim_level_extensions(caf, semantics="preferred")) is True + + +@given(well_formed_cafs()) +@settings(max_examples=60) +def test_proposition_3_well_formed_preferred_concurrence(caf: ClaimAugmentedAF) -> None: + assert set(claim_level_extensions(caf, semantics="preferred")) == set( + inherited_extensions(caf, semantics="preferred") + ) + + +@given(cafs()) +@settings(max_examples=60) +def test_proposition_5_cl_naive_is_subset_of_i_naive(caf: ClaimAugmentedAF) -> None: + assert set(claim_level_extensions(caf, semantics="naive")) <= set( + inherited_extensions(caf, semantics="naive") + ) + + +@given(cafs()) +@settings(max_examples=60) +def test_proposition_6_cl_naive_is_i_maximal(caf: ClaimAugmentedAF) -> None: + assert is_i_maximal(claim_level_extensions(caf, semantics="naive")) is True + + +@given(well_formed_cafs()) +@settings(max_examples=60) +def test_proposition_8_well_formed_stable_variants_coincide(caf: ClaimAugmentedAF) -> None: + assert set(inherited_extensions(caf, semantics="stable")) == set( + claim_level_extensions(caf, semantics="stable") + ) + assert set(claim_level_extensions(caf, semantics="stable")) == set( + claim_level_extensions(caf, semantics="stable-admissible") + ) + + +@given(well_formed_cafs()) +@settings(max_examples=60) +def test_proposition_10_well_formed_semi_stable_outputs_are_i_maximal(caf: ClaimAugmentedAF) -> None: + assert is_i_maximal(inherited_extensions(caf, semantics="semi-stable")) is True + assert is_i_maximal(claim_level_extensions(caf, semantics="semi-stable")) is True + + +@given(well_formed_cafs()) +@settings(max_examples=60) +def test_proposition_11_well_formed_stage_outputs_are_i_maximal(caf: ClaimAugmentedAF) -> None: + assert is_i_maximal(inherited_extensions(caf, semantics="stage")) is True + assert is_i_maximal(claim_level_extensions(caf, semantics="stage")) is True + + +@given(unique_claim_cafs()) +@settings(max_examples=60) +def test_lemma_3_unique_claims_coincide_with_dung_semantics(caf: ClaimAugmentedAF) -> None: + assert set(inherited_extensions(caf, semantics="preferred")) == project(caf, preferred_extensions(caf.framework)) + assert set(claim_level_extensions(caf, semantics="preferred")) == project(caf, preferred_extensions(caf.framework)) + assert set(inherited_extensions(caf, semantics="naive")) == project(caf, naive_extensions(caf.framework)) + assert set(claim_level_extensions(caf, semantics="naive")) == project(caf, naive_extensions(caf.framework)) + assert set(inherited_extensions(caf, semantics="stable")) == project(caf, stable_extensions(caf.framework)) + assert set(claim_level_extensions(caf, semantics="stable")) == project(caf, stable_extensions(caf.framework)) + assert set(inherited_extensions(caf, semantics="semi-stable")) == project(caf, semi_stable_extensions(caf.framework)) + assert set(claim_level_extensions(caf, semantics="semi-stable")) == project(caf, semi_stable_extensions(caf.framework)) + assert set(inherited_extensions(caf, semantics="stage")) == project(caf, stage_extensions(caf.framework)) + assert set(claim_level_extensions(caf, semantics="stage")) == project(caf, stage_extensions(caf.framework)) diff --git a/tests/test_partial_af.py b/tests/frameworks/test_partial_af.py similarity index 94% rename from tests/test_partial_af.py rename to tests/frameworks/test_partial_af.py index dee2a63..da4d63e 100644 --- a/tests/test_partial_af.py +++ b/tests/frameworks/test_partial_af.py @@ -1,138 +1,138 @@ -"""Core tests for tiny partial argumentation frameworks.""" - -from __future__ import annotations - -from itertools import combinations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.partial_af import ( - PartialArgumentationFramework, - enumerate_completions, - merge_framework_edit_distance, -) - - -def _powerset(items: frozenset[tuple[str, str]]) -> set[frozenset[tuple[str, str]]]: - ordered = sorted(items) - return { - frozenset(choice) - for size in range(len(ordered) + 1) - for choice in combinations(ordered, size) - } - - -def _tiny_paf() -> PartialArgumentationFramework: - return PartialArgumentationFramework( - arguments={"A", "B"}, - attacks={("A", "B")}, - ignorance={("A", "A"), ("B", "A")}, - non_attacks={("B", "B")}, - ) - - -def test_partial_argumentation_framework_tracks_total_partition(): - paf = _tiny_paf() - - assert paf.ordered_pairs == frozenset( - {("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")} - ) - assert paf.attacks | paf.ignorance | paf.non_attacks == paf.ordered_pairs - assert paf.attacks.isdisjoint(paf.ignorance) - assert paf.attacks.isdisjoint(paf.non_attacks) - assert paf.ignorance.isdisjoint(paf.non_attacks) - - -@pytest.mark.parametrize( - ("attacks", "ignorance", "non_attacks"), - [ - ( - {("A", "B")}, - {("A", "B")}, - {("A", "A"), ("B", "A"), ("B", "B")}, - ), - ( - {("A", "B")}, - set(), - {("A", "A"), ("B", "B")}, - ), - ], -) -def test_partial_argumentation_framework_rejects_non_partitions( - attacks: set[tuple[str, str]], - ignorance: set[tuple[str, str]], - non_attacks: set[tuple[str, str]], -): - with pytest.raises(ValueError): - PartialArgumentationFramework( - arguments={"A", "B"}, - attacks=attacks, - ignorance=ignorance, - non_attacks=non_attacks, - ) - - -def test_paf_completions_are_sound_dung_frameworks(): - paf = _tiny_paf() - - completions = enumerate_completions(paf) - - assert completions - for completion in completions: - assert isinstance(completion, ArgumentationFramework) - assert completion.arguments == paf.arguments - assert paf.attacks <= completion.defeats - assert completion.defeats <= paf.attacks | paf.ignorance - assert completion.defeats.isdisjoint(paf.non_attacks) - - -def test_paf_completions_are_exact_and_counted_by_ignorance_choices(): - paf = _tiny_paf() - - expected = { - frozenset(paf.attacks | choice) - for choice in _powerset(paf.ignorance) - } - actual = {completion.defeats for completion in enumerate_completions(paf)} - - assert actual == expected - assert len(actual) == 2 ** len(paf.ignorance) - - -def test_merge_framework_edit_distance_is_identity_and_symmetric(): - left = _tiny_paf() - right = PartialArgumentationFramework( - arguments={"A", "B"}, - attacks={("A", "B"), ("B", "A")}, - ignorance={("A", "A")}, - non_attacks={("B", "B")}, - ) - - assert merge_framework_edit_distance(left, left) == 0 - assert merge_framework_edit_distance(left, right) == 1 - assert merge_framework_edit_distance(right, left) == 1 - - -def test_merge_framework_edit_distance_satisfies_triangle_inequality(): - left = _tiny_paf() - middle = PartialArgumentationFramework( - arguments={"A", "B"}, - attacks={("A", "B"), ("B", "A")}, - ignorance={("A", "A")}, - non_attacks={("B", "B")}, - ) - right = PartialArgumentationFramework( - arguments={"A", "B"}, - attacks=set(), - ignorance={("A", "A"), ("B", "A")}, - non_attacks={("A", "B"), ("B", "B")}, - ) - - direct = merge_framework_edit_distance(left, right) - via_middle = ( - merge_framework_edit_distance(left, middle) - + merge_framework_edit_distance(middle, right) - ) - - assert direct <= via_middle +"""Core tests for tiny partial argumentation frameworks.""" + +from __future__ import annotations + +from itertools import combinations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.frameworks.partial_af import ( + PartialArgumentationFramework, + enumerate_completions, + merge_framework_edit_distance, +) + + +def _powerset(items: frozenset[tuple[str, str]]) -> set[frozenset[tuple[str, str]]]: + ordered = sorted(items) + return { + frozenset(choice) + for size in range(len(ordered) + 1) + for choice in combinations(ordered, size) + } + + +def _tiny_paf() -> PartialArgumentationFramework: + return PartialArgumentationFramework( + arguments={"A", "B"}, + attacks={("A", "B")}, + ignorance={("A", "A"), ("B", "A")}, + non_attacks={("B", "B")}, + ) + + +def test_partial_argumentation_framework_tracks_total_partition(): + paf = _tiny_paf() + + assert paf.ordered_pairs == frozenset( + {("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")} + ) + assert paf.attacks | paf.ignorance | paf.non_attacks == paf.ordered_pairs + assert paf.attacks.isdisjoint(paf.ignorance) + assert paf.attacks.isdisjoint(paf.non_attacks) + assert paf.ignorance.isdisjoint(paf.non_attacks) + + +@pytest.mark.parametrize( + ("attacks", "ignorance", "non_attacks"), + [ + ( + {("A", "B")}, + {("A", "B")}, + {("A", "A"), ("B", "A"), ("B", "B")}, + ), + ( + {("A", "B")}, + set(), + {("A", "A"), ("B", "B")}, + ), + ], +) +def test_partial_argumentation_framework_rejects_non_partitions( + attacks: set[tuple[str, str]], + ignorance: set[tuple[str, str]], + non_attacks: set[tuple[str, str]], +): + with pytest.raises(ValueError): + PartialArgumentationFramework( + arguments={"A", "B"}, + attacks=attacks, + ignorance=ignorance, + non_attacks=non_attacks, + ) + + +def test_paf_completions_are_sound_dung_frameworks(): + paf = _tiny_paf() + + completions = enumerate_completions(paf) + + assert completions + for completion in completions: + assert isinstance(completion, ArgumentationFramework) + assert completion.arguments == paf.arguments + assert paf.attacks <= completion.defeats + assert completion.defeats <= paf.attacks | paf.ignorance + assert completion.defeats.isdisjoint(paf.non_attacks) + + +def test_paf_completions_are_exact_and_counted_by_ignorance_choices(): + paf = _tiny_paf() + + expected = { + frozenset(paf.attacks | choice) + for choice in _powerset(paf.ignorance) + } + actual = {completion.defeats for completion in enumerate_completions(paf)} + + assert actual == expected + assert len(actual) == 2 ** len(paf.ignorance) + + +def test_merge_framework_edit_distance_is_identity_and_symmetric(): + left = _tiny_paf() + right = PartialArgumentationFramework( + arguments={"A", "B"}, + attacks={("A", "B"), ("B", "A")}, + ignorance={("A", "A")}, + non_attacks={("B", "B")}, + ) + + assert merge_framework_edit_distance(left, left) == 0 + assert merge_framework_edit_distance(left, right) == 1 + assert merge_framework_edit_distance(right, left) == 1 + + +def test_merge_framework_edit_distance_satisfies_triangle_inequality(): + left = _tiny_paf() + middle = PartialArgumentationFramework( + arguments={"A", "B"}, + attacks={("A", "B"), ("B", "A")}, + ignorance={("A", "A")}, + non_attacks={("B", "B")}, + ) + right = PartialArgumentationFramework( + arguments={"A", "B"}, + attacks=set(), + ignorance={("A", "A"), ("B", "A")}, + non_attacks={("A", "B"), ("B", "B")}, + ) + + direct = merge_framework_edit_distance(left, right) + via_middle = ( + merge_framework_edit_distance(left, middle) + + merge_framework_edit_distance(middle, right) + ) + + assert direct <= via_middle diff --git a/tests/test_partial_af_merge.py b/tests/frameworks/test_partial_af_merge.py similarity index 95% rename from tests/test_partial_af_merge.py rename to tests/frameworks/test_partial_af_merge.py index 2084e65..95cf11f 100644 --- a/tests/test_partial_af_merge.py +++ b/tests/frameworks/test_partial_af_merge.py @@ -1,244 +1,244 @@ -"""Tests for exact merge operators over tiny argumentation frameworks.""" - -from __future__ import annotations - -from itertools import product - -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework -from argumentation.partial_af import ( - EnumerationExceeded, - consensual_expand, - leximax_merge_frameworks, - max_merge_frameworks, - sum_merge_frameworks, -) - - -PAIR_SPACE = [("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")] -st_attack_pairs = st.sets(st.sampled_from(PAIR_SPACE), max_size=len(PAIR_SPACE)) -ALL_ATTACK_SETS = [ - set(pair for pair, enabled in zip(PAIR_SPACE, mask, strict=True) if enabled) - for mask in product([False, True], repeat=len(PAIR_SPACE)) -] - - -def _af(arguments: set[str], attacks: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(attacks), - attacks=frozenset(attacks), - ) - - -def test_consensual_expand_preserves_in_scope_pairs_and_marks_out_of_scope_as_ignorance(): - af = _af({"A"}, {("A", "A")}) - - expanded = consensual_expand(af, frozenset({"A", "B"})) - - assert ("A", "A") in expanded.attacks - assert ("A", "B") in expanded.ignorance - assert ("B", "A") in expanded.ignorance - assert ("B", "B") in expanded.ignorance - - -def test_consensual_expand_on_shared_universe_introduces_no_ignorance(): - af = _af({"A", "B"}, {("A", "B")}) - - expanded = consensual_expand(af, frozenset({"A", "B"})) - - assert expanded.ignorance == frozenset() - assert expanded.attacks == frozenset({("A", "B")}) - assert expanded.non_attacks == frozenset( - {("A", "A"), ("B", "A"), ("B", "B")} - ) - - -@settings( - deadline=None, - suppress_health_check=[HealthCheck.too_slow], -) -@given(attacks=st_attack_pairs) -def test_sum_merge_unanimity(attacks: set[tuple[str, str]]): - framework = _af({"A", "B"}, attacks) - - result = sum_merge_frameworks({"left": framework, "right": framework}) - - assert result == [framework] - - -def test_concordant_profiles_yield_unique_result_for_all_operators(): - framework = _af({"A", "B"}, {("A", "B"), ("B", "A")}) - profile = {"left": framework, "right": framework, "third": framework} - - assert sum_merge_frameworks(profile) == [framework] - assert max_merge_frameworks(profile) == [framework] - assert leximax_merge_frameworks(profile) == [framework] - - -@settings( - deadline=None, - suppress_health_check=[HealthCheck.too_slow], -) -@given(left_attacks=st_attack_pairs, right_attacks=st_attack_pairs) -def test_sum_merge_profile_order_invariant( - left_attacks: set[tuple[str, str]], - right_attacks: set[tuple[str, str]], -): - left = _af({"A", "B"}, left_attacks) - right = _af({"A", "B"}, right_attacks) - - forward = sum_merge_frameworks({"left": left, "right": right}) - reverse = sum_merge_frameworks({"right": right, "left": left}) - - assert forward == reverse - - -def test_sum_merge_matches_majority_profile_on_shared_universe(): - left = _af({"A", "B"}, {("A", "B")}) - middle = _af({"A", "B"}, {("A", "B")}) - right = _af({"A", "B"}, set()) - - result = sum_merge_frameworks({"left": left, "middle": middle, "right": right}) - - assert result == [_af({"A", "B"}, {("A", "B")})] - - -def test_sum_merge_strict_bipartition_profile_bypasses_candidate_ceiling(): - arguments = {f"A{index}" for index in range(6)} - majority_attacks = {("A0", "A1"), ("A2", "A3"), ("A4", "A5")} - profile = { - "left": _af(arguments, majority_attacks), - "middle": _af(arguments, majority_attacks), - "right": _af(arguments, set()), - } - - result = sum_merge_frameworks(profile, max_candidates=1) - - assert result == [_af(arguments, majority_attacks)] - - -def test_sum_merge_returns_enumeration_exceeded_past_candidate_ceiling(): - profile = { - "left": _af({"A", "B"}, {("A", "B")}), - "right": _af({"A", "B"}, {("B", "A")}), - } - - result = sum_merge_frameworks(profile, max_candidates=1) - - assert isinstance(result, EnumerationExceeded) - assert result.partial_count == 1 - assert result.max_candidates == 1 - assert result.remainder_provenance == "vacuous" - - -@settings( - deadline=None, - suppress_health_check=[HealthCheck.too_slow], -) -@given(left_attacks=st_attack_pairs, right_attacks=st_attack_pairs) -def test_max_merge_profile_order_invariant( - left_attacks: set[tuple[str, str]], - right_attacks: set[tuple[str, str]], -): - left = _af({"A", "B"}, left_attacks) - right = _af({"A", "B"}, right_attacks) - - forward = max_merge_frameworks({"left": left, "right": right}) - reverse = max_merge_frameworks({"right": right, "left": left}) - - assert forward == reverse - - -def test_max_merge_returns_enumeration_exceeded_past_candidate_ceiling(): - profile = { - "left": _af({"A", "B"}, {("A", "B")}), - "right": _af({"A", "B"}, {("B", "A")}), - } - - result = max_merge_frameworks(profile, max_candidates=1) - - assert isinstance(result, EnumerationExceeded) - assert result.partial_count == 1 - assert result.max_candidates == 1 - assert result.remainder_provenance == "vacuous" - - -def test_leximax_refines_max_results(): - left = _af({"A", "B"}, {("A", "B")}) - middle = _af({"A", "B"}, {("B", "A")}) - right = _af({"A", "B"}, set()) - - max_results = max_merge_frameworks({"left": left, "middle": middle, "right": right}) - leximax_results = leximax_merge_frameworks( - {"left": left, "middle": middle, "right": right} - ) - - assert leximax_results - assert set(leximax_results).issubset(set(max_results)) - - -def test_leximax_merge_returns_enumeration_exceeded_past_candidate_ceiling(): - profile = { - "left": _af({"A", "B"}, {("A", "B")}), - "right": _af({"A", "B"}, {("B", "A")}), - } - - result = leximax_merge_frameworks(profile, max_candidates=1) - - assert isinstance(result, EnumerationExceeded) - assert result.partial_count == 1 - assert result.max_candidates == 1 - assert result.remainder_provenance == "vacuous" - - -def test_sum_and_max_diverge_on_tiny_exact_profile(): - found = None - for left_attacks in ALL_ATTACK_SETS: - for middle_attacks in ALL_ATTACK_SETS: - for right_attacks in ALL_ATTACK_SETS: - profile = { - "left": _af({"A", "B"}, left_attacks), - "middle": _af({"A", "B"}, middle_attacks), - "right": _af({"A", "B"}, right_attacks), - } - sum_results = sum_merge_frameworks(profile) - max_results = max_merge_frameworks(profile) - if set(sum_results) != set(max_results): - found = (profile, sum_results, max_results) - break - if found is not None: - break - if found is not None: - break - - assert found is not None - _profile, sum_results, max_results = found - assert set(sum_results) != set(max_results) - - -def test_leximax_strictly_refines_a_multi_result_max_profile(): - found = None - for left_attacks in ALL_ATTACK_SETS: - for middle_attacks in ALL_ATTACK_SETS: - for right_attacks in ALL_ATTACK_SETS: - profile = { - "left": _af({"A", "B"}, left_attacks), - "middle": _af({"A", "B"}, middle_attacks), - "right": _af({"A", "B"}, right_attacks), - } - max_results = max_merge_frameworks(profile) - leximax_results = leximax_merge_frameworks(profile) - if len(max_results) > 1 and set(leximax_results) < set(max_results): - found = (profile, max_results, leximax_results) - break - if found is not None: - break - if found is not None: - break - - assert found is not None - _profile, max_results, leximax_results = found - assert set(leximax_results) < set(max_results) +"""Tests for exact merge operators over tiny argumentation frameworks.""" + +from __future__ import annotations + +from itertools import product + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework +from argumentation.frameworks.partial_af import ( + EnumerationExceeded, + consensual_expand, + leximax_merge_frameworks, + max_merge_frameworks, + sum_merge_frameworks, +) + + +PAIR_SPACE = [("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")] +st_attack_pairs = st.sets(st.sampled_from(PAIR_SPACE), max_size=len(PAIR_SPACE)) +ALL_ATTACK_SETS = [ + set(pair for pair, enabled in zip(PAIR_SPACE, mask, strict=True) if enabled) + for mask in product([False, True], repeat=len(PAIR_SPACE)) +] + + +def _af(arguments: set[str], attacks: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(attacks), + attacks=frozenset(attacks), + ) + + +def test_consensual_expand_preserves_in_scope_pairs_and_marks_out_of_scope_as_ignorance(): + af = _af({"A"}, {("A", "A")}) + + expanded = consensual_expand(af, frozenset({"A", "B"})) + + assert ("A", "A") in expanded.attacks + assert ("A", "B") in expanded.ignorance + assert ("B", "A") in expanded.ignorance + assert ("B", "B") in expanded.ignorance + + +def test_consensual_expand_on_shared_universe_introduces_no_ignorance(): + af = _af({"A", "B"}, {("A", "B")}) + + expanded = consensual_expand(af, frozenset({"A", "B"})) + + assert expanded.ignorance == frozenset() + assert expanded.attacks == frozenset({("A", "B")}) + assert expanded.non_attacks == frozenset( + {("A", "A"), ("B", "A"), ("B", "B")} + ) + + +@settings( + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given(attacks=st_attack_pairs) +def test_sum_merge_unanimity(attacks: set[tuple[str, str]]): + framework = _af({"A", "B"}, attacks) + + result = sum_merge_frameworks({"left": framework, "right": framework}) + + assert result == [framework] + + +def test_concordant_profiles_yield_unique_result_for_all_operators(): + framework = _af({"A", "B"}, {("A", "B"), ("B", "A")}) + profile = {"left": framework, "right": framework, "third": framework} + + assert sum_merge_frameworks(profile) == [framework] + assert max_merge_frameworks(profile) == [framework] + assert leximax_merge_frameworks(profile) == [framework] + + +@settings( + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given(left_attacks=st_attack_pairs, right_attacks=st_attack_pairs) +def test_sum_merge_profile_order_invariant( + left_attacks: set[tuple[str, str]], + right_attacks: set[tuple[str, str]], +): + left = _af({"A", "B"}, left_attacks) + right = _af({"A", "B"}, right_attacks) + + forward = sum_merge_frameworks({"left": left, "right": right}) + reverse = sum_merge_frameworks({"right": right, "left": left}) + + assert forward == reverse + + +def test_sum_merge_matches_majority_profile_on_shared_universe(): + left = _af({"A", "B"}, {("A", "B")}) + middle = _af({"A", "B"}, {("A", "B")}) + right = _af({"A", "B"}, set()) + + result = sum_merge_frameworks({"left": left, "middle": middle, "right": right}) + + assert result == [_af({"A", "B"}, {("A", "B")})] + + +def test_sum_merge_strict_bipartition_profile_bypasses_candidate_ceiling(): + arguments = {f"A{index}" for index in range(6)} + majority_attacks = {("A0", "A1"), ("A2", "A3"), ("A4", "A5")} + profile = { + "left": _af(arguments, majority_attacks), + "middle": _af(arguments, majority_attacks), + "right": _af(arguments, set()), + } + + result = sum_merge_frameworks(profile, max_candidates=1) + + assert result == [_af(arguments, majority_attacks)] + + +def test_sum_merge_returns_enumeration_exceeded_past_candidate_ceiling(): + profile = { + "left": _af({"A", "B"}, {("A", "B")}), + "right": _af({"A", "B"}, {("B", "A")}), + } + + result = sum_merge_frameworks(profile, max_candidates=1) + + assert isinstance(result, EnumerationExceeded) + assert result.partial_count == 1 + assert result.max_candidates == 1 + assert result.remainder_provenance == "vacuous" + + +@settings( + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given(left_attacks=st_attack_pairs, right_attacks=st_attack_pairs) +def test_max_merge_profile_order_invariant( + left_attacks: set[tuple[str, str]], + right_attacks: set[tuple[str, str]], +): + left = _af({"A", "B"}, left_attacks) + right = _af({"A", "B"}, right_attacks) + + forward = max_merge_frameworks({"left": left, "right": right}) + reverse = max_merge_frameworks({"right": right, "left": left}) + + assert forward == reverse + + +def test_max_merge_returns_enumeration_exceeded_past_candidate_ceiling(): + profile = { + "left": _af({"A", "B"}, {("A", "B")}), + "right": _af({"A", "B"}, {("B", "A")}), + } + + result = max_merge_frameworks(profile, max_candidates=1) + + assert isinstance(result, EnumerationExceeded) + assert result.partial_count == 1 + assert result.max_candidates == 1 + assert result.remainder_provenance == "vacuous" + + +def test_leximax_refines_max_results(): + left = _af({"A", "B"}, {("A", "B")}) + middle = _af({"A", "B"}, {("B", "A")}) + right = _af({"A", "B"}, set()) + + max_results = max_merge_frameworks({"left": left, "middle": middle, "right": right}) + leximax_results = leximax_merge_frameworks( + {"left": left, "middle": middle, "right": right} + ) + + assert leximax_results + assert set(leximax_results).issubset(set(max_results)) + + +def test_leximax_merge_returns_enumeration_exceeded_past_candidate_ceiling(): + profile = { + "left": _af({"A", "B"}, {("A", "B")}), + "right": _af({"A", "B"}, {("B", "A")}), + } + + result = leximax_merge_frameworks(profile, max_candidates=1) + + assert isinstance(result, EnumerationExceeded) + assert result.partial_count == 1 + assert result.max_candidates == 1 + assert result.remainder_provenance == "vacuous" + + +def test_sum_and_max_diverge_on_tiny_exact_profile(): + found = None + for left_attacks in ALL_ATTACK_SETS: + for middle_attacks in ALL_ATTACK_SETS: + for right_attacks in ALL_ATTACK_SETS: + profile = { + "left": _af({"A", "B"}, left_attacks), + "middle": _af({"A", "B"}, middle_attacks), + "right": _af({"A", "B"}, right_attacks), + } + sum_results = sum_merge_frameworks(profile) + max_results = max_merge_frameworks(profile) + if set(sum_results) != set(max_results): + found = (profile, sum_results, max_results) + break + if found is not None: + break + if found is not None: + break + + assert found is not None + _profile, sum_results, max_results = found + assert set(sum_results) != set(max_results) + + +def test_leximax_strictly_refines_a_multi_result_max_profile(): + found = None + for left_attacks in ALL_ATTACK_SETS: + for middle_attacks in ALL_ATTACK_SETS: + for right_attacks in ALL_ATTACK_SETS: + profile = { + "left": _af({"A", "B"}, left_attacks), + "middle": _af({"A", "B"}, middle_attacks), + "right": _af({"A", "B"}, right_attacks), + } + max_results = max_merge_frameworks(profile) + leximax_results = leximax_merge_frameworks(profile) + if len(max_results) > 1 and set(leximax_results) < set(max_results): + found = (profile, max_results, leximax_results) + break + if found is not None: + break + if found is not None: + break + + assert found is not None + _profile, max_results, leximax_results = found + assert set(leximax_results) < set(max_results) diff --git a/tests/test_partial_af_queries.py b/tests/frameworks/test_partial_af_queries.py similarity index 94% rename from tests/test_partial_af_queries.py rename to tests/frameworks/test_partial_af_queries.py index 38af272..b8a0c0d 100644 --- a/tests/test_partial_af_queries.py +++ b/tests/frameworks/test_partial_af_queries.py @@ -1,155 +1,155 @@ -"""Tests for completion-based query semantics over partial frameworks.""" - -from __future__ import annotations - -from itertools import product - -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st - -from argumentation.dung import grounded_extension, preferred_extensions, stable_extensions -from argumentation.partial_af import ( - PartialArgumentationFramework, - credulously_accepted_arguments, - enumerate_completions, - skeptically_accepted_arguments, -) - - -PAIR_ORDER = [("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")] -st_states = st.lists( - st.sampled_from(["attack", "ignorance", "non_attack"]), - min_size=len(PAIR_ORDER), - max_size=len(PAIR_ORDER), -) -ALL_STATE_ASSIGNMENTS = list(product(["attack", "ignorance", "non_attack"], repeat=len(PAIR_ORDER))) - - -def _example_paf() -> PartialArgumentationFramework: - return PartialArgumentationFramework( - arguments={"A", "B"}, - attacks={("A", "B")}, - ignorance={("B", "A")}, - non_attacks={("A", "A"), ("B", "B")}, - ) - - -def _paf_from_states(states: tuple[str, ...] | list[str]) -> PartialArgumentationFramework: - attacks = set() - ignorance = set() - non_attacks = set() - for pair, state in zip(PAIR_ORDER, states, strict=True): - if state == "attack": - attacks.add(pair) - elif state == "ignorance": - ignorance.add(pair) - else: - non_attacks.add(pair) - return PartialArgumentationFramework( - arguments={"A", "B"}, - attacks=attacks, - ignorance=ignorance, - non_attacks=non_attacks, - ) - - -def _extensions_for_completion( - paf: PartialArgumentationFramework, - semantics: str, -) -> list[frozenset[str]]: - extensions: list[frozenset[str]] = [] - for completion in enumerate_completions(paf): - if semantics == "grounded": - extensions.append(grounded_extension(completion)) - elif semantics == "preferred": - extensions.extend(frozenset(ext) for ext in preferred_extensions(completion)) - elif semantics == "stable": - extensions.extend(frozenset(ext) for ext in stable_extensions(completion)) - else: - raise AssertionError(f"unexpected semantics {semantics}") - return extensions - - -def _bruteforce_skeptical(paf: PartialArgumentationFramework, semantics: str) -> frozenset[str]: - extensions = _extensions_for_completion(paf, semantics) - if not extensions: - return frozenset() - skeptical = set(paf.arguments) - for extension in extensions: - skeptical.intersection_update(extension) - return frozenset(skeptical) - - -def _bruteforce_credulous(paf: PartialArgumentationFramework, semantics: str) -> frozenset[str]: - credulous: set[str] = set() - for extension in _extensions_for_completion(paf, semantics): - credulous.update(extension) - return frozenset(credulous) - - -def test_grounded_skeptical_and_credulous_acceptance_follow_completions(): - paf = _example_paf() - - skeptical = skeptically_accepted_arguments(paf, semantics="grounded") - credulous = credulously_accepted_arguments(paf, semantics="grounded") - - assert skeptical == frozenset() - assert credulous == frozenset({"A"}) - - -def test_single_completion_matches_ordinary_dung_query(): - paf = PartialArgumentationFramework( - arguments={"A", "B"}, - attacks={("A", "B")}, - ignorance=set(), - non_attacks={("A", "A"), ("B", "A"), ("B", "B")}, - ) - - assert skeptically_accepted_arguments(paf, semantics="grounded") == frozenset({"A"}) - assert credulously_accepted_arguments(paf, semantics="grounded") == frozenset({"A"}) - - -def test_query_helpers_match_bruteforce_completion_semantics_on_tiny_profiles(): - for semantics in ("grounded", "preferred", "stable"): - for states in ALL_STATE_ASSIGNMENTS: - paf = _paf_from_states(states) - assert skeptically_accepted_arguments(paf, semantics=semantics) == _bruteforce_skeptical( - paf, semantics - ) - assert credulously_accepted_arguments(paf, semantics=semantics) == _bruteforce_credulous( - paf, semantics - ) - - -@settings( - deadline=None, - suppress_health_check=[HealthCheck.too_slow], -) -@given(states=st_states, fixed_relation=st.sampled_from(["attack", "non_attack"])) -def test_fixing_ignorance_grows_skeptical_and_shrinks_credulous( - states: list[str], - fixed_relation: str, -): - paf = _paf_from_states(states) - if not paf.ignorance: - return - - fixed_pair = next(iter(sorted(paf.ignorance))) - fixed_framework = PartialArgumentationFramework( - arguments=paf.arguments, - attacks=(paf.attacks | {fixed_pair}) if fixed_relation == "attack" else paf.attacks, - ignorance=paf.ignorance - {fixed_pair}, - non_attacks=( - paf.non_attacks | {fixed_pair} - if fixed_relation == "non_attack" - else paf.non_attacks - ), - ) - - original_skeptical = skeptically_accepted_arguments(paf, semantics="grounded") - fixed_skeptical = skeptically_accepted_arguments(fixed_framework, semantics="grounded") - original_credulous = credulously_accepted_arguments(paf, semantics="grounded") - fixed_credulous = credulously_accepted_arguments(fixed_framework, semantics="grounded") - - assert original_skeptical <= fixed_skeptical - assert fixed_credulous <= original_credulous +"""Tests for completion-based query semantics over partial frameworks.""" + +from __future__ import annotations + +from itertools import product + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import grounded_extension, preferred_extensions, stable_extensions +from argumentation.frameworks.partial_af import ( + PartialArgumentationFramework, + credulously_accepted_arguments, + enumerate_completions, + skeptically_accepted_arguments, +) + + +PAIR_ORDER = [("A", "A"), ("A", "B"), ("B", "A"), ("B", "B")] +st_states = st.lists( + st.sampled_from(["attack", "ignorance", "non_attack"]), + min_size=len(PAIR_ORDER), + max_size=len(PAIR_ORDER), +) +ALL_STATE_ASSIGNMENTS = list(product(["attack", "ignorance", "non_attack"], repeat=len(PAIR_ORDER))) + + +def _example_paf() -> PartialArgumentationFramework: + return PartialArgumentationFramework( + arguments={"A", "B"}, + attacks={("A", "B")}, + ignorance={("B", "A")}, + non_attacks={("A", "A"), ("B", "B")}, + ) + + +def _paf_from_states(states: tuple[str, ...] | list[str]) -> PartialArgumentationFramework: + attacks = set() + ignorance = set() + non_attacks = set() + for pair, state in zip(PAIR_ORDER, states, strict=True): + if state == "attack": + attacks.add(pair) + elif state == "ignorance": + ignorance.add(pair) + else: + non_attacks.add(pair) + return PartialArgumentationFramework( + arguments={"A", "B"}, + attacks=attacks, + ignorance=ignorance, + non_attacks=non_attacks, + ) + + +def _extensions_for_completion( + paf: PartialArgumentationFramework, + semantics: str, +) -> list[frozenset[str]]: + extensions: list[frozenset[str]] = [] + for completion in enumerate_completions(paf): + if semantics == "grounded": + extensions.append(grounded_extension(completion)) + elif semantics == "preferred": + extensions.extend(frozenset(ext) for ext in preferred_extensions(completion)) + elif semantics == "stable": + extensions.extend(frozenset(ext) for ext in stable_extensions(completion)) + else: + raise AssertionError(f"unexpected semantics {semantics}") + return extensions + + +def _bruteforce_skeptical(paf: PartialArgumentationFramework, semantics: str) -> frozenset[str]: + extensions = _extensions_for_completion(paf, semantics) + if not extensions: + return frozenset() + skeptical = set(paf.arguments) + for extension in extensions: + skeptical.intersection_update(extension) + return frozenset(skeptical) + + +def _bruteforce_credulous(paf: PartialArgumentationFramework, semantics: str) -> frozenset[str]: + credulous: set[str] = set() + for extension in _extensions_for_completion(paf, semantics): + credulous.update(extension) + return frozenset(credulous) + + +def test_grounded_skeptical_and_credulous_acceptance_follow_completions(): + paf = _example_paf() + + skeptical = skeptically_accepted_arguments(paf, semantics="grounded") + credulous = credulously_accepted_arguments(paf, semantics="grounded") + + assert skeptical == frozenset() + assert credulous == frozenset({"A"}) + + +def test_single_completion_matches_ordinary_dung_query(): + paf = PartialArgumentationFramework( + arguments={"A", "B"}, + attacks={("A", "B")}, + ignorance=set(), + non_attacks={("A", "A"), ("B", "A"), ("B", "B")}, + ) + + assert skeptically_accepted_arguments(paf, semantics="grounded") == frozenset({"A"}) + assert credulously_accepted_arguments(paf, semantics="grounded") == frozenset({"A"}) + + +def test_query_helpers_match_bruteforce_completion_semantics_on_tiny_profiles(): + for semantics in ("grounded", "preferred", "stable"): + for states in ALL_STATE_ASSIGNMENTS: + paf = _paf_from_states(states) + assert skeptically_accepted_arguments(paf, semantics=semantics) == _bruteforce_skeptical( + paf, semantics + ) + assert credulously_accepted_arguments(paf, semantics=semantics) == _bruteforce_credulous( + paf, semantics + ) + + +@settings( + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given(states=st_states, fixed_relation=st.sampled_from(["attack", "non_attack"])) +def test_fixing_ignorance_grows_skeptical_and_shrinks_credulous( + states: list[str], + fixed_relation: str, +): + paf = _paf_from_states(states) + if not paf.ignorance: + return + + fixed_pair = next(iter(sorted(paf.ignorance))) + fixed_framework = PartialArgumentationFramework( + arguments=paf.arguments, + attacks=(paf.attacks | {fixed_pair}) if fixed_relation == "attack" else paf.attacks, + ignorance=paf.ignorance - {fixed_pair}, + non_attacks=( + paf.non_attacks | {fixed_pair} + if fixed_relation == "non_attack" + else paf.non_attacks + ), + ) + + original_skeptical = skeptically_accepted_arguments(paf, semantics="grounded") + fixed_skeptical = skeptically_accepted_arguments(fixed_framework, semantics="grounded") + original_credulous = credulously_accepted_arguments(paf, semantics="grounded") + fixed_credulous = credulously_accepted_arguments(fixed_framework, semantics="grounded") + + assert original_skeptical <= fixed_skeptical + assert fixed_credulous <= original_credulous diff --git a/tests/test_practical_reasoning.py b/tests/frameworks/test_practical_reasoning.py similarity index 95% rename from tests/test_practical_reasoning.py rename to tests/frameworks/test_practical_reasoning.py index 035e8cb..66ddf1c 100644 --- a/tests/test_practical_reasoning.py +++ b/tests/frameworks/test_practical_reasoning.py @@ -5,19 +5,13 @@ import pytest -import argumentation -from argumentation.practical_reasoning import ( +from argumentation.frameworks.practical_reasoning import ( ActionBasedAlternatingTransitionSystem, PracticalArgument, critical_question_objections, ) -def test_practical_reasoning_module_is_exported() -> None: - assert argumentation.practical_reasoning.PracticalArgument is PracticalArgument - assert "practical_reasoning" in argumentation.__all__ - - def _toy_aats() -> ActionBasedAlternatingTransitionSystem: return ActionBasedAlternatingTransitionSystem( states=frozenset({"q0", "q1", "q2", "q3"}), diff --git a/tests/test_setaf.py b/tests/frameworks/test_setaf.py similarity index 96% rename from tests/test_setaf.py rename to tests/frameworks/test_setaf.py index a7ee63f..f25413a 100644 --- a/tests/test_setaf.py +++ b/tests/frameworks/test_setaf.py @@ -1,260 +1,260 @@ -from __future__ import annotations - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.dung import ( - ArgumentationFramework, - admissible as dung_admissible, - complete_extensions as dung_complete_extensions, - conflict_free as dung_conflict_free, - grounded_extension, - preferred_extensions, - stable_extensions as dung_stable_extensions, -) -from argumentation.setaf import ( - SETAF, - admissible, - attacks_argument, - characteristic_fn, - complete_extensions, - conflict_free, - defends, - grounded_extension as setaf_grounded_extension, - preferred_extensions as setaf_preferred_extensions, - range_of, - stable_extensions, -) - - -ARGUMENTS = frozenset({"a", "b", "c", "d"}) -SMALL_ARGUMENTS = ("a", "b", "c", "d") - - -def _all_subsets(arguments: frozenset[str]) -> tuple[frozenset[str], ...]: - ordered = sorted(arguments) - return tuple( - frozenset(argument for index, argument in enumerate(ordered) if mask & (1 << index)) - for mask in range(1 << len(ordered)) - ) - - -@st.composite -def setafs(draw: st.DrawFn) -> SETAF: - arguments = frozenset(draw(st.sets(st.sampled_from(SMALL_ARGUMENTS), max_size=4))) - possible_attacks = [ - (tail, target) - for tail in _all_subsets(arguments) - if tail - for target in sorted(arguments) - ] - attack_strategy = ( - st.just(set()) - if not possible_attacks - else st.sets(st.sampled_from(possible_attacks), max_size=8) - ) - attacks = frozenset(draw(attack_strategy)) - return SETAF(arguments=arguments, attacks=attacks) - - -@st.composite -def singleton_tail_frameworks(draw: st.DrawFn) -> tuple[SETAF, ArgumentationFramework]: - arguments = frozenset(draw(st.sets(st.sampled_from(SMALL_ARGUMENTS), max_size=4))) - possible_defeats = [ - (attacker, target) - for attacker in sorted(arguments) - for target in sorted(arguments) - ] - defeat_strategy = ( - st.just(set()) - if not possible_defeats - else st.sets(st.sampled_from(possible_defeats), max_size=8) - ) - defeats = frozenset(draw(defeat_strategy)) - setaf = SETAF( - arguments=arguments, - attacks=frozenset((frozenset({attacker}), target) for attacker, target in defeats), - ) - dung = ArgumentationFramework(arguments=arguments, defeats=defeats) - return setaf, dung - - -def test_collective_attack_requires_all_attackers() -> None: - framework = SETAF( - arguments=frozenset({"a", "b", "c"}), - attacks=frozenset({(frozenset({"a", "b"}), "c")}), - ) - - assert conflict_free(framework, frozenset({"a", "c"})) is True - assert conflict_free(framework, frozenset({"a", "b", "c"})) is False - - -def test_admissibility_defends_against_collective_attackers() -> None: - framework = SETAF( - arguments=frozenset({"a", "b", "c", "x", "y"}), - attacks=frozenset( - { - (frozenset({"a", "b"}), "c"), - (frozenset({"x"}), "a"), - (frozenset({"y"}), "b"), - } - ), - ) - - assert admissible(framework, frozenset({"c"})) is False - assert admissible(framework, frozenset({"c", "x"})) is True - assert admissible(framework, frozenset({"c", "x", "y"})) is True - - -def test_setaf_rejects_empty_attack_tails_from_definition_1() -> None: - with pytest.raises(ValueError, match="non-empty"): - SETAF( - arguments=frozenset({"a"}), - attacks=frozenset({(frozenset(), "a")}), - ) - - -def test_defense_attacks_at_least_one_member_of_collective_tail() -> None: - framework = SETAF( - arguments=frozenset({"a", "b", "c", "x"}), - attacks=frozenset( - { - (frozenset({"a", "b"}), "c"), - (frozenset({"x"}), "a"), - } - ), - ) - - assert defends(framework, frozenset({"x"}), "c") is True - - -def test_singleton_setaf_reduces_to_dung_for_grounded_and_preferred() -> None: - attacks = frozenset({(frozenset({"a"}), "b"), (frozenset({"b"}), "a")}) - setaf = SETAF(arguments=frozenset({"a", "b"}), attacks=attacks) - dung = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - assert setaf_grounded_extension(setaf) == grounded_extension(dung) - assert set(setaf_preferred_extensions(setaf)) == set(preferred_extensions(dung)) - - -def test_stable_extensions_cover_outsiders_with_collective_attacks() -> None: - framework = SETAF( - arguments=frozenset({"a", "b", "c"}), - attacks=frozenset({(frozenset({"a", "b"}), "c")}), - ) - - assert stable_extensions(framework) == (frozenset({"a", "b"}),) - - -def test_grounded_extension_is_subset_minimal_complete_extension() -> None: - framework = SETAF( - arguments=frozenset({"a", "b", "c", "d"}), - attacks=frozenset( - { - (frozenset({"a", "b"}), "c"), - (frozenset({"c"}), "d"), - } - ), - ) - - grounded = setaf_grounded_extension(framework) - completes = complete_extensions(framework) - - assert grounded in completes - assert not any(candidate < grounded for candidate in completes) - - -@given(setafs()) -@settings(max_examples=100) -def test_definition_1_attack_activation_iff_tail_is_contained(framework: SETAF) -> None: - for candidate in _all_subsets(framework.arguments): - for target in framework.arguments: - assert attacks_argument(framework, candidate, target) is any( - tail <= candidate and attacked == target - for tail, attacked in framework.attacks - ) - - -@given(setafs()) -@settings(max_examples=100) -def test_definition_2_conflict_free_iff_no_active_attack_hits_candidate( - framework: SETAF, -) -> None: - for candidate in _all_subsets(framework.arguments): - assert conflict_free(framework, candidate) is not any( - tail <= candidate and target in candidate - for tail, target in framework.attacks - ) - - -@given(setafs()) -@settings(max_examples=100) -def test_definition_3_stable_iff_conflict_free_and_full_range(framework: SETAF) -> None: - expected = { - candidate - for candidate in _all_subsets(framework.arguments) - if conflict_free(framework, candidate) - and range_of(framework, candidate) == framework.arguments - } - - assert set(stable_extensions(framework)) == expected - - -@given(setafs()) -@settings(max_examples=100) -def test_grounded_is_subset_minimal_complete_extension(framework: SETAF) -> None: - grounded = setaf_grounded_extension(framework) - completes = complete_extensions(framework) - - assert grounded in completes - assert not any(candidate < grounded for candidate in completes) - - -@given(setafs()) -@settings(max_examples=100) -def test_characteristic_function_is_monotone(framework: SETAF) -> None: - subsets = _all_subsets(framework.arguments) - for left in subsets: - for right in subsets: - if left <= right: - assert characteristic_fn(framework, left) <= characteristic_fn(framework, right) - - -@given(setafs()) -@settings(max_examples=100) -def test_complete_extensions_are_exactly_admissible_fixed_points( - framework: SETAF, -) -> None: - expected = { - candidate - for candidate in _all_subsets(framework.arguments) - if admissible(framework, candidate) - and characteristic_fn(framework, candidate) == candidate - } - - assert set(complete_extensions(framework)) == expected - - -@given(singleton_tail_frameworks()) -@settings(max_examples=100) -def test_singleton_tail_setafs_reduce_to_dung_semantics( - pair: tuple[SETAF, ArgumentationFramework], -) -> None: - setaf, dung = pair - - for candidate in _all_subsets(setaf.arguments): - assert conflict_free(setaf, candidate) is dung_conflict_free(candidate, dung.defeats) - assert admissible(setaf, candidate) is dung_admissible( - candidate, - dung.arguments, - dung.defeats, - ) - - assert set(complete_extensions(setaf)) == set(dung_complete_extensions(dung)) - assert setaf_grounded_extension(setaf) == grounded_extension(dung) - assert set(setaf_preferred_extensions(setaf)) == set(preferred_extensions(dung)) - assert set(stable_extensions(setaf)) == set(dung_stable_extensions(dung)) +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import ( + ArgumentationFramework, + admissible as dung_admissible, + complete_extensions as dung_complete_extensions, + conflict_free as dung_conflict_free, + grounded_extension, + preferred_extensions, + stable_extensions as dung_stable_extensions, +) +from argumentation.frameworks.setaf import ( + SETAF, + admissible, + attacks_argument, + characteristic_fn, + complete_extensions, + conflict_free, + defends, + grounded_extension as setaf_grounded_extension, + preferred_extensions as setaf_preferred_extensions, + range_of, + stable_extensions, +) + + +ARGUMENTS = frozenset({"a", "b", "c", "d"}) +SMALL_ARGUMENTS = ("a", "b", "c", "d") + + +def _all_subsets(arguments: frozenset[str]) -> tuple[frozenset[str], ...]: + ordered = sorted(arguments) + return tuple( + frozenset(argument for index, argument in enumerate(ordered) if mask & (1 << index)) + for mask in range(1 << len(ordered)) + ) + + +@st.composite +def setafs(draw: st.DrawFn) -> SETAF: + arguments = frozenset(draw(st.sets(st.sampled_from(SMALL_ARGUMENTS), max_size=4))) + possible_attacks = [ + (tail, target) + for tail in _all_subsets(arguments) + if tail + for target in sorted(arguments) + ] + attack_strategy = ( + st.just(set()) + if not possible_attacks + else st.sets(st.sampled_from(possible_attacks), max_size=8) + ) + attacks = frozenset(draw(attack_strategy)) + return SETAF(arguments=arguments, attacks=attacks) + + +@st.composite +def singleton_tail_frameworks(draw: st.DrawFn) -> tuple[SETAF, ArgumentationFramework]: + arguments = frozenset(draw(st.sets(st.sampled_from(SMALL_ARGUMENTS), max_size=4))) + possible_defeats = [ + (attacker, target) + for attacker in sorted(arguments) + for target in sorted(arguments) + ] + defeat_strategy = ( + st.just(set()) + if not possible_defeats + else st.sets(st.sampled_from(possible_defeats), max_size=8) + ) + defeats = frozenset(draw(defeat_strategy)) + setaf = SETAF( + arguments=arguments, + attacks=frozenset((frozenset({attacker}), target) for attacker, target in defeats), + ) + dung = ArgumentationFramework(arguments=arguments, defeats=defeats) + return setaf, dung + + +def test_collective_attack_requires_all_attackers() -> None: + framework = SETAF( + arguments=frozenset({"a", "b", "c"}), + attacks=frozenset({(frozenset({"a", "b"}), "c")}), + ) + + assert conflict_free(framework, frozenset({"a", "c"})) is True + assert conflict_free(framework, frozenset({"a", "b", "c"})) is False + + +def test_admissibility_defends_against_collective_attackers() -> None: + framework = SETAF( + arguments=frozenset({"a", "b", "c", "x", "y"}), + attacks=frozenset( + { + (frozenset({"a", "b"}), "c"), + (frozenset({"x"}), "a"), + (frozenset({"y"}), "b"), + } + ), + ) + + assert admissible(framework, frozenset({"c"})) is False + assert admissible(framework, frozenset({"c", "x"})) is True + assert admissible(framework, frozenset({"c", "x", "y"})) is True + + +def test_setaf_rejects_empty_attack_tails_from_definition_1() -> None: + with pytest.raises(ValueError, match="non-empty"): + SETAF( + arguments=frozenset({"a"}), + attacks=frozenset({(frozenset(), "a")}), + ) + + +def test_defense_attacks_at_least_one_member_of_collective_tail() -> None: + framework = SETAF( + arguments=frozenset({"a", "b", "c", "x"}), + attacks=frozenset( + { + (frozenset({"a", "b"}), "c"), + (frozenset({"x"}), "a"), + } + ), + ) + + assert defends(framework, frozenset({"x"}), "c") is True + + +def test_singleton_setaf_reduces_to_dung_for_grounded_and_preferred() -> None: + attacks = frozenset({(frozenset({"a"}), "b"), (frozenset({"b"}), "a")}) + setaf = SETAF(arguments=frozenset({"a", "b"}), attacks=attacks) + dung = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + assert setaf_grounded_extension(setaf) == grounded_extension(dung) + assert set(setaf_preferred_extensions(setaf)) == set(preferred_extensions(dung)) + + +def test_stable_extensions_cover_outsiders_with_collective_attacks() -> None: + framework = SETAF( + arguments=frozenset({"a", "b", "c"}), + attacks=frozenset({(frozenset({"a", "b"}), "c")}), + ) + + assert stable_extensions(framework) == (frozenset({"a", "b"}),) + + +def test_grounded_extension_is_subset_minimal_complete_extension() -> None: + framework = SETAF( + arguments=frozenset({"a", "b", "c", "d"}), + attacks=frozenset( + { + (frozenset({"a", "b"}), "c"), + (frozenset({"c"}), "d"), + } + ), + ) + + grounded = setaf_grounded_extension(framework) + completes = complete_extensions(framework) + + assert grounded in completes + assert not any(candidate < grounded for candidate in completes) + + +@given(setafs()) +@settings(max_examples=100) +def test_definition_1_attack_activation_iff_tail_is_contained(framework: SETAF) -> None: + for candidate in _all_subsets(framework.arguments): + for target in framework.arguments: + assert attacks_argument(framework, candidate, target) is any( + tail <= candidate and attacked == target + for tail, attacked in framework.attacks + ) + + +@given(setafs()) +@settings(max_examples=100) +def test_definition_2_conflict_free_iff_no_active_attack_hits_candidate( + framework: SETAF, +) -> None: + for candidate in _all_subsets(framework.arguments): + assert conflict_free(framework, candidate) is not any( + tail <= candidate and target in candidate + for tail, target in framework.attacks + ) + + +@given(setafs()) +@settings(max_examples=100) +def test_definition_3_stable_iff_conflict_free_and_full_range(framework: SETAF) -> None: + expected = { + candidate + for candidate in _all_subsets(framework.arguments) + if conflict_free(framework, candidate) + and range_of(framework, candidate) == framework.arguments + } + + assert set(stable_extensions(framework)) == expected + + +@given(setafs()) +@settings(max_examples=100) +def test_grounded_is_subset_minimal_complete_extension(framework: SETAF) -> None: + grounded = setaf_grounded_extension(framework) + completes = complete_extensions(framework) + + assert grounded in completes + assert not any(candidate < grounded for candidate in completes) + + +@given(setafs()) +@settings(max_examples=100) +def test_characteristic_function_is_monotone(framework: SETAF) -> None: + subsets = _all_subsets(framework.arguments) + for left in subsets: + for right in subsets: + if left <= right: + assert characteristic_fn(framework, left) <= characteristic_fn(framework, right) + + +@given(setafs()) +@settings(max_examples=100) +def test_complete_extensions_are_exactly_admissible_fixed_points( + framework: SETAF, +) -> None: + expected = { + candidate + for candidate in _all_subsets(framework.arguments) + if admissible(framework, candidate) + and characteristic_fn(framework, candidate) == candidate + } + + assert set(complete_extensions(framework)) == expected + + +@given(singleton_tail_frameworks()) +@settings(max_examples=100) +def test_singleton_tail_setafs_reduce_to_dung_semantics( + pair: tuple[SETAF, ArgumentationFramework], +) -> None: + setaf, dung = pair + + for candidate in _all_subsets(setaf.arguments): + assert conflict_free(setaf, candidate) is dung_conflict_free(candidate, dung.defeats) + assert admissible(setaf, candidate) is dung_admissible( + candidate, + dung.arguments, + dung.defeats, + ) + + assert set(complete_extensions(setaf)) == set(dung_complete_extensions(dung)) + assert setaf_grounded_extension(setaf) == grounded_extension(dung) + assert set(setaf_preferred_extensions(setaf)) == set(preferred_extensions(dung)) + assert set(stable_extensions(setaf)) == set(dung_stable_extensions(dung)) diff --git a/tests/test_setaf_io.py b/tests/frameworks/test_setaf_io.py similarity index 96% rename from tests/test_setaf_io.py rename to tests/frameworks/test_setaf_io.py index 1c649f2..1724ba3 100644 --- a/tests/test_setaf_io.py +++ b/tests/frameworks/test_setaf_io.py @@ -4,8 +4,8 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.setaf import SETAF -from argumentation.setaf_io import ( +from argumentation.frameworks.setaf import SETAF +from argumentation.frameworks.setaf_io import ( parse_aspartix_setaf, parse_compact_setaf, write_aspartix_setaf, diff --git a/tests/test_vaf.py b/tests/frameworks/test_vaf.py similarity index 92% rename from tests/test_vaf.py rename to tests/frameworks/test_vaf.py index 92c3a82..2a0af7c 100644 --- a/tests/test_vaf.py +++ b/tests/frameworks/test_vaf.py @@ -3,13 +3,7 @@ from hypothesis import given from hypothesis import strategies as st -import argumentation -from argumentation.vaf import ValueBasedArgumentationFramework - - -def test_vaf_module_is_exported() -> None: - assert argumentation.vaf.ValueBasedArgumentationFramework is ValueBasedArgumentationFramework - assert "vaf" in argumentation.__all__ +from argumentation.frameworks.vaf import ValueBasedArgumentationFramework def test_successful_attacks_follow_bench_capon_defeat_condition() -> None: diff --git a/tests/test_vaf_completion.py b/tests/frameworks/test_vaf_completion.py similarity index 98% rename from tests/test_vaf_completion.py rename to tests/frameworks/test_vaf_completion.py index 457b666..3b512ae 100644 --- a/tests/test_vaf_completion.py +++ b/tests/frameworks/test_vaf_completion.py @@ -4,8 +4,8 @@ from hypothesis import given from hypothesis import strategies as st -from argumentation.vaf import ValueBasedArgumentationFramework -from argumentation.vaf_completion import ( +from argumentation.frameworks.vaf import ValueBasedArgumentationFramework +from argumentation.frameworks.vaf_completion import ( FACT_VALUE, ArgumentChain, ArgumentLine, diff --git a/tests/gradual/__init__.py b/tests/gradual/__init__.py new file mode 100644 index 0000000..e59c5e8 --- /dev/null +++ b/tests/gradual/__init__.py @@ -0,0 +1 @@ +"""Tests for the gradual layer.""" diff --git a/tests/test_baroni_2019_principle_library.py b/tests/gradual/test_baroni_2019_principle_library.py similarity index 85% rename from tests/test_baroni_2019_principle_library.py rename to tests/gradual/test_baroni_2019_principle_library.py index ef566ec..38bed1e 100644 --- a/tests/test_baroni_2019_principle_library.py +++ b/tests/gradual/test_baroni_2019_principle_library.py @@ -1,8 +1,8 @@ from __future__ import annotations -from argumentation.dfquad import dfquad_strengths -from argumentation.gradual import WeightedBipolarGraph -from argumentation.gradual_principles import ( +from argumentation.gradual.dfquad import dfquad_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph +from argumentation.gradual.gradual_principles import ( PRINCIPLE_COMPLIANCE, ComplianceLabel, principle_balance, diff --git a/tests/test_dfquad.py b/tests/gradual/test_dfquad.py similarity index 86% rename from tests/test_dfquad.py rename to tests/gradual/test_dfquad.py index c6a17e4..64c7603 100644 --- a/tests/test_dfquad.py +++ b/tests/gradual/test_dfquad.py @@ -1,81 +1,81 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.dfquad import ( - dfquad_aggregate, - dfquad_bipolar_strengths, - dfquad_combine, - dfquad_strengths, -) -from argumentation.gradual import WeightedBipolarGraph -from argumentation.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance - - -def test_dfquad_aggregate_bounds_attack_and_support_effects() -> None: - assert dfquad_aggregate(0.4, 0.5) == pytest.approx(0.7) - assert dfquad_aggregate(0.4, -0.5) == pytest.approx(0.2) - - -def test_dfquad_combine_uses_noisy_or_for_each_polarity() -> None: - assert dfquad_combine([0.5, 0.5], []) == pytest.approx(0.75) - assert dfquad_combine([], [0.5, 0.5]) == pytest.approx(-0.75) - - -def test_quad_strengths_are_support_sensitive() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset(), - ), - p_args={"a": 0.9, "b": 0.3}, - p_defeats={}, - supports=frozenset({("a", "b")}), - p_supports={("a", "b"): 0.8}, - ) - - graph = WeightedBipolarGraph( - arguments=praf.framework.arguments, - initial_weights={"a": 0.9, "b": 0.3}, - supports=praf.supports, - ) - strengths = dfquad_strengths( - graph, - base_scores={"a": 0.9, "b": 0.3}, - support_weights={("a", "b"): 0.8}, - ).strengths - - assert strengths["b"] > 0.3 - - -def test_dfquad_dispatch_requires_explicit_tau_for_quad_mode() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()), - p_args={"a": 0.8}, - p_defeats={}, - ) - - with pytest.raises(ValueError, match="tau"): - compute_probabilistic_acceptance(praf, strategy="dfquad_quad") - - -def test_baf_strengths_use_neutral_base_score() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ), - p_args={"a": 1.0, "b": 1.0}, - p_defeats={("a", "b"): 1.0}, - ) - - graph = WeightedBipolarGraph( - arguments=praf.framework.arguments, - initial_weights={argument: 0.5 for argument in praf.framework.arguments}, - attacks=praf.framework.defeats, - ) - strengths = dfquad_bipolar_strengths(graph).strengths - - assert strengths["a"] == pytest.approx(0.5) - assert strengths["b"] < 0.5 +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.gradual.dfquad import ( + dfquad_aggregate, + dfquad_bipolar_strengths, + dfquad_combine, + dfquad_strengths, +) +from argumentation.gradual.gradual import WeightedBipolarGraph +from argumentation.probabilistic.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance + + +def test_dfquad_aggregate_bounds_attack_and_support_effects() -> None: + assert dfquad_aggregate(0.4, 0.5) == pytest.approx(0.7) + assert dfquad_aggregate(0.4, -0.5) == pytest.approx(0.2) + + +def test_dfquad_combine_uses_noisy_or_for_each_polarity() -> None: + assert dfquad_combine([0.5, 0.5], []) == pytest.approx(0.75) + assert dfquad_combine([], [0.5, 0.5]) == pytest.approx(-0.75) + + +def test_quad_strengths_are_support_sensitive() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset(), + ), + p_args={"a": 0.9, "b": 0.3}, + p_defeats={}, + supports=frozenset({("a", "b")}), + p_supports={("a", "b"): 0.8}, + ) + + graph = WeightedBipolarGraph( + arguments=praf.framework.arguments, + initial_weights={"a": 0.9, "b": 0.3}, + supports=praf.supports, + ) + strengths = dfquad_strengths( + graph, + base_scores={"a": 0.9, "b": 0.3}, + support_weights={("a", "b"): 0.8}, + ).strengths + + assert strengths["b"] > 0.3 + + +def test_dfquad_dispatch_requires_explicit_tau_for_quad_mode() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()), + p_args={"a": 0.8}, + p_defeats={}, + ) + + with pytest.raises(ValueError, match="tau"): + compute_probabilistic_acceptance(praf, strategy="dfquad_quad") + + +def test_baf_strengths_use_neutral_base_score() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ), + p_args={"a": 1.0, "b": 1.0}, + p_defeats={("a", "b"): 1.0}, + ) + + graph = WeightedBipolarGraph( + arguments=praf.framework.arguments, + initial_weights={argument: 0.5 for argument in praf.framework.arguments}, + attacks=praf.framework.defeats, + ) + strengths = dfquad_bipolar_strengths(graph).strengths + + assert strengths["a"] == pytest.approx(0.5) + assert strengths["b"] < 0.5 diff --git a/tests/test_dfquad_baroni_2019_principles.py b/tests/gradual/test_dfquad_baroni_2019_principles.py similarity index 90% rename from tests/test_dfquad_baroni_2019_principles.py rename to tests/gradual/test_dfquad_baroni_2019_principles.py index a2b8c62..2ff5fcf 100644 --- a/tests/test_dfquad_baroni_2019_principles.py +++ b/tests/gradual/test_dfquad_baroni_2019_principles.py @@ -3,9 +3,9 @@ from hypothesis import given from hypothesis import strategies as st -from argumentation.dfquad import dfquad_strengths -from argumentation.gradual import WeightedBipolarGraph -from argumentation.gradual_principles import ( +from argumentation.gradual.dfquad import dfquad_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph +from argumentation.gradual.gradual_principles import ( PRINCIPLE_COMPLIANCE, ComplianceLabel, principle_balance, diff --git a/tests/test_dfquad_bipolar_attack_support_symmetry.py b/tests/gradual/test_dfquad_bipolar_attack_support_symmetry.py similarity index 86% rename from tests/test_dfquad_bipolar_attack_support_symmetry.py rename to tests/gradual/test_dfquad_bipolar_attack_support_symmetry.py index f34155b..c1fc303 100644 --- a/tests/test_dfquad_bipolar_attack_support_symmetry.py +++ b/tests/gradual/test_dfquad_bipolar_attack_support_symmetry.py @@ -2,8 +2,8 @@ import pytest -from argumentation.dfquad import dfquad_bipolar_strengths -from argumentation.gradual import WeightedBipolarGraph +from argumentation.gradual.dfquad import dfquad_bipolar_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph def test_bipolar_single_support_and_attack_are_symmetric_around_neutral() -> None: diff --git a/tests/test_dfquad_bipolar_strict_balance.py b/tests/gradual/test_dfquad_bipolar_strict_balance.py similarity index 88% rename from tests/test_dfquad_bipolar_strict_balance.py rename to tests/gradual/test_dfquad_bipolar_strict_balance.py index 742ccd3..4f99af4 100644 --- a/tests/test_dfquad_bipolar_strict_balance.py +++ b/tests/gradual/test_dfquad_bipolar_strict_balance.py @@ -2,8 +2,8 @@ import pytest -from argumentation.dfquad import dfquad_bipolar_strengths -from argumentation.gradual import WeightedBipolarGraph +from argumentation.gradual.dfquad import dfquad_bipolar_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph @pytest.mark.parametrize("pairs", [1, 5, 20, 100]) diff --git a/tests/test_dfquad_continuity_at_zero.py b/tests/gradual/test_dfquad_continuity_at_zero.py similarity index 89% rename from tests/test_dfquad_continuity_at_zero.py rename to tests/gradual/test_dfquad_continuity_at_zero.py index 12c5af7..facd818 100644 --- a/tests/test_dfquad_continuity_at_zero.py +++ b/tests/gradual/test_dfquad_continuity_at_zero.py @@ -3,8 +3,8 @@ import pytest from hypothesis import given, strategies as st -from argumentation.dfquad import dfquad_strengths -from argumentation.gradual import WeightedBipolarGraph +from argumentation.gradual.dfquad import dfquad_strengths +from argumentation.gradual.gradual import WeightedBipolarGraph @given( diff --git a/tests/test_gabbay_equational_fixpoint.py b/tests/gradual/test_gabbay_equational_fixpoint.py similarity index 84% rename from tests/test_gabbay_equational_fixpoint.py rename to tests/gradual/test_gabbay_equational_fixpoint.py index f5045b6..ad3da51 100644 --- a/tests/test_gabbay_equational_fixpoint.py +++ b/tests/gradual/test_gabbay_equational_fixpoint.py @@ -2,8 +2,8 @@ import pytest -from argumentation.equational import equational_fixpoint -from argumentation.gradual import WeightedBipolarGraph +from argumentation.gradual.equational import equational_fixpoint +from argumentation.gradual.gradual import WeightedBipolarGraph def test_eq_inverse_and_eq_max_on_simple_attack_chain() -> None: diff --git a/tests/test_gabbay_equational_relation_to_dung.py b/tests/gradual/test_gabbay_equational_relation_to_dung.py similarity index 75% rename from tests/test_gabbay_equational_relation_to_dung.py rename to tests/gradual/test_gabbay_equational_relation_to_dung.py index 69af5c0..25f25b3 100644 --- a/tests/test_gabbay_equational_relation_to_dung.py +++ b/tests/gradual/test_gabbay_equational_relation_to_dung.py @@ -1,27 +1,27 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework, complete_extensions -from argumentation.equational import equational_fixpoint -from argumentation.gradual import WeightedBipolarGraph - - -def test_eq_min_crisp_attack_chain_recovers_complete_extension_acceptance() -> None: - """Gabbay 2012, Argument & Computation, pp. 104-108, Dung relation.""" - - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - graph = WeightedBipolarGraph( - arguments=framework.arguments, - initial_weights={"a": 1.0, "b": 1.0}, - attacks=framework.defeats, - ) - - result = equational_fixpoint(graph, scheme="min") - - assert result.converged - assert complete_extensions(framework) == [frozenset({"a"})] - assert result.strengths == pytest.approx({"a": 1.0, "b": 0.0}) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework, complete_extensions +from argumentation.gradual.equational import equational_fixpoint +from argumentation.gradual.gradual import WeightedBipolarGraph + + +def test_eq_min_crisp_attack_chain_recovers_complete_extension_acceptance() -> None: + """Gabbay 2012, Argument & Computation, pp. 104-108, Dung relation.""" + + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + graph = WeightedBipolarGraph( + arguments=framework.arguments, + initial_weights={"a": 1.0, "b": 1.0}, + attacks=framework.defeats, + ) + + result = equational_fixpoint(graph, scheme="min") + + assert result.converged + assert complete_extensions(framework) == [frozenset({"a"})] + assert result.strengths == pytest.approx({"a": 1.0, "b": 0.0}) diff --git a/tests/test_gradual.py b/tests/gradual/test_gradual.py similarity index 95% rename from tests/test_gradual.py rename to tests/gradual/test_gradual.py index 7e921bd..166e888 100644 --- a/tests/test_gradual.py +++ b/tests/gradual/test_gradual.py @@ -2,8 +2,7 @@ import pytest -import argumentation -from argumentation.gradual import ( +from argumentation.gradual.gradual import ( WeightedBipolarGraph, quadratic_energy_strengths, revised_direct_impact, @@ -11,11 +10,6 @@ ) -def test_gradual_module_is_exported() -> None: - assert argumentation.gradual.WeightedBipolarGraph is WeightedBipolarGraph - assert "gradual" in argumentation.__all__ - - def test_quadratic_energy_keeps_isolated_argument_at_initial_weight() -> None: graph = WeightedBipolarGraph( arguments=frozenset({"a"}), diff --git a/tests/test_llm_surface.py b/tests/gradual/test_llm_surface.py similarity index 96% rename from tests/test_llm_surface.py rename to tests/gradual/test_llm_surface.py index ad681eb..61da0ac 100644 --- a/tests/test_llm_surface.py +++ b/tests/gradual/test_llm_surface.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.llm_surface import ( +from argumentation.gradual.llm_surface import ( build_qbaf_from_proposition_set, contest, explain_acceptance, diff --git a/tests/test_potyka_continuous_ode.py b/tests/gradual/test_potyka_continuous_ode.py similarity index 95% rename from tests/test_potyka_continuous_ode.py rename to tests/gradual/test_potyka_continuous_ode.py index c55c2f5..ceb9b51 100644 --- a/tests/test_potyka_continuous_ode.py +++ b/tests/gradual/test_potyka_continuous_ode.py @@ -2,7 +2,7 @@ import pytest -from argumentation.gradual import ( +from argumentation.gradual.gradual import ( WeightedBipolarGraph, quadratic_energy_strengths, quadratic_energy_strengths_continuous, diff --git a/tests/test_potyka_convergence_on_stiff_graphs.py b/tests/gradual/test_potyka_convergence_on_stiff_graphs.py similarity index 95% rename from tests/test_potyka_convergence_on_stiff_graphs.py rename to tests/gradual/test_potyka_convergence_on_stiff_graphs.py index d7cc0e7..8e4a490 100644 --- a/tests/test_potyka_convergence_on_stiff_graphs.py +++ b/tests/gradual/test_potyka_convergence_on_stiff_graphs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.gradual import ( +from argumentation.gradual.gradual import ( WeightedBipolarGraph, quadratic_energy_strengths_continuous, quadratic_energy_strengths_discrete, diff --git a/tests/test_sensitivity.py b/tests/gradual/test_sensitivity.py similarity index 96% rename from tests/test_sensitivity.py rename to tests/gradual/test_sensitivity.py index 2e99701..0f6289f 100644 --- a/tests/test_sensitivity.py +++ b/tests/gradual/test_sensitivity.py @@ -1,312 +1,312 @@ -"""Tests for argumentation-framework sensitivity / importance analysis. - -The hand-constructed cases use a small framework whose expected -sensitivity value is checked by hand in the accompanying comments. -The property-based cases generate random small frameworks and base-score -maps with ``hypothesis`` and assert invariants that hold by construction. -""" - -from __future__ import annotations - -import pytest -from hypothesis import example, given, settings -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework -from argumentation.sensitivity import attack_removal_sensitivity, score_conflict - - -# ── score_conflict ────────────────────────────────────────────────── - - -def test_score_conflict_pivotal_argument_is_one() -> None: - # a defeats b. grounded = {a}. - # remove a -> args {b}, grounded {b}; symdiff {a,b} = 2. - # remove b -> args {a}, grounded {a}; symdiff {} = 0. - # total = 2 -> min(1, 2/2) = 1.0. - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - assert score_conflict(framework, "a", "b") == pytest.approx(1.0) - - -def test_score_conflict_isolated_arguments_only_drop_themselves() -> None: - # three arguments, no defeats. grounded = {a,b,c}. - # removing a leaves {b,c}; symdiff = {a} = 1. likewise for b. - # total = 3 -> min(1, 1/3) = 1/3. - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset(), - ) - assert score_conflict(framework, "a", "b") == pytest.approx(1.0 / 3.0) - - -def test_score_conflict_empty_framework_is_zero() -> None: - framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - assert score_conflict(framework, "a", "b") == 0.0 - - -def test_score_conflict_rejects_unsupported_semantics() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a"}), - defeats=frozenset(), - ) - with pytest.raises(ValueError, match="Unsupported semantics"): - score_conflict(framework, "a", "a", semantics="preferred") - - -# ── attack_removal_sensitivity ────────────────────────────────────── - - -def test_attack_removal_sensitivity_recovers_suppressed_strength() -> None: - # a -> b, base scores 0.5 / 0.5, no supports. - # with attack: b influence = -(1 - (1-0.5)) = -0.5; - # dfquad_aggregate(0.5, -0.5) = 0.5 + (-0.5)*0.5 = 0.25. - # without attack: b strength = base 0.5. - # delta = 0.5 - 0.25 = 0.25. - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - base_scores = {"a": 0.5, "b": 0.5} - delta = attack_removal_sensitivity(framework, {}, base_scores, ("a", "b")) - assert delta == pytest.approx(0.25) - - -def test_attack_removal_sensitivity_absent_attack_is_zero() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - base_scores = {"a": 0.5, "b": 0.5} - # ("b", "a") is not a defeat of the framework. - assert attack_removal_sensitivity(framework, {}, base_scores, ("b", "a")) == 0.0 - - -def test_attack_removal_sensitivity_targets_only_the_attacked_argument() -> None: - # a -> b with an unrelated c. base 0.4 / 0.6 / 0.7. - # with attack: b influence = -(1 - (1-0.4)) = -0.4; - # dfquad_aggregate(0.6, -0.4) = 0.6 + (-0.4)*0.6 = 0.36. - # without attack: b strength = base 0.6. - # delta for target b = 0.6 - 0.36 = 0.24; c never enters. - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b")}), - ) - base_scores = {"a": 0.4, "b": 0.6, "c": 0.7} - delta = attack_removal_sensitivity(framework, {}, base_scores, ("a", "b")) - assert delta == pytest.approx(0.24) - - -# ── Property-based tests ──────────────────────────────────────────── -# -# Generators produce VALID inputs only: defeats range over declared -# arguments, base_scores covers exactly the arguments, support weights -# lie in [0, 1], and supports never overlap attacks. A property test -# that crashed on its own malformed input would prove nothing. - -_PROP_SETTINGS = settings(deadline=None) - - -@st.composite -def frameworks(draw, max_args: int = 6): - """Draw a small ArgumentationFramework over a..f with defeats on declared args.""" - args = draw( - st.frozensets( - st.text(alphabet="abcdef", min_size=1, max_size=2), - min_size=1, - max_size=max_args, - ) - ) - arg_list = sorted(args) - defeats = draw( - st.frozensets( - st.tuples(st.sampled_from(arg_list), st.sampled_from(arg_list)), - max_size=len(arg_list) ** 2, - ) - ) - return ArgumentationFramework(arguments=args, defeats=defeats) - - -@st.composite -def framework_with_scored_attacks(draw, max_args: int = 6): - """Draw a framework, a base-score map covering exactly its arguments, - in-range support weights over support edges disjoint from the defeats, - and a candidate attack to remove. - - Returns ``(framework, supports, base_scores, attack)``. The candidate - attack is a real defeat about half the time and a non-defeat the other - half, so absent-attack and present-attack behaviour are both exercised. - """ - framework = draw(frameworks(max_args=max_args)) - arg_list = sorted(framework.arguments) - - base_scores = { - arg: draw( - st.floats( - min_value=0.0, - max_value=1.0, - allow_nan=False, - allow_infinity=False, - ) - ) - for arg in arg_list - } - - all_pairs = [(s, t) for s in arg_list for t in arg_list] - support_candidates = [p for p in all_pairs if p not in framework.defeats] - support_edges = ( - draw( - st.frozensets( - st.sampled_from(support_candidates), - max_size=len(support_candidates), - ) - ) - if support_candidates - else frozenset() - ) - supports = { - edge: draw( - st.floats( - min_value=0.0, - max_value=1.0, - allow_nan=False, - allow_infinity=False, - ) - ) - for edge in support_edges - } - - defeat_list = sorted(framework.defeats) - if defeat_list and draw(st.booleans()): - attack = draw(st.sampled_from(defeat_list)) - else: - attack = draw(st.sampled_from(all_pairs)) - - return framework, supports, base_scores, attack - - -# ── score_conflict ────────────────────────────────────────────────── - - -class TestScoreConflictProperties: - """Invariants of ``score_conflict`` that hold by construction.""" - - pytestmark = pytest.mark.property - - @given(framework=frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_result_is_within_unit_interval(self, framework, data) -> None: - """The returned swing score is always clamped into ``[0.0, 1.0]``.""" - arg_list = sorted(framework.arguments) - claim_a = data.draw(st.sampled_from(arg_list)) - claim_b = data.draw(st.sampled_from(arg_list)) - score = score_conflict(framework, claim_a, claim_b) - assert 0.0 <= score <= 1.0 - - @given(framework=frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_symmetric_in_the_two_claim_arguments(self, framework, data) -> None: - """``score_conflict(f, a, b) == score_conflict(f, b, a)``.""" - arg_list = sorted(framework.arguments) - claim_a = data.draw(st.sampled_from(arg_list)) - claim_b = data.draw(st.sampled_from(arg_list)) - assert score_conflict(framework, claim_a, claim_b) == score_conflict( - framework, claim_b, claim_a - ) - - @given(framework=frameworks(), data=st.data()) - @_PROP_SETTINGS - def test_deterministic(self, framework, data) -> None: - """Same inputs yield the same output on repeated calls.""" - arg_list = sorted(framework.arguments) - claim_a = data.draw(st.sampled_from(arg_list)) - claim_b = data.draw(st.sampled_from(arg_list)) - first = score_conflict(framework, claim_a, claim_b) - second = score_conflict(framework, claim_a, claim_b) - assert first == second - - -# ── attack_removal_sensitivity ────────────────────────────────────── - - -class TestAttackRemovalSensitivityProperties: - """Invariants of ``attack_removal_sensitivity``.""" - - pytestmark = pytest.mark.property - - @given(framework_with_scored_attacks()) - @_PROP_SETTINGS - def test_absent_attack_returns_exactly_zero(self, scenario) -> None: - """An attack not in ``framework.defeats`` returns exactly ``0.0``.""" - framework, supports, base_scores, attack = scenario - if attack not in framework.defeats: - assert ( - attack_removal_sensitivity(framework, supports, base_scores, attack) - == 0.0 - ) - - @given(framework_with_scored_attacks()) - @_PROP_SETTINGS - def test_delta_is_finite_and_bounded(self, scenario) -> None: - """DF-QuAD strengths lie in ``[0, 1]``, so the delta lies in ``[-1, 1]``.""" - framework, supports, base_scores, attack = scenario - delta = attack_removal_sensitivity(framework, supports, base_scores, attack) - assert delta == delta # not NaN - assert -1.0 <= delta <= 1.0 - - @given(framework_with_scored_attacks()) - @_PROP_SETTINGS - def test_deterministic(self, scenario) -> None: - """Same inputs yield the same delta on repeated calls.""" - framework, supports, base_scores, attack = scenario - first = attack_removal_sensitivity(framework, supports, base_scores, attack) - second = attack_removal_sensitivity(framework, supports, base_scores, attack) - assert first == second - - @pytest.mark.xfail( - reason=( - "Plausible-but-unproven, and FALSIFIED by hypothesis: removing an " - "attack should not DECREASE the target's DF-QuAD strength. The minimal " - "counterexample is a SELF-ATTACK -- args {a, b}, defeats all four " - "directed pairs, base {a: 0.5, b: 0.9375}, remove (a, a). The target " - "of the removed attack is 'a' itself; 'a' is also attacked by 'b', so " - "the DF-QuAD fixed point couples the two incoming attacks. Removing " - "(a, a) shifts the joint fixed point and 'a' ends ~0.068 LOWER, not " - "higher. The docstring's 'removing an attack normally raises the " - "target' holds only in isolation; cyclic/coupled attacks flip the " - "sign. See reports/argumentation-sensitivity-proptests.md." - ), - strict=True, - ) - @given(framework_with_scored_attacks()) - @example( - # The minimal counterexample hypothesis found, pinned so the xfail - # is deterministic rather than dependent on the random search budget. - scenario=( - ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "a"), ("a", "b"), ("b", "a"), ("b", "b")}), - ), - {}, - {"a": 0.5, "b": 0.9375}, - ("a", "a"), - ), - ) - @_PROP_SETTINGS - def test_removing_attack_does_not_decrease_target_strength(self, scenario) -> None: - """Removing an attack should raise (not lower) the target's strength. - - This is the documented ``xfail``: the docstring of - ``attack_removal_sensitivity`` says removing an attack "normally - raises the target's strength", and the delta is - ``strength_reduced - strength_full``. The conjecture under test is - that the delta is therefore ``>= 0`` for every valid input. - hypothesis falsifies it -- a self-attack on the target couples the - target's incoming attacks through the DF-QuAD fixed point, and - removing one can lower the target's strength. - """ - framework, supports, base_scores, attack = scenario - delta = attack_removal_sensitivity(framework, supports, base_scores, attack) - assert delta >= -1e-9 +"""Tests for argumentation-framework sensitivity / importance analysis. + +The hand-constructed cases use a small framework whose expected +sensitivity value is checked by hand in the accompanying comments. +The property-based cases generate random small frameworks and base-score +maps with ``hypothesis`` and assert invariants that hold by construction. +""" + +from __future__ import annotations + +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework +from argumentation.gradual.sensitivity import attack_removal_sensitivity, score_conflict + + +# ── score_conflict ────────────────────────────────────────────────── + + +def test_score_conflict_pivotal_argument_is_one() -> None: + # a defeats b. grounded = {a}. + # remove a -> args {b}, grounded {b}; symdiff {a,b} = 2. + # remove b -> args {a}, grounded {a}; symdiff {} = 0. + # total = 2 -> min(1, 2/2) = 1.0. + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + assert score_conflict(framework, "a", "b") == pytest.approx(1.0) + + +def test_score_conflict_isolated_arguments_only_drop_themselves() -> None: + # three arguments, no defeats. grounded = {a,b,c}. + # removing a leaves {b,c}; symdiff = {a} = 1. likewise for b. + # total = 3 -> min(1, 1/3) = 1/3. + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset(), + ) + assert score_conflict(framework, "a", "b") == pytest.approx(1.0 / 3.0) + + +def test_score_conflict_empty_framework_is_zero() -> None: + framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + assert score_conflict(framework, "a", "b") == 0.0 + + +def test_score_conflict_rejects_unsupported_semantics() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a"}), + defeats=frozenset(), + ) + with pytest.raises(ValueError, match="Unsupported semantics"): + score_conflict(framework, "a", "a", semantics="preferred") + + +# ── attack_removal_sensitivity ────────────────────────────────────── + + +def test_attack_removal_sensitivity_recovers_suppressed_strength() -> None: + # a -> b, base scores 0.5 / 0.5, no supports. + # with attack: b influence = -(1 - (1-0.5)) = -0.5; + # dfquad_aggregate(0.5, -0.5) = 0.5 + (-0.5)*0.5 = 0.25. + # without attack: b strength = base 0.5. + # delta = 0.5 - 0.25 = 0.25. + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + base_scores = {"a": 0.5, "b": 0.5} + delta = attack_removal_sensitivity(framework, {}, base_scores, ("a", "b")) + assert delta == pytest.approx(0.25) + + +def test_attack_removal_sensitivity_absent_attack_is_zero() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + base_scores = {"a": 0.5, "b": 0.5} + # ("b", "a") is not a defeat of the framework. + assert attack_removal_sensitivity(framework, {}, base_scores, ("b", "a")) == 0.0 + + +def test_attack_removal_sensitivity_targets_only_the_attacked_argument() -> None: + # a -> b with an unrelated c. base 0.4 / 0.6 / 0.7. + # with attack: b influence = -(1 - (1-0.4)) = -0.4; + # dfquad_aggregate(0.6, -0.4) = 0.6 + (-0.4)*0.6 = 0.36. + # without attack: b strength = base 0.6. + # delta for target b = 0.6 - 0.36 = 0.24; c never enters. + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b")}), + ) + base_scores = {"a": 0.4, "b": 0.6, "c": 0.7} + delta = attack_removal_sensitivity(framework, {}, base_scores, ("a", "b")) + assert delta == pytest.approx(0.24) + + +# ── Property-based tests ──────────────────────────────────────────── +# +# Generators produce VALID inputs only: defeats range over declared +# arguments, base_scores covers exactly the arguments, support weights +# lie in [0, 1], and supports never overlap attacks. A property test +# that crashed on its own malformed input would prove nothing. + +_PROP_SETTINGS = settings(deadline=None) + + +@st.composite +def frameworks(draw, max_args: int = 6): + """Draw a small ArgumentationFramework over a..f with defeats on declared args.""" + args = draw( + st.frozensets( + st.text(alphabet="abcdef", min_size=1, max_size=2), + min_size=1, + max_size=max_args, + ) + ) + arg_list = sorted(args) + defeats = draw( + st.frozensets( + st.tuples(st.sampled_from(arg_list), st.sampled_from(arg_list)), + max_size=len(arg_list) ** 2, + ) + ) + return ArgumentationFramework(arguments=args, defeats=defeats) + + +@st.composite +def framework_with_scored_attacks(draw, max_args: int = 6): + """Draw a framework, a base-score map covering exactly its arguments, + in-range support weights over support edges disjoint from the defeats, + and a candidate attack to remove. + + Returns ``(framework, supports, base_scores, attack)``. The candidate + attack is a real defeat about half the time and a non-defeat the other + half, so absent-attack and present-attack behaviour are both exercised. + """ + framework = draw(frameworks(max_args=max_args)) + arg_list = sorted(framework.arguments) + + base_scores = { + arg: draw( + st.floats( + min_value=0.0, + max_value=1.0, + allow_nan=False, + allow_infinity=False, + ) + ) + for arg in arg_list + } + + all_pairs = [(s, t) for s in arg_list for t in arg_list] + support_candidates = [p for p in all_pairs if p not in framework.defeats] + support_edges = ( + draw( + st.frozensets( + st.sampled_from(support_candidates), + max_size=len(support_candidates), + ) + ) + if support_candidates + else frozenset() + ) + supports = { + edge: draw( + st.floats( + min_value=0.0, + max_value=1.0, + allow_nan=False, + allow_infinity=False, + ) + ) + for edge in support_edges + } + + defeat_list = sorted(framework.defeats) + if defeat_list and draw(st.booleans()): + attack = draw(st.sampled_from(defeat_list)) + else: + attack = draw(st.sampled_from(all_pairs)) + + return framework, supports, base_scores, attack + + +# ── score_conflict ────────────────────────────────────────────────── + + +class TestScoreConflictProperties: + """Invariants of ``score_conflict`` that hold by construction.""" + + pytestmark = pytest.mark.property + + @given(framework=frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_result_is_within_unit_interval(self, framework, data) -> None: + """The returned swing score is always clamped into ``[0.0, 1.0]``.""" + arg_list = sorted(framework.arguments) + claim_a = data.draw(st.sampled_from(arg_list)) + claim_b = data.draw(st.sampled_from(arg_list)) + score = score_conflict(framework, claim_a, claim_b) + assert 0.0 <= score <= 1.0 + + @given(framework=frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_symmetric_in_the_two_claim_arguments(self, framework, data) -> None: + """``score_conflict(f, a, b) == score_conflict(f, b, a)``.""" + arg_list = sorted(framework.arguments) + claim_a = data.draw(st.sampled_from(arg_list)) + claim_b = data.draw(st.sampled_from(arg_list)) + assert score_conflict(framework, claim_a, claim_b) == score_conflict( + framework, claim_b, claim_a + ) + + @given(framework=frameworks(), data=st.data()) + @_PROP_SETTINGS + def test_deterministic(self, framework, data) -> None: + """Same inputs yield the same output on repeated calls.""" + arg_list = sorted(framework.arguments) + claim_a = data.draw(st.sampled_from(arg_list)) + claim_b = data.draw(st.sampled_from(arg_list)) + first = score_conflict(framework, claim_a, claim_b) + second = score_conflict(framework, claim_a, claim_b) + assert first == second + + +# ── attack_removal_sensitivity ────────────────────────────────────── + + +class TestAttackRemovalSensitivityProperties: + """Invariants of ``attack_removal_sensitivity``.""" + + pytestmark = pytest.mark.property + + @given(framework_with_scored_attacks()) + @_PROP_SETTINGS + def test_absent_attack_returns_exactly_zero(self, scenario) -> None: + """An attack not in ``framework.defeats`` returns exactly ``0.0``.""" + framework, supports, base_scores, attack = scenario + if attack not in framework.defeats: + assert ( + attack_removal_sensitivity(framework, supports, base_scores, attack) + == 0.0 + ) + + @given(framework_with_scored_attacks()) + @_PROP_SETTINGS + def test_delta_is_finite_and_bounded(self, scenario) -> None: + """DF-QuAD strengths lie in ``[0, 1]``, so the delta lies in ``[-1, 1]``.""" + framework, supports, base_scores, attack = scenario + delta = attack_removal_sensitivity(framework, supports, base_scores, attack) + assert delta == delta # not NaN + assert -1.0 <= delta <= 1.0 + + @given(framework_with_scored_attacks()) + @_PROP_SETTINGS + def test_deterministic(self, scenario) -> None: + """Same inputs yield the same delta on repeated calls.""" + framework, supports, base_scores, attack = scenario + first = attack_removal_sensitivity(framework, supports, base_scores, attack) + second = attack_removal_sensitivity(framework, supports, base_scores, attack) + assert first == second + + @pytest.mark.xfail( + reason=( + "Plausible-but-unproven, and FALSIFIED by hypothesis: removing an " + "attack should not DECREASE the target's DF-QuAD strength. The minimal " + "counterexample is a SELF-ATTACK -- args {a, b}, defeats all four " + "directed pairs, base {a: 0.5, b: 0.9375}, remove (a, a). The target " + "of the removed attack is 'a' itself; 'a' is also attacked by 'b', so " + "the DF-QuAD fixed point couples the two incoming attacks. Removing " + "(a, a) shifts the joint fixed point and 'a' ends ~0.068 LOWER, not " + "higher. The docstring's 'removing an attack normally raises the " + "target' holds only in isolation; cyclic/coupled attacks flip the " + "sign. See reports/argumentation-sensitivity-proptests.md." + ), + strict=True, + ) + @given(framework_with_scored_attacks()) + @example( + # The minimal counterexample hypothesis found, pinned so the xfail + # is deterministic rather than dependent on the random search budget. + scenario=( + ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "a"), ("a", "b"), ("b", "a"), ("b", "b")}), + ), + {}, + {"a": 0.5, "b": 0.9375}, + ("a", "a"), + ), + ) + @_PROP_SETTINGS + def test_removing_attack_does_not_decrease_target_strength(self, scenario) -> None: + """Removing an attack should raise (not lower) the target's strength. + + This is the documented ``xfail``: the docstring of + ``attack_removal_sensitivity`` says removing an attack "normally + raises the target's strength", and the delta is + ``strength_reduced - strength_full``. The conjecture under test is + that the delta is therefore ``>= 0`` for every valid input. + hypothesis falsifies it -- a self-attack on the target couples the + target's incoming attacks through the DF-QuAD fixed point, and + removing one can lower the target's strength. + """ + framework, supports, base_scores, attack = scenario + delta = attack_removal_sensitivity(framework, supports, base_scores, attack) + assert delta >= -1e-9 diff --git a/tests/interop/__init__.py b/tests/interop/__init__.py new file mode 100644 index 0000000..da78e33 --- /dev/null +++ b/tests/interop/__init__.py @@ -0,0 +1 @@ +"""Tests for the interop layer.""" diff --git a/tests/test_iccma.py b/tests/interop/test_iccma.py similarity index 92% rename from tests/test_iccma.py rename to tests/interop/test_iccma.py index 9cd4a6f..100f2a3 100644 --- a/tests/test_iccma.py +++ b/tests/interop/test_iccma.py @@ -1,111 +1,111 @@ -from __future__ import annotations - -from hypothesis import given, settings -from hypothesis import strategies as st -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.iccma import parse_af, write_af - - -def test_parse_af_reads_iccma_2023_numeric_format() -> None: - framework = parse_af( - """ - # comments are ignored - p af 5 - 1 2 - 2 4 - 4 5 - 5 4 - 5 5 - """ - ) - - assert framework == ArgumentationFramework( - arguments=frozenset({"1", "2", "3", "4", "5"}), - defeats=frozenset({ - ("1", "2"), - ("2", "4"), - ("4", "5"), - ("5", "4"), - ("5", "5"), - }), - ) - - -@st.composite -def numeric_afs(draw: st.DrawFn) -> ArgumentationFramework: - size = draw(st.integers(min_value=0, max_value=5)) - arguments = frozenset(str(index) for index in range(1, size + 1)) - possible_attacks = [ - (attacker, target) - for attacker in sorted(arguments, key=int) - for target in sorted(arguments, key=int) - ] - attack_strategy = ( - st.just(set()) - if not possible_attacks - else st.sets(st.sampled_from(possible_attacks), max_size=10) - ) - return ArgumentationFramework(arguments=arguments, defeats=frozenset(draw(attack_strategy))) - - -def test_write_af_emits_deterministic_iccma_format() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"1", "2", "3"}), - defeats=frozenset({("2", "3"), ("1", "2")}), - ) - - assert write_af(framework) == "p af 3\n1 2\n2 3\n" - - -def test_parse_then_write_round_trip() -> None: - text = "p af 3\n1 2\n2 3\n" - - assert write_af(parse_af(text)) == text - - -@given(numeric_afs()) -@settings(max_examples=100) -def test_iccma_numeric_af_round_trip_preserves_contiguous_ids( - framework: ArgumentationFramework, -) -> None: - assert parse_af(write_af(framework)) == framework - - -def test_parse_af_allows_comments_only_around_official_lines() -> None: - framework = parse_af("# first\np af 2\n# attack follows\n1 2\n") - - assert framework == ArgumentationFramework( - arguments=frozenset({"1", "2"}), - defeats=frozenset({("1", "2")}), - ) - - -def test_parse_af_rejects_non_numeric_or_out_of_range_attacks() -> None: - with pytest.raises(ValueError, match="p af"): - parse_af("1 2\n") - with pytest.raises(ValueError, match="attack"): - parse_af("p af 1\n1 2\n") - with pytest.raises(ValueError, match="numeric"): - parse_af("p af 2\na b\n") - - -def test_write_af_rejects_non_iccma_argument_ids() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a"}), - defeats=frozenset(), - ) - - with pytest.raises(ValueError, match="numeric"): - write_af(framework) - - -def test_write_af_rejects_non_contiguous_numeric_ids() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"1", "3"}), - defeats=frozenset(), - ) - - with pytest.raises(ValueError, match="1..n"): - write_af(framework) +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.interop.iccma import parse_af, write_af + + +def test_parse_af_reads_iccma_2023_numeric_format() -> None: + framework = parse_af( + """ + # comments are ignored + p af 5 + 1 2 + 2 4 + 4 5 + 5 4 + 5 5 + """ + ) + + assert framework == ArgumentationFramework( + arguments=frozenset({"1", "2", "3", "4", "5"}), + defeats=frozenset({ + ("1", "2"), + ("2", "4"), + ("4", "5"), + ("5", "4"), + ("5", "5"), + }), + ) + + +@st.composite +def numeric_afs(draw: st.DrawFn) -> ArgumentationFramework: + size = draw(st.integers(min_value=0, max_value=5)) + arguments = frozenset(str(index) for index in range(1, size + 1)) + possible_attacks = [ + (attacker, target) + for attacker in sorted(arguments, key=int) + for target in sorted(arguments, key=int) + ] + attack_strategy = ( + st.just(set()) + if not possible_attacks + else st.sets(st.sampled_from(possible_attacks), max_size=10) + ) + return ArgumentationFramework(arguments=arguments, defeats=frozenset(draw(attack_strategy))) + + +def test_write_af_emits_deterministic_iccma_format() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"1", "2", "3"}), + defeats=frozenset({("2", "3"), ("1", "2")}), + ) + + assert write_af(framework) == "p af 3\n1 2\n2 3\n" + + +def test_parse_then_write_round_trip() -> None: + text = "p af 3\n1 2\n2 3\n" + + assert write_af(parse_af(text)) == text + + +@given(numeric_afs()) +@settings(max_examples=100) +def test_iccma_numeric_af_round_trip_preserves_contiguous_ids( + framework: ArgumentationFramework, +) -> None: + assert parse_af(write_af(framework)) == framework + + +def test_parse_af_allows_comments_only_around_official_lines() -> None: + framework = parse_af("# first\np af 2\n# attack follows\n1 2\n") + + assert framework == ArgumentationFramework( + arguments=frozenset({"1", "2"}), + defeats=frozenset({("1", "2")}), + ) + + +def test_parse_af_rejects_non_numeric_or_out_of_range_attacks() -> None: + with pytest.raises(ValueError, match="p af"): + parse_af("1 2\n") + with pytest.raises(ValueError, match="attack"): + parse_af("p af 1\n1 2\n") + with pytest.raises(ValueError, match="numeric"): + parse_af("p af 2\na b\n") + + +def test_write_af_rejects_non_iccma_argument_ids() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a"}), + defeats=frozenset(), + ) + + with pytest.raises(ValueError, match="numeric"): + write_af(framework) + + +def test_write_af_rejects_non_contiguous_numeric_ids() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"1", "3"}), + defeats=frozenset(), + ) + + with pytest.raises(ValueError, match="1..n"): + write_af(framework) diff --git a/tests/test_iccma_post_router_workstream.py b/tests/interop/test_iccma_post_router_workstream.py similarity index 100% rename from tests/test_iccma_post_router_workstream.py rename to tests/interop/test_iccma_post_router_workstream.py diff --git a/tests/test_iccma_range_trace_compare.py b/tests/interop/test_iccma_range_trace_compare.py similarity index 100% rename from tests/test_iccma_range_trace_compare.py rename to tests/interop/test_iccma_range_trace_compare.py diff --git a/tests/test_iccma_run_selected.py b/tests/interop/test_iccma_run_selected.py similarity index 100% rename from tests/test_iccma_run_selected.py rename to tests/interop/test_iccma_run_selected.py diff --git a/tests/test_iccma_run_timeout_rows.py b/tests/interop/test_iccma_run_timeout_rows.py similarity index 99% rename from tests/test_iccma_run_timeout_rows.py rename to tests/interop/test_iccma_run_timeout_rows.py index d7d496d..7ab3366 100644 --- a/tests/test_iccma_run_timeout_rows.py +++ b/tests/interop/test_iccma_run_timeout_rows.py @@ -13,7 +13,7 @@ from tools.iccma_timeout_corpus import summarize_timeout_rows -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] CAP150_MANIFEST = ROOT / "tests" / "manifests" / "iccma2025-cap150-timeouts.json" CAP200_MANIFEST = ROOT / "tests" / "manifests" / "iccma2025-cap200-timeouts.json" ICCMA_2025_INPUT_ROOT = ROOT / "data" / "iccma" / "2025" / "extracted" / "instances" diff --git a/tests/test_iccma_runner.py b/tests/interop/test_iccma_runner.py similarity index 97% rename from tests/test_iccma_runner.py rename to tests/interop/test_iccma_runner.py index d7358ab..9860f59 100644 --- a/tests/test_iccma_runner.py +++ b/tests/interop/test_iccma_runner.py @@ -431,7 +431,7 @@ def test_write_csv_accepts_profiled_rows(tmp_path) -> None: def test_solve_aba_job_passes_clingo_diagnostics_to_single_extension( tmp_path, monkeypatch ) -> None: - from argumentation.solver import SingleExtensionSolverSuccess + from argumentation.solving.solver import SingleExtensionSolverSuccess instance_path = tmp_path / "extracted" / "instances" / "case.aba" instance_path.parent.mkdir(parents=True) @@ -439,7 +439,7 @@ def test_solve_aba_job_passes_clingo_diagnostics_to_single_extension( framework = object() captured = {} - monkeypatch.setattr("argumentation.iccma.parse_aba", lambda text: framework) + monkeypatch.setattr("argumentation.interop.iccma.parse_aba", lambda text: framework) def fake_solve(framework_arg, **kwargs): captured["framework"] = framework_arg @@ -453,7 +453,7 @@ def fake_solve(framework_arg, **kwargs): }, ) - monkeypatch.setattr("argumentation.solver.solve_aba_single_extension", fake_solve) + monkeypatch.setattr("argumentation.solving.solver.solve_aba_single_extension", fake_solve) result = solve_aba_job( { @@ -487,14 +487,14 @@ def fake_solve(framework_arg, **kwargs): def test_solve_aba_job_defaults_clingo_diagnostics_to_disabled(tmp_path, monkeypatch) -> None: - from argumentation.solver import SingleExtensionSolverSuccess + from argumentation.solving.solver import SingleExtensionSolverSuccess instance_path = tmp_path / "extracted" / "instances" / "case.aba" instance_path.parent.mkdir(parents=True) instance_path.write_text("ignored by parser monkeypatch\n", encoding="utf-8") captured = {} - monkeypatch.setattr("argumentation.iccma.parse_aba", lambda text: object()) + monkeypatch.setattr("argumentation.interop.iccma.parse_aba", lambda text: object()) def fake_solve(framework_arg, **kwargs): captured["kwargs"] = kwargs @@ -506,7 +506,7 @@ def fake_solve(framework_arg, **kwargs): }, ) - monkeypatch.setattr("argumentation.solver.solve_aba_single_extension", fake_solve) + monkeypatch.setattr("argumentation.solving.solver.solve_aba_single_extension", fake_solve) solve_aba_job( { diff --git a/tests/test_iccma_runner_timeout_contract.py b/tests/interop/test_iccma_runner_timeout_contract.py similarity index 100% rename from tests/test_iccma_runner_timeout_contract.py rename to tests/interop/test_iccma_runner_timeout_contract.py diff --git a/tests/test_iccma_timeout_corpus.py b/tests/interop/test_iccma_timeout_corpus.py similarity index 99% rename from tests/test_iccma_timeout_corpus.py rename to tests/interop/test_iccma_timeout_corpus.py index 6bd11c6..5ea0550 100644 --- a/tests/test_iccma_timeout_corpus.py +++ b/tests/interop/test_iccma_timeout_corpus.py @@ -7,7 +7,7 @@ from tools.iccma_timeout_corpus import collect_timeout_rows, summarize_timeout_rows -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] FIELDS = [ diff --git a/tests/test_iccma_trace_classify.py b/tests/interop/test_iccma_trace_classify.py similarity index 100% rename from tests/test_iccma_trace_classify.py rename to tests/interop/test_iccma_trace_classify.py diff --git a/tests/probabilistic/__init__.py b/tests/probabilistic/__init__.py new file mode 100644 index 0000000..f82b4d7 --- /dev/null +++ b/tests/probabilistic/__init__.py @@ -0,0 +1 @@ +"""Tests for the probabilistic layer.""" diff --git a/tests/test_epistemic.py b/tests/probabilistic/test_epistemic.py similarity index 97% rename from tests/test_epistemic.py rename to tests/probabilistic/test_epistemic.py index cf75c2f..29e303c 100644 --- a/tests/test_epistemic.py +++ b/tests/probabilistic/test_epistemic.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.epistemic import ( +from argumentation.probabilistic.epistemic import ( BeliefConstraint, EpistemicGraph, Influence, diff --git a/tests/test_epistemic_language.py b/tests/probabilistic/test_epistemic_language.py similarity index 98% rename from tests/test_epistemic_language.py rename to tests/probabilistic/test_epistemic_language.py index 81ef12b..275f24e 100644 --- a/tests/test_epistemic_language.py +++ b/tests/probabilistic/test_epistemic_language.py @@ -4,7 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.epistemic import ( +from argumentation.probabilistic.epistemic import ( EpistemicAtom, OperationalFormula, ProbabilityFunction, diff --git a/tests/test_epistemic_lp.py b/tests/probabilistic/test_epistemic_lp.py similarity index 98% rename from tests/test_epistemic_lp.py rename to tests/probabilistic/test_epistemic_lp.py index 823cbe9..98f90d2 100644 --- a/tests/test_epistemic_lp.py +++ b/tests/probabilistic/test_epistemic_lp.py @@ -4,7 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.epistemic import ( +from argumentation.probabilistic.epistemic import ( EpistemicLabel, LabelledArc, LabelledEpistemicGraph, diff --git a/tests/test_epistemic_probability.py b/tests/probabilistic/test_epistemic_probability.py similarity index 98% rename from tests/test_epistemic_probability.py rename to tests/probabilistic/test_epistemic_probability.py index fa96aa3..5e82322 100644 --- a/tests/test_epistemic_probability.py +++ b/tests/probabilistic/test_epistemic_probability.py @@ -4,7 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.epistemic import ( +from argumentation.probabilistic.epistemic import ( ProbabilityFunction, induced_probability_labelling, possible_worlds, diff --git a/tests/test_probabilistic.py b/tests/probabilistic/test_probabilistic.py similarity index 94% rename from tests/test_probabilistic.py rename to tests/probabilistic/test_probabilistic.py index 5a093f4..37375a5 100644 --- a/tests/test_probabilistic.py +++ b/tests/probabilistic/test_probabilistic.py @@ -1,136 +1,136 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.probabilistic import ( - ProbabilisticAF, - _z_for_confidence, - compute_probabilistic_acceptance, - summarize_defeat_relations, -) - - -def test_deterministic_probabilistic_af_matches_grounded_extension() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - praf = ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0, "c": 1.0}, - p_defeats={("a", "b"): 1.0, ("b", "c"): 1.0}, - ) - - result = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="deterministic", - ) - - assert result.acceptance_probs == {"a": 1.0, "b": 0.0, "c": 1.0} - assert result.strategy_used == "deterministic" - - -def test_exact_enum_extension_probability_query() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - praf = ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0}, - p_defeats={("a", "b"): 0.5}, - ) - - result = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="exact_enum", - query_kind="extension_probability", - queried_set={"a"}, - ) - - assert result.extension_probability == pytest.approx(0.5) - - -def test_probabilities_are_plain_bounded_floats() -> None: - framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) - - with pytest.raises(ValueError, match="p_args"): - ProbabilisticAF(framework=framework, p_args={"a": 1.2}, p_defeats={}) - - -def test_p_args_must_match_framework_arguments() -> None: - framework = ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()) - - with pytest.raises(ValueError, match="p_args"): - ProbabilisticAF(framework=framework, p_args={"a": 1.0}, p_defeats={}) - - with pytest.raises(ValueError, match="p_args"): - ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0, "c": 1.0}, - p_defeats={}, - ) - - -def test_probabilistic_relation_probabilities_must_reference_declared_relations() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - with pytest.raises(ValueError, match="p_defeats"): - ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0}, - p_defeats={("b", "a"): 0.5}, - ) - - with pytest.raises(ValueError, match="p_supports"): - ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0}, - p_defeats={("a", "b"): 1.0}, - supports=frozenset(), - p_supports={("a", "b"): 0.5}, - ) - - -def test_probabilistic_supports_must_reference_declared_arguments() -> None: - framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) - - with pytest.raises(ValueError, match="supports"): - ProbabilisticAF( - framework=framework, - p_args={"a": 1.0}, - p_defeats={}, - supports=frozenset({("a", "b")}), - ) - - -def test_summarize_defeat_relations_returns_exact_marginals() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - praf = ProbabilisticAF( - framework=framework, - p_args={"a": 1.0, "b": 1.0}, - p_defeats={("a", "b"): 0.25}, - ) - - assert summarize_defeat_relations(praf) == {("a", "b"): pytest.approx(0.25)} - - -def test_ws_o_arg_z_for_confidence_accepts_continuous_confidence_values() -> None: - """Bug 8: Monte Carlo confidence should not be limited to three dict keys.""" - assert _z_for_confidence(0.975) == pytest.approx(2.2414027276) - assert _z_for_confidence(0.999) == pytest.approx(3.2905267315) - - -@pytest.mark.parametrize("confidence", [0.0, 1.0, -0.1, 1.1]) -def test_ws_o_arg_z_for_confidence_rejects_out_of_range_values(confidence: float) -> None: - with pytest.raises(ValueError, match="mc_confidence"): - _z_for_confidence(confidence) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.probabilistic.probabilistic import ( + ProbabilisticAF, + _z_for_confidence, + compute_probabilistic_acceptance, + summarize_defeat_relations, +) + + +def test_deterministic_probabilistic_af_matches_grounded_extension() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + praf = ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0, "c": 1.0}, + p_defeats={("a", "b"): 1.0, ("b", "c"): 1.0}, + ) + + result = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="deterministic", + ) + + assert result.acceptance_probs == {"a": 1.0, "b": 0.0, "c": 1.0} + assert result.strategy_used == "deterministic" + + +def test_exact_enum_extension_probability_query() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + praf = ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0}, + p_defeats={("a", "b"): 0.5}, + ) + + result = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="exact_enum", + query_kind="extension_probability", + queried_set={"a"}, + ) + + assert result.extension_probability == pytest.approx(0.5) + + +def test_probabilities_are_plain_bounded_floats() -> None: + framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) + + with pytest.raises(ValueError, match="p_args"): + ProbabilisticAF(framework=framework, p_args={"a": 1.2}, p_defeats={}) + + +def test_p_args_must_match_framework_arguments() -> None: + framework = ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()) + + with pytest.raises(ValueError, match="p_args"): + ProbabilisticAF(framework=framework, p_args={"a": 1.0}, p_defeats={}) + + with pytest.raises(ValueError, match="p_args"): + ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0, "c": 1.0}, + p_defeats={}, + ) + + +def test_probabilistic_relation_probabilities_must_reference_declared_relations() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + with pytest.raises(ValueError, match="p_defeats"): + ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0}, + p_defeats={("b", "a"): 0.5}, + ) + + with pytest.raises(ValueError, match="p_supports"): + ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0}, + p_defeats={("a", "b"): 1.0}, + supports=frozenset(), + p_supports={("a", "b"): 0.5}, + ) + + +def test_probabilistic_supports_must_reference_declared_arguments() -> None: + framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) + + with pytest.raises(ValueError, match="supports"): + ProbabilisticAF( + framework=framework, + p_args={"a": 1.0}, + p_defeats={}, + supports=frozenset({("a", "b")}), + ) + + +def test_summarize_defeat_relations_returns_exact_marginals() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + praf = ProbabilisticAF( + framework=framework, + p_args={"a": 1.0, "b": 1.0}, + p_defeats={("a", "b"): 0.25}, + ) + + assert summarize_defeat_relations(praf) == {("a", "b"): pytest.approx(0.25)} + + +def test_ws_o_arg_z_for_confidence_accepts_continuous_confidence_values() -> None: + """Bug 8: Monte Carlo confidence should not be limited to three dict keys.""" + assert _z_for_confidence(0.975) == pytest.approx(2.2414027276) + assert _z_for_confidence(0.999) == pytest.approx(3.2905267315) + + +@pytest.mark.parametrize("confidence", [0.0, 1.0, -0.1, 1.1]) +def test_ws_o_arg_z_for_confidence_rejects_out_of_range_values(confidence: float) -> None: + with pytest.raises(ValueError, match="mc_confidence"): + _z_for_confidence(confidence) diff --git a/tests/test_probabilistic_paper_td.py b/tests/probabilistic/test_probabilistic_paper_td.py similarity index 94% rename from tests/test_probabilistic_paper_td.py rename to tests/probabilistic/test_probabilistic_paper_td.py index 4a276da..3d0d3cf 100644 --- a/tests/test_probabilistic_paper_td.py +++ b/tests/probabilistic/test_probabilistic_paper_td.py @@ -1,290 +1,290 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance -from argumentation.probabilistic_treedecomp import ( - PaperTDLabel, - PaperTDRow, - compute_paper_exact_extension_probability, - paper_forget_rows, - paper_introduce_rows, - paper_join_rows, - paper_leaf_rows, -) - - -def test_paper_td_leaf_table_starts_with_empty_structure_and_unit_mass() -> None: - rows = paper_leaf_rows() - - assert rows == ( - PaperTDRow( - present_arguments=frozenset(), - active_defeats=frozenset(), - labels={}, - witnesses={}, - probability=1.0, - ), - ) - - -def test_paper_td_introduce_filters_absent_target_argument() -> None: - rows = paper_introduce_rows( - paper_leaf_rows(), - argument="a", - bag=frozenset({"a"}), - all_defeats=frozenset(), - p_argument=0.8, - p_defeats={}, - queried_in=frozenset({"a"}), - ) - - assert rows == ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=pytest.approx(0.8), - ), - ) - - -def test_paper_td_introduce_branches_absent_and_unattacked_present_argument() -> None: - rows = paper_introduce_rows( - paper_leaf_rows(), - argument="b", - bag=frozenset({"b"}), - all_defeats=frozenset(), - p_argument=0.75, - p_defeats={}, - queried_in=frozenset(), - ) - - assert rows[0] == PaperTDRow( - present_arguments=frozenset(), - active_defeats=frozenset(), - labels={}, - witnesses={}, - probability=pytest.approx(0.25), - ) - assert { - row.labels["b"] - for row in rows[1:] - } == {PaperTDLabel.IN, PaperTDLabel.OUT, PaperTDLabel.UNDECIDED} - assert all(row.probability == pytest.approx(0.75) for row in rows[1:]) - - -def test_paper_td_introduce_records_out_witness_for_attacked_argument() -> None: - rows = paper_introduce_rows( - ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=1.0, - ), - ), - argument="b", - bag=frozenset({"a", "b"}), - all_defeats=frozenset({("a", "b")}), - p_argument=1.0, - p_defeats={("a", "b"): 0.6}, - queried_in=frozenset(), - ) - - out_rows = [ - row - for row in rows - if row.labels.get("b") is PaperTDLabel.OUT and row.witnesses - ] - assert len(out_rows) == 1 - assert out_rows[0].active_defeats == frozenset({("a", "b")}) - assert out_rows[0].witnesses == {"b": "a"} - assert out_rows[0].probability == pytest.approx(0.6) - - -def test_paper_td_forget_filters_out_without_witness_and_removes_local_state() -> None: - rows = paper_forget_rows( - ( - PaperTDRow( - present_arguments=frozenset({"a", "b"}), - active_defeats=frozenset({("a", "b")}), - labels={"a": PaperTDLabel.IN, "b": PaperTDLabel.OUT}, - witnesses={}, - probability=0.2, - ), - PaperTDRow( - present_arguments=frozenset({"a", "b"}), - active_defeats=frozenset({("a", "b")}), - labels={"a": PaperTDLabel.IN, "b": PaperTDLabel.OUT}, - witnesses={"b": "a"}, - probability=0.3, - ), - ), - argument="b", - ) - - assert rows == ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=pytest.approx(0.3), - ), - ) - - -def test_paper_td_join_divides_out_common_bag_probability() -> None: - rows = paper_join_rows( - ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=0.4, - ), - ), - ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=0.5, - ), - ), - bag=frozenset({"a"}), - p_arguments={"a": 0.8}, - p_defeats={}, - all_defeats=frozenset(), - ) - - assert rows == ( - PaperTDRow( - present_arguments=frozenset({"a"}), - active_defeats=frozenset(), - labels={"a": PaperTDLabel.IN}, - witnesses={}, - probability=pytest.approx(0.25), - ), - ) - - -def test_paper_td_evaluator_matches_enumeration_on_complete_extension_query() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ), - p_args={"a": 1.0, "b": 1.0, "c": 1.0}, - p_defeats={("a", "b"): 0.7, ("b", "c"): 0.4}, - ) - - expected = compute_probabilistic_acceptance( - praf, - semantics="complete", - strategy="exact_enum", - query_kind="extension_probability", - queried_set=frozenset({"a", "c"}), - ) - result = compute_paper_exact_extension_probability( - praf, - queried_set=frozenset({"a", "c"}), - ) - - assert result.extension_probability == pytest.approx(expected.extension_probability) - assert result.backend == "popescu_wallner_iou_witness_td" - assert result.table_summaries - - -def test_paper_td_evaluator_lifts_witness_metadata_for_rejected_arguments() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("c", "b")}), - ), - p_args={"a": 1.0, "b": 1.0, "c": 1.0}, - p_defeats={("a", "b"): 1.0, ("c", "b"): 1.0}, - ) - - result = compute_paper_exact_extension_probability( - praf, - queried_set=frozenset({"a", "c"}), - ) - - assert result.argument_witnesses["a"].label is PaperTDLabel.IN - assert result.argument_witnesses["c"].label is PaperTDLabel.IN - assert result.argument_witnesses["b"].label is PaperTDLabel.OUT - assert result.argument_witnesses["b"].witnesses <= frozenset({"a", "c"}) - assert result.argument_witnesses["b"].witnesses - - -def test_probabilistic_acceptance_routes_paper_td_without_old_exact_dp_backend() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ), - p_args={"a": 1.0, "b": 1.0, "c": 1.0}, - p_defeats={("a", "b"): 0.7, ("b", "c"): 0.4}, - ) - - result = compute_probabilistic_acceptance( - praf, - semantics="complete", - strategy="paper_td", - query_kind="extension_probability", - queried_set=frozenset({"a", "c"}), - ) - - assert result.strategy_used == "paper_td" - assert result.extension_probability == pytest.approx(0.7) - assert result.strategy_metadata is not None - assert result.strategy_metadata["backend"] == "popescu_wallner_iou_witness_td" - assert result.strategy_metadata["paper_conformance"] == "popescu_wallner_2024_algorithm_1" - - -@pytest.mark.differential -def test_paper_td_evaluator_matches_enumeration_on_low_treewidth_queries() -> None: - frameworks = ( - ( - frozenset({"a", "b"}), - frozenset({("a", "b")}), - {"a": 0.9, "b": 0.8}, - {("a", "b"): 0.5}, - frozenset({"a"}), - ), - ( - frozenset({"a", "b", "c"}), - frozenset({("a", "b"), ("c", "b")}), - {"a": 1.0, "b": 0.75, "c": 0.8}, - {("a", "b"): 0.6, ("c", "b"): 0.7}, - frozenset({"a", "c"}), - ), - ) - - for arguments, defeats, p_args, p_defeats, queried_set in frameworks: - praf = ProbabilisticAF( - framework=ArgumentationFramework(arguments=arguments, defeats=defeats), - p_args=p_args, - p_defeats=p_defeats, - ) - - expected = compute_probabilistic_acceptance( - praf, - semantics="complete", - strategy="exact_enum", - query_kind="extension_probability", - queried_set=queried_set, - ) - result = compute_paper_exact_extension_probability( - praf, - queried_set=queried_set, - ) - - assert result.extension_probability == pytest.approx(expected.extension_probability) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.probabilistic.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance +from argumentation.probabilistic.probabilistic_treedecomp import ( + PaperTDLabel, + PaperTDRow, + compute_paper_exact_extension_probability, + paper_forget_rows, + paper_introduce_rows, + paper_join_rows, + paper_leaf_rows, +) + + +def test_paper_td_leaf_table_starts_with_empty_structure_and_unit_mass() -> None: + rows = paper_leaf_rows() + + assert rows == ( + PaperTDRow( + present_arguments=frozenset(), + active_defeats=frozenset(), + labels={}, + witnesses={}, + probability=1.0, + ), + ) + + +def test_paper_td_introduce_filters_absent_target_argument() -> None: + rows = paper_introduce_rows( + paper_leaf_rows(), + argument="a", + bag=frozenset({"a"}), + all_defeats=frozenset(), + p_argument=0.8, + p_defeats={}, + queried_in=frozenset({"a"}), + ) + + assert rows == ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=pytest.approx(0.8), + ), + ) + + +def test_paper_td_introduce_branches_absent_and_unattacked_present_argument() -> None: + rows = paper_introduce_rows( + paper_leaf_rows(), + argument="b", + bag=frozenset({"b"}), + all_defeats=frozenset(), + p_argument=0.75, + p_defeats={}, + queried_in=frozenset(), + ) + + assert rows[0] == PaperTDRow( + present_arguments=frozenset(), + active_defeats=frozenset(), + labels={}, + witnesses={}, + probability=pytest.approx(0.25), + ) + assert { + row.labels["b"] + for row in rows[1:] + } == {PaperTDLabel.IN, PaperTDLabel.OUT, PaperTDLabel.UNDECIDED} + assert all(row.probability == pytest.approx(0.75) for row in rows[1:]) + + +def test_paper_td_introduce_records_out_witness_for_attacked_argument() -> None: + rows = paper_introduce_rows( + ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=1.0, + ), + ), + argument="b", + bag=frozenset({"a", "b"}), + all_defeats=frozenset({("a", "b")}), + p_argument=1.0, + p_defeats={("a", "b"): 0.6}, + queried_in=frozenset(), + ) + + out_rows = [ + row + for row in rows + if row.labels.get("b") is PaperTDLabel.OUT and row.witnesses + ] + assert len(out_rows) == 1 + assert out_rows[0].active_defeats == frozenset({("a", "b")}) + assert out_rows[0].witnesses == {"b": "a"} + assert out_rows[0].probability == pytest.approx(0.6) + + +def test_paper_td_forget_filters_out_without_witness_and_removes_local_state() -> None: + rows = paper_forget_rows( + ( + PaperTDRow( + present_arguments=frozenset({"a", "b"}), + active_defeats=frozenset({("a", "b")}), + labels={"a": PaperTDLabel.IN, "b": PaperTDLabel.OUT}, + witnesses={}, + probability=0.2, + ), + PaperTDRow( + present_arguments=frozenset({"a", "b"}), + active_defeats=frozenset({("a", "b")}), + labels={"a": PaperTDLabel.IN, "b": PaperTDLabel.OUT}, + witnesses={"b": "a"}, + probability=0.3, + ), + ), + argument="b", + ) + + assert rows == ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=pytest.approx(0.3), + ), + ) + + +def test_paper_td_join_divides_out_common_bag_probability() -> None: + rows = paper_join_rows( + ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=0.4, + ), + ), + ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=0.5, + ), + ), + bag=frozenset({"a"}), + p_arguments={"a": 0.8}, + p_defeats={}, + all_defeats=frozenset(), + ) + + assert rows == ( + PaperTDRow( + present_arguments=frozenset({"a"}), + active_defeats=frozenset(), + labels={"a": PaperTDLabel.IN}, + witnesses={}, + probability=pytest.approx(0.25), + ), + ) + + +def test_paper_td_evaluator_matches_enumeration_on_complete_extension_query() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ), + p_args={"a": 1.0, "b": 1.0, "c": 1.0}, + p_defeats={("a", "b"): 0.7, ("b", "c"): 0.4}, + ) + + expected = compute_probabilistic_acceptance( + praf, + semantics="complete", + strategy="exact_enum", + query_kind="extension_probability", + queried_set=frozenset({"a", "c"}), + ) + result = compute_paper_exact_extension_probability( + praf, + queried_set=frozenset({"a", "c"}), + ) + + assert result.extension_probability == pytest.approx(expected.extension_probability) + assert result.backend == "popescu_wallner_iou_witness_td" + assert result.table_summaries + + +def test_paper_td_evaluator_lifts_witness_metadata_for_rejected_arguments() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("c", "b")}), + ), + p_args={"a": 1.0, "b": 1.0, "c": 1.0}, + p_defeats={("a", "b"): 1.0, ("c", "b"): 1.0}, + ) + + result = compute_paper_exact_extension_probability( + praf, + queried_set=frozenset({"a", "c"}), + ) + + assert result.argument_witnesses["a"].label is PaperTDLabel.IN + assert result.argument_witnesses["c"].label is PaperTDLabel.IN + assert result.argument_witnesses["b"].label is PaperTDLabel.OUT + assert result.argument_witnesses["b"].witnesses <= frozenset({"a", "c"}) + assert result.argument_witnesses["b"].witnesses + + +def test_probabilistic_acceptance_routes_paper_td_without_old_exact_dp_backend() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ), + p_args={"a": 1.0, "b": 1.0, "c": 1.0}, + p_defeats={("a", "b"): 0.7, ("b", "c"): 0.4}, + ) + + result = compute_probabilistic_acceptance( + praf, + semantics="complete", + strategy="paper_td", + query_kind="extension_probability", + queried_set=frozenset({"a", "c"}), + ) + + assert result.strategy_used == "paper_td" + assert result.extension_probability == pytest.approx(0.7) + assert result.strategy_metadata is not None + assert result.strategy_metadata["backend"] == "popescu_wallner_iou_witness_td" + assert result.strategy_metadata["paper_conformance"] == "popescu_wallner_2024_algorithm_1" + + +@pytest.mark.differential +def test_paper_td_evaluator_matches_enumeration_on_low_treewidth_queries() -> None: + frameworks = ( + ( + frozenset({"a", "b"}), + frozenset({("a", "b")}), + {"a": 0.9, "b": 0.8}, + {("a", "b"): 0.5}, + frozenset({"a"}), + ), + ( + frozenset({"a", "b", "c"}), + frozenset({("a", "b"), ("c", "b")}), + {"a": 1.0, "b": 0.75, "c": 0.8}, + {("a", "b"): 0.6, ("c", "b"): 0.7}, + frozenset({"a", "c"}), + ), + ) + + for arguments, defeats, p_args, p_defeats, queried_set in frameworks: + praf = ProbabilisticAF( + framework=ArgumentationFramework(arguments=arguments, defeats=defeats), + p_args=p_args, + p_defeats=p_defeats, + ) + + expected = compute_probabilistic_acceptance( + praf, + semantics="complete", + strategy="exact_enum", + query_kind="extension_probability", + queried_set=queried_set, + ) + result = compute_paper_exact_extension_probability( + praf, + queried_set=queried_set, + ) + + assert result.extension_probability == pytest.approx(expected.extension_probability) diff --git a/tests/test_treedecomp.py b/tests/probabilistic/test_treedecomp.py similarity index 93% rename from tests/test_treedecomp.py rename to tests/probabilistic/test_treedecomp.py index 21128c1..111d1dd 100644 --- a/tests/test_treedecomp.py +++ b/tests/probabilistic/test_treedecomp.py @@ -1,164 +1,164 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance -from argumentation.probabilistic_treedecomp import ( - compute_exact_dp, - compute_exact_dp_with_diagnostics, - compute_tree_decomposition, - estimate_treewidth, - supports_exact_dp, - to_nice_tree_decomposition, - validate_tree_decomposition, -) - - -def _praf( - arguments: set[str], - defeats: set[tuple[str, str]], - *, - p_defeat: float = 0.5, -) -> ProbabilisticAF: - framework = ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(defeats), - ) - return ProbabilisticAF( - framework=framework, - p_args={arg: 1.0 for arg in arguments}, - p_defeats={edge: p_defeat for edge in defeats}, - ) - - -def test_treewidth_estimation_for_empty_path_and_clique() -> None: - assert estimate_treewidth(ArgumentationFramework(frozenset(), frozenset())) == 0 - assert estimate_treewidth( - ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - ) == 1 - assert estimate_treewidth( - ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({ - ("a", "b"), - ("b", "a"), - ("a", "c"), - ("c", "a"), - ("b", "c"), - ("c", "b"), - }), - ) - ) == 2 - - -def test_tree_decomposition_validates_path_framework() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - - decomposition = compute_tree_decomposition(framework) - nice = to_nice_tree_decomposition(decomposition) - - validate_tree_decomposition(decomposition, framework) - assert nice.root in nice.nodes - - -def test_exact_dp_matches_exact_enumeration_on_grounded_path() -> None: - praf = _praf({"a", "b", "c"}, {("a", "b"), ("b", "c")}, p_defeat=0.5) - - exact = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="exact_enum", - ) - dp = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="exact_dp", - ) - - assert dp.strategy_used == "exact_dp" - assert dp.acceptance_probs == pytest.approx(exact.acceptance_probs) - assert dp.strategy_metadata == { - "backend": "grounded_edge_tracking_td", - "paper_conformance": "adapted_not_popescu_iou_witness_dp", - } - - -def test_exact_dp_diagnostics_expose_grounded_table_summaries() -> None: - praf = _praf({"a", "b", "c"}, {("a", "b"), ("b", "c")}, p_defeat=0.5) - - diagnostics = compute_exact_dp_with_diagnostics(praf, semantics="grounded") - - assert diagnostics.acceptance_probs == pytest.approx(compute_exact_dp(praf)) - assert diagnostics.treewidth == 1 - assert diagnostics.node_count >= 1 - assert diagnostics.component_count == 1 - assert diagnostics.root_table_rows >= 1 - assert diagnostics.root_probability_mass == pytest.approx(1.0) - assert diagnostics.table_summaries - assert {summary.node_type for summary in diagnostics.table_summaries} >= { - "leaf", - "introduce", - "forget", - } - assert all(summary.row_count >= 1 for summary in diagnostics.table_summaries) - - -def test_exact_dp_diagnostics_expose_grounded_status_witnesses() -> None: - praf = _praf( - {"a", "b", "c", "x", "y"}, - {("a", "b"), ("b", "c"), ("x", "y"), ("y", "x")}, - p_defeat=1.0, - ) - - diagnostics = compute_exact_dp_with_diagnostics(praf, semantics="grounded") - - assert diagnostics.acceptance_probs == pytest.approx(compute_exact_dp(praf)) - assert diagnostics.component_count == 2 - assert diagnostics.status_probabilities["a"].accepted == pytest.approx(1.0) - assert diagnostics.status_probabilities["b"].rejected == pytest.approx(1.0) - assert diagnostics.status_probabilities["x"].undecided == pytest.approx(1.0) - assert diagnostics.status_probabilities["y"].undecided == pytest.approx(1.0) - - accepted_witness = diagnostics.status_witnesses["a"].accepted - rejected_witness = diagnostics.status_witnesses["b"].rejected - undecided_witness = diagnostics.status_witnesses["x"].undecided - - assert accepted_witness is not None - assert accepted_witness.argument == "a" - assert accepted_witness.present_arguments >= frozenset({"a", "b", "c"}) - - assert rejected_witness is not None - assert rejected_witness.argument == "b" - assert ("a", "b") in rejected_witness.active_defeats - - assert undecided_witness is not None - assert undecided_witness.argument == "x" - assert {("x", "y"), ("y", "x")} <= undecided_witness.active_defeats - - -def test_exact_dp_rejects_richer_support_worlds() -> None: - praf = ProbabilisticAF( - framework=ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()), - p_args={"a": 1.0, "b": 1.0}, - p_defeats={}, - supports=frozenset({("a", "b")}), - p_supports={("a", "b"): 1.0}, - ) - - assert not supports_exact_dp(praf, "grounded") - with pytest.raises(ValueError, match="exact_dp"): - compute_probabilistic_acceptance(praf, strategy="exact_dp") - - -def test_exact_dp_docstring_discloses_adapted_backend() -> None: - doc = compute_exact_dp.__doc__ or "" - - assert "edge-tracking" in doc - assert "I/O/U labelling DP with witness" not in doc +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.probabilistic.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance +from argumentation.probabilistic.probabilistic_treedecomp import ( + compute_exact_dp, + compute_exact_dp_with_diagnostics, + compute_tree_decomposition, + estimate_treewidth, + supports_exact_dp, + to_nice_tree_decomposition, + validate_tree_decomposition, +) + + +def _praf( + arguments: set[str], + defeats: set[tuple[str, str]], + *, + p_defeat: float = 0.5, +) -> ProbabilisticAF: + framework = ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(defeats), + ) + return ProbabilisticAF( + framework=framework, + p_args={arg: 1.0 for arg in arguments}, + p_defeats={edge: p_defeat for edge in defeats}, + ) + + +def test_treewidth_estimation_for_empty_path_and_clique() -> None: + assert estimate_treewidth(ArgumentationFramework(frozenset(), frozenset())) == 0 + assert estimate_treewidth( + ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + ) == 1 + assert estimate_treewidth( + ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({ + ("a", "b"), + ("b", "a"), + ("a", "c"), + ("c", "a"), + ("b", "c"), + ("c", "b"), + }), + ) + ) == 2 + + +def test_tree_decomposition_validates_path_framework() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + + decomposition = compute_tree_decomposition(framework) + nice = to_nice_tree_decomposition(decomposition) + + validate_tree_decomposition(decomposition, framework) + assert nice.root in nice.nodes + + +def test_exact_dp_matches_exact_enumeration_on_grounded_path() -> None: + praf = _praf({"a", "b", "c"}, {("a", "b"), ("b", "c")}, p_defeat=0.5) + + exact = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="exact_enum", + ) + dp = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="exact_dp", + ) + + assert dp.strategy_used == "exact_dp" + assert dp.acceptance_probs == pytest.approx(exact.acceptance_probs) + assert dp.strategy_metadata == { + "backend": "grounded_edge_tracking_td", + "paper_conformance": "adapted_not_popescu_iou_witness_dp", + } + + +def test_exact_dp_diagnostics_expose_grounded_table_summaries() -> None: + praf = _praf({"a", "b", "c"}, {("a", "b"), ("b", "c")}, p_defeat=0.5) + + diagnostics = compute_exact_dp_with_diagnostics(praf, semantics="grounded") + + assert diagnostics.acceptance_probs == pytest.approx(compute_exact_dp(praf)) + assert diagnostics.treewidth == 1 + assert diagnostics.node_count >= 1 + assert diagnostics.component_count == 1 + assert diagnostics.root_table_rows >= 1 + assert diagnostics.root_probability_mass == pytest.approx(1.0) + assert diagnostics.table_summaries + assert {summary.node_type for summary in diagnostics.table_summaries} >= { + "leaf", + "introduce", + "forget", + } + assert all(summary.row_count >= 1 for summary in diagnostics.table_summaries) + + +def test_exact_dp_diagnostics_expose_grounded_status_witnesses() -> None: + praf = _praf( + {"a", "b", "c", "x", "y"}, + {("a", "b"), ("b", "c"), ("x", "y"), ("y", "x")}, + p_defeat=1.0, + ) + + diagnostics = compute_exact_dp_with_diagnostics(praf, semantics="grounded") + + assert diagnostics.acceptance_probs == pytest.approx(compute_exact_dp(praf)) + assert diagnostics.component_count == 2 + assert diagnostics.status_probabilities["a"].accepted == pytest.approx(1.0) + assert diagnostics.status_probabilities["b"].rejected == pytest.approx(1.0) + assert diagnostics.status_probabilities["x"].undecided == pytest.approx(1.0) + assert diagnostics.status_probabilities["y"].undecided == pytest.approx(1.0) + + accepted_witness = diagnostics.status_witnesses["a"].accepted + rejected_witness = diagnostics.status_witnesses["b"].rejected + undecided_witness = diagnostics.status_witnesses["x"].undecided + + assert accepted_witness is not None + assert accepted_witness.argument == "a" + assert accepted_witness.present_arguments >= frozenset({"a", "b", "c"}) + + assert rejected_witness is not None + assert rejected_witness.argument == "b" + assert ("a", "b") in rejected_witness.active_defeats + + assert undecided_witness is not None + assert undecided_witness.argument == "x" + assert {("x", "y"), ("y", "x")} <= undecided_witness.active_defeats + + +def test_exact_dp_rejects_richer_support_worlds() -> None: + praf = ProbabilisticAF( + framework=ArgumentationFramework(arguments=frozenset({"a", "b"}), defeats=frozenset()), + p_args={"a": 1.0, "b": 1.0}, + p_defeats={}, + supports=frozenset({("a", "b")}), + p_supports={("a", "b"): 1.0}, + ) + + assert not supports_exact_dp(praf, "grounded") + with pytest.raises(ValueError, match="exact_dp"): + compute_probabilistic_acceptance(praf, strategy="exact_dp") + + +def test_exact_dp_docstring_discloses_adapted_backend() -> None: + doc = compute_exact_dp.__doc__ or "" + + assert "edge-tracking" in doc + assert "I/O/U labelling DP with witness" not in doc diff --git a/tests/test_treedecomp_differential.py b/tests/probabilistic/test_treedecomp_differential.py similarity index 86% rename from tests/test_treedecomp_differential.py rename to tests/probabilistic/test_treedecomp_differential.py index cd3a74a..0094003 100644 --- a/tests/test_treedecomp_differential.py +++ b/tests/probabilistic/test_treedecomp_differential.py @@ -1,49 +1,49 @@ -from __future__ import annotations - -import random - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance - - -def _random_praf(seed: int) -> ProbabilisticAF: - rng = random.Random(seed) - args = [f"a{i}" for i in range(rng.randint(2, 5))] - defeats = { - (src, tgt) - for src in args - for tgt in args - if src != tgt and rng.random() < 0.35 - } - if not defeats: - defeats.add((args[0], args[-1])) - framework = ArgumentationFramework( - arguments=frozenset(args), - defeats=frozenset(defeats), - ) - return ProbabilisticAF( - framework=framework, - p_args={arg: rng.choice([0.5, 0.7, 0.9, 1.0]) for arg in args}, - p_defeats={edge: rng.choice([0.4, 0.6, 0.8, 1.0]) for edge in defeats}, - ) - - -@pytest.mark.differential -def test_exact_dp_matches_exact_enum_under_repeated_randomized_runs() -> None: - for seed in range(10): - praf = _random_praf(seed) - exact = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="exact_enum", - ) - routed = compute_probabilistic_acceptance( - praf, - semantics="grounded", - strategy="exact_dp", - ) - - assert routed.strategy_used == "exact_dp" - assert routed.acceptance_probs == pytest.approx(exact.acceptance_probs) +from __future__ import annotations + +import random + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.probabilistic.probabilistic import ProbabilisticAF, compute_probabilistic_acceptance + + +def _random_praf(seed: int) -> ProbabilisticAF: + rng = random.Random(seed) + args = [f"a{i}" for i in range(rng.randint(2, 5))] + defeats = { + (src, tgt) + for src in args + for tgt in args + if src != tgt and rng.random() < 0.35 + } + if not defeats: + defeats.add((args[0], args[-1])) + framework = ArgumentationFramework( + arguments=frozenset(args), + defeats=frozenset(defeats), + ) + return ProbabilisticAF( + framework=framework, + p_args={arg: rng.choice([0.5, 0.7, 0.9, 1.0]) for arg in args}, + p_defeats={edge: rng.choice([0.4, 0.6, 0.8, 1.0]) for edge in defeats}, + ) + + +@pytest.mark.differential +def test_exact_dp_matches_exact_enum_under_repeated_randomized_runs() -> None: + for seed in range(10): + praf = _random_praf(seed) + exact = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="exact_enum", + ) + routed = compute_probabilistic_acceptance( + praf, + semantics="grounded", + strategy="exact_dp", + ) + + assert routed.strategy_used == "exact_dp" + assert routed.acceptance_probs == pytest.approx(exact.acceptance_probs) diff --git a/tests/ranking/__init__.py b/tests/ranking/__init__.py new file mode 100644 index 0000000..01ff8ad --- /dev/null +++ b/tests/ranking/__init__.py @@ -0,0 +1 @@ +"""Tests for the ranking layer.""" diff --git a/tests/test_matt_toni_baroni_2019_compliance.py b/tests/ranking/test_matt_toni_baroni_2019_compliance.py similarity index 82% rename from tests/test_matt_toni_baroni_2019_compliance.py rename to tests/ranking/test_matt_toni_baroni_2019_compliance.py index 94421b7..3dc8706 100644 --- a/tests/test_matt_toni_baroni_2019_compliance.py +++ b/tests/ranking/test_matt_toni_baroni_2019_compliance.py @@ -1,31 +1,31 @@ -from __future__ import annotations - -import pytest -from hypothesis import given -from hypothesis import strategies as st - -from argumentation.dung import ArgumentationFramework -from argumentation.gradual_principles import PRINCIPLE_COMPLIANCE, ComplianceLabel -from argumentation.matt_toni import matt_toni_strengths - - -@given(st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)) -def test_matt_toni_declares_conditional_baroni_principle_compliance( - _unused_weight: float, -) -> None: - """Matt-Toni 2008, JELIA, p. 291; Baroni-Rago-Toni 2019, IJAR 105, pp. 258-259.""" - - one_way_attack = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - mutual_attack = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - assert matt_toni_strengths(one_way_attack) == pytest.approx({"a": 1.0, "b": 0.25}) - assert matt_toni_strengths(mutual_attack) == pytest.approx({"a": 0.5, "b": 0.5}) - assert PRINCIPLE_COMPLIANCE["matt_toni"]["directionality"] is ComplianceLabel.HOLDS - assert PRINCIPLE_COMPLIANCE["matt_toni"]["balance"] is ComplianceLabel.CONDITIONAL - assert PRINCIPLE_COMPLIANCE["matt_toni"]["monotonicity"] is ComplianceLabel.CONDITIONAL +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework +from argumentation.gradual.gradual_principles import PRINCIPLE_COMPLIANCE, ComplianceLabel +from argumentation.ranking.matt_toni import matt_toni_strengths + + +@given(st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)) +def test_matt_toni_declares_conditional_baroni_principle_compliance( + _unused_weight: float, +) -> None: + """Matt-Toni 2008, JELIA, p. 291; Baroni-Rago-Toni 2019, IJAR 105, pp. 258-259.""" + + one_way_attack = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + mutual_attack = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + assert matt_toni_strengths(one_way_attack) == pytest.approx({"a": 1.0, "b": 0.25}) + assert matt_toni_strengths(mutual_attack) == pytest.approx({"a": 0.5, "b": 0.5}) + assert PRINCIPLE_COMPLIANCE["matt_toni"]["directionality"] is ComplianceLabel.HOLDS + assert PRINCIPLE_COMPLIANCE["matt_toni"]["balance"] is ComplianceLabel.CONDITIONAL + assert PRINCIPLE_COMPLIANCE["matt_toni"]["monotonicity"] is ComplianceLabel.CONDITIONAL diff --git a/tests/test_matt_toni_extension_consistency.py b/tests/ranking/test_matt_toni_extension_consistency.py similarity index 84% rename from tests/test_matt_toni_extension_consistency.py rename to tests/ranking/test_matt_toni_extension_consistency.py index eb04de7..e89ee48 100644 --- a/tests/test_matt_toni_extension_consistency.py +++ b/tests/ranking/test_matt_toni_extension_consistency.py @@ -1,24 +1,24 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.matt_toni import matt_toni_strengths - - -def test_unattacked_arguments_are_strongest_and_attacked_arguments_are_weaker() -> None: - """Matt and Toni 2008, JELIA, p. 291, Table 1, frameworks F1-F3.""" - - unattacked = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) - one_way_attack = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - mutual_attack = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - assert matt_toni_strengths(unattacked)["a"] == pytest.approx(1.0) - assert matt_toni_strengths(one_way_attack) == pytest.approx({"a": 1.0, "b": 0.25}) - assert matt_toni_strengths(mutual_attack) == pytest.approx({"a": 0.5, "b": 0.5}) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.matt_toni import matt_toni_strengths + + +def test_unattacked_arguments_are_strongest_and_attacked_arguments_are_weaker() -> None: + """Matt and Toni 2008, JELIA, p. 291, Table 1, frameworks F1-F3.""" + + unattacked = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) + one_way_attack = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + mutual_attack = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + assert matt_toni_strengths(unattacked)["a"] == pytest.approx(1.0) + assert matt_toni_strengths(one_way_attack) == pytest.approx({"a": 1.0, "b": 0.25}) + assert matt_toni_strengths(mutual_attack) == pytest.approx({"a": 0.5, "b": 0.5}) diff --git a/tests/test_matt_toni_two_player_strength.py b/tests/ranking/test_matt_toni_two_player_strength.py similarity index 82% rename from tests/test_matt_toni_two_player_strength.py rename to tests/ranking/test_matt_toni_two_player_strength.py index fd7da43..5d0ac55 100644 --- a/tests/test_matt_toni_two_player_strength.py +++ b/tests/ranking/test_matt_toni_two_player_strength.py @@ -1,38 +1,38 @@ -from __future__ import annotations - -import pytest - -from argumentation.dung import ArgumentationFramework -from argumentation.matt_toni import matt_toni_strengths - - -def test_matt_toni_table_one_f8_strengths() -> None: - """Matt and Toni 2008, JELIA, p. 291, Def. 6 and Table 1.""" - - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c", "d", "e", "f"}), - defeats=frozenset( - { - ("a", "b"), - ("b", "a"), - ("b", "c"), - ("c", "d"), - ("e", "c"), - ("f", "e"), - } - ), - ) - - strengths = matt_toni_strengths(framework) - - assert strengths == pytest.approx( - { - "a": 0.5, - "b": 0.5, - "c": 0.417, - "d": 0.5, - "e": 0.25, - "f": 1.0, - }, - abs=0.001, - ) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.matt_toni import matt_toni_strengths + + +def test_matt_toni_table_one_f8_strengths() -> None: + """Matt and Toni 2008, JELIA, p. 291, Def. 6 and Table 1.""" + + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c", "d", "e", "f"}), + defeats=frozenset( + { + ("a", "b"), + ("b", "a"), + ("b", "c"), + ("c", "d"), + ("e", "c"), + ("f", "e"), + } + ), + ) + + strengths = matt_toni_strengths(framework) + + assert strengths == pytest.approx( + { + "a": 0.5, + "b": 0.5, + "c": 0.417, + "d": 0.5, + "e": 0.25, + "f": 1.0, + }, + abs=0.001, + ) diff --git a/tests/test_ranking.py b/tests/ranking/test_ranking.py similarity index 93% rename from tests/test_ranking.py rename to tests/ranking/test_ranking.py index ae8f915..36eb20d 100644 --- a/tests/test_ranking.py +++ b/tests/ranking/test_ranking.py @@ -1,157 +1,150 @@ -from __future__ import annotations - -import pytest -from hypothesis import given -from hypothesis import strategies as st - -import argumentation -from argumentation.dung import ArgumentationFramework -from argumentation.ranking import ( - RankingResult, - burden_numbers, - burden_ranking, - categoriser_ranking, - categoriser_scores, - counting_ranking, - discussion_based_ranking, - h_categoriser_ranking, - iterated_graded_ranking, - tuples_ranking, -) - - -def test_ranking_module_is_exported() -> None: - assert argumentation.ranking.categoriser_scores is categoriser_scores - assert argumentation.ranking.RankingResult is RankingResult - assert "ranking" in argumentation.__all__ - - -def _bonzon_example() -> ArgumentationFramework: - return ArgumentationFramework( - arguments=frozenset({"a", "b", "c", "d", "e"}), - defeats=frozenset({ - ("a", "e"), - ("b", "a"), - ("b", "c"), - ("c", "e"), - ("d", "a"), - ("e", "d"), - }), - ) - - -def test_categoriser_scores_match_bonzon_running_example() -> None: - result = categoriser_scores(_bonzon_example(), tolerance=1e-10) - - assert result.converged is True - assert result.semantics == "categoriser" - assert result.scores["a"] == pytest.approx(0.38, abs=0.01) - assert result.scores["b"] == pytest.approx(1.00, abs=0.01) - assert result.scores["c"] == pytest.approx(0.50, abs=0.01) - assert result.scores["d"] == pytest.approx(0.65, abs=0.01) - assert result.scores["e"] == pytest.approx(0.53, abs=0.01) - - ranking = categoriser_ranking(_bonzon_example(), tolerance=1e-10) - assert ranking.ranking == ( - frozenset({"b"}), - frozenset({"d"}), - frozenset({"e"}), - frozenset({"c"}), - frozenset({"a"}), - ) - assert ranking.strictly_prefers("b", "a") - - -def test_burden_numbers_match_bonzon_running_example_steps() -> None: - burdens = burden_numbers(_bonzon_example(), iterations=2) - - assert burdens.converged is True - assert burdens.semantics == "burden" - assert burdens.iterations == 2 - assert burdens.scores == pytest.approx({ - "a": 2.5, - "b": 1.0, - "c": 2.0, - "d": 1.3333333333, - "e": 1.8333333333, - }) - - ranking = burden_ranking(_bonzon_example(), iterations=2) - assert ranking.ranking == ( - frozenset({"b"}), - frozenset({"d"}), - frozenset({"e"}), - frozenset({"c"}), - frozenset({"a"}), - ) - assert ranking.strictly_prefers("d", "c") - - -def test_ranking_keeps_unattacked_arguments_above_attacked_arguments() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("a", "c")}), - ) - - ranking = categoriser_ranking(framework) - - assert ranking.strictly_prefers("a", "b") - assert ranking.strictly_prefers("a", "c") - assert ranking.equivalent("b", "c") - - -def test_categoriser_non_convergence_is_result_data() -> None: - result = categoriser_scores(_bonzon_example(), tolerance=1e-30, max_iterations=1) - - assert result.converged is False - assert result.iterations == 1 - assert set(result.scores) == _bonzon_example().arguments - - -@pytest.mark.parametrize( - "semantic", - [ - discussion_based_ranking, - counting_ranking, - tuples_ranking, - h_categoriser_ranking, - iterated_graded_ranking, - ], -) -def test_additional_ranking_semantics_return_total_preorders(semantic) -> None: - result = semantic(_bonzon_example()) - - assert isinstance(result, RankingResult) - assert set(result.scores) == _bonzon_example().arguments - assert set().union(*result.ranking) == _bonzon_example().arguments - assert all(result.equivalent(argument, argument) for argument in _bonzon_example().arguments) - - -def _small_frameworks() -> st.SearchStrategy[ArgumentationFramework]: - arguments = ("a", "b", "c", "d") - possible_attacks = [(attacker, target) for attacker in arguments for target in arguments] - return st.builds( - lambda attacks: ArgumentationFramework( - arguments=frozenset(arguments), - defeats=frozenset(attacks), - ), - st.sets(st.sampled_from(possible_attacks), max_size=8), - ) - - -@given(_small_frameworks()) -def test_generated_ranking_results_are_reflexive_and_transitive( - framework: ArgumentationFramework, -) -> None: - # Ranking semantics are total preorders over arguments; every result should - # therefore induce reflexive equivalence and transitive strict preference. - result = categoriser_ranking(framework, max_iterations=100) - - for argument in framework.arguments: - assert result.equivalent(argument, argument) - - for left in framework.arguments: - for middle in framework.arguments: - for right in framework.arguments: - if result.strictly_prefers(left, middle) and result.strictly_prefers(middle, right): - assert result.strictly_prefers(left, right) +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.ranking import ( + RankingResult, + burden_numbers, + burden_ranking, + categoriser_ranking, + categoriser_scores, + counting_ranking, + discussion_based_ranking, + h_categoriser_ranking, + iterated_graded_ranking, + tuples_ranking, +) + + +def _bonzon_example() -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset({"a", "b", "c", "d", "e"}), + defeats=frozenset({ + ("a", "e"), + ("b", "a"), + ("b", "c"), + ("c", "e"), + ("d", "a"), + ("e", "d"), + }), + ) + + +def test_categoriser_scores_match_bonzon_running_example() -> None: + result = categoriser_scores(_bonzon_example(), tolerance=1e-10) + + assert result.converged is True + assert result.semantics == "categoriser" + assert result.scores["a"] == pytest.approx(0.38, abs=0.01) + assert result.scores["b"] == pytest.approx(1.00, abs=0.01) + assert result.scores["c"] == pytest.approx(0.50, abs=0.01) + assert result.scores["d"] == pytest.approx(0.65, abs=0.01) + assert result.scores["e"] == pytest.approx(0.53, abs=0.01) + + ranking = categoriser_ranking(_bonzon_example(), tolerance=1e-10) + assert ranking.ranking == ( + frozenset({"b"}), + frozenset({"d"}), + frozenset({"e"}), + frozenset({"c"}), + frozenset({"a"}), + ) + assert ranking.strictly_prefers("b", "a") + + +def test_burden_numbers_match_bonzon_running_example_steps() -> None: + burdens = burden_numbers(_bonzon_example(), iterations=2) + + assert burdens.converged is True + assert burdens.semantics == "burden" + assert burdens.iterations == 2 + assert burdens.scores == pytest.approx({ + "a": 2.5, + "b": 1.0, + "c": 2.0, + "d": 1.3333333333, + "e": 1.8333333333, + }) + + ranking = burden_ranking(_bonzon_example(), iterations=2) + assert ranking.ranking == ( + frozenset({"b"}), + frozenset({"d"}), + frozenset({"e"}), + frozenset({"c"}), + frozenset({"a"}), + ) + assert ranking.strictly_prefers("d", "c") + + +def test_ranking_keeps_unattacked_arguments_above_attacked_arguments() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("a", "c")}), + ) + + ranking = categoriser_ranking(framework) + + assert ranking.strictly_prefers("a", "b") + assert ranking.strictly_prefers("a", "c") + assert ranking.equivalent("b", "c") + + +def test_categoriser_non_convergence_is_result_data() -> None: + result = categoriser_scores(_bonzon_example(), tolerance=1e-30, max_iterations=1) + + assert result.converged is False + assert result.iterations == 1 + assert set(result.scores) == _bonzon_example().arguments + + +@pytest.mark.parametrize( + "semantic", + [ + discussion_based_ranking, + counting_ranking, + tuples_ranking, + h_categoriser_ranking, + iterated_graded_ranking, + ], +) +def test_additional_ranking_semantics_return_total_preorders(semantic) -> None: + result = semantic(_bonzon_example()) + + assert isinstance(result, RankingResult) + assert set(result.scores) == _bonzon_example().arguments + assert set().union(*result.ranking) == _bonzon_example().arguments + assert all(result.equivalent(argument, argument) for argument in _bonzon_example().arguments) + + +def _small_frameworks() -> st.SearchStrategy[ArgumentationFramework]: + arguments = ("a", "b", "c", "d") + possible_attacks = [(attacker, target) for attacker in arguments for target in arguments] + return st.builds( + lambda attacks: ArgumentationFramework( + arguments=frozenset(arguments), + defeats=frozenset(attacks), + ), + st.sets(st.sampled_from(possible_attacks), max_size=8), + ) + + +@given(_small_frameworks()) +def test_generated_ranking_results_are_reflexive_and_transitive( + framework: ArgumentationFramework, +) -> None: + # Ranking semantics are total preorders over arguments; every result should + # therefore induce reflexive equivalence and transitive strict preference. + result = categoriser_ranking(framework, max_iterations=100) + + for argument in framework.arguments: + assert result.equivalent(argument, argument) + + for left in framework.arguments: + for middle in framework.arguments: + for right in framework.arguments: + if result.strictly_prefers(left, middle) and result.strictly_prefers(middle, right): + assert result.strictly_prefers(left, right) diff --git a/tests/test_ranking_axioms.py b/tests/ranking/test_ranking_axioms.py similarity index 91% rename from tests/test_ranking_axioms.py rename to tests/ranking/test_ranking_axioms.py index 00f611e..c0187ba 100644 --- a/tests/test_ranking_axioms.py +++ b/tests/ranking/test_ranking_axioms.py @@ -1,129 +1,123 @@ -from __future__ import annotations - -import argumentation -from argumentation.dung import ArgumentationFramework -from argumentation.ranking import categoriser_ranking -from argumentation.ranking_axioms import ( - abstraction, - cardinality_precedence, - counter_transitivity, - defense_precedence, - distributed_defense_precedence, - independence, - quality_precedence, - self_contradiction, - strict_addition_of_defense_branch, - strict_counter_transitivity, - strict_preference_transitive, - void_precedence, -) - - -def test_ranking_axioms_module_is_exported() -> None: - assert argumentation.ranking_axioms.void_precedence is void_precedence - assert "ranking_axioms" in argumentation.__all__ - - -def test_strict_preference_transitive_checks_ranking_result() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - - assert strict_preference_transitive(categoriser_ranking(framework)) - - -def test_void_precedence_prefers_unattacked_over_attacked() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - assert void_precedence(framework, categoriser_ranking(framework)) - - -def test_cardinality_precedence_prefers_fewer_unattacked_attackers() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c", "d", "e"}), - defeats=frozenset({("a", "d"), ("b", "e"), ("c", "e")}), - ) - - assert cardinality_precedence(framework, categoriser_ranking(framework)) - - -def test_abstraction_and_independence_ignore_names_and_disconnected_context() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "x", "y"}), - defeats=frozenset({("a", "b"), ("x", "y")}), - ) - - assert abstraction(categoriser_ranking, framework) - assert independence(categoriser_ranking, framework) - - -def test_self_contradiction_ranks_self_attackers_no_higher_than_clean_arguments() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"self", "clean"}), - defeats=frozenset({("self", "self")}), - ) - - assert self_contradiction(framework, categoriser_ranking(framework)) - - -def test_defense_and_strict_addition_precedence_reward_defended_attackers() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"defended", "undefended", "attacker_a", "attacker_b", "helper"}), - defeats=frozenset({ - ("attacker_a", "defended"), - ("attacker_b", "undefended"), - ("helper", "attacker_a"), - }), - ) - result = categoriser_ranking(framework) - - assert defense_precedence(framework, result) - assert strict_addition_of_defense_branch(framework, result) - - -def test_counter_transitivity_variants_follow_attacker_group_quality() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"strong_attacker", "weak_attacker", "left", "right", "helper"}), - defeats=frozenset({ - ("strong_attacker", "left"), - ("weak_attacker", "right"), - ("helper", "strong_attacker"), - }), - ) - result = categoriser_ranking(framework) - - assert counter_transitivity(framework, result) - assert strict_counter_transitivity(framework, result) - assert quality_precedence(framework, result) - - -def test_distributed_defense_precedence_prefers_spread_defense() -> None: - framework = ArgumentationFramework( - arguments=frozenset({ - "distributed", - "concentrated", - "da", - "db", - "ca", - "cb", - "d1", - "d2", - "c1", - }), - defeats=frozenset({ - ("da", "distributed"), - ("db", "distributed"), - ("ca", "concentrated"), - ("cb", "concentrated"), - ("d1", "da"), - ("d2", "db"), - ("c1", "ca"), - ("c1", "cb"), - }), - ) - - assert distributed_defense_precedence(framework, categoriser_ranking(framework)) +from __future__ import annotations + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.ranking import categoriser_ranking +from argumentation.ranking.ranking_axioms import ( + abstraction, + cardinality_precedence, + counter_transitivity, + defense_precedence, + distributed_defense_precedence, + independence, + quality_precedence, + self_contradiction, + strict_addition_of_defense_branch, + strict_counter_transitivity, + strict_preference_transitive, + void_precedence, +) + + +def test_strict_preference_transitive_checks_ranking_result() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + + assert strict_preference_transitive(categoriser_ranking(framework)) + + +def test_void_precedence_prefers_unattacked_over_attacked() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + assert void_precedence(framework, categoriser_ranking(framework)) + + +def test_cardinality_precedence_prefers_fewer_unattacked_attackers() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c", "d", "e"}), + defeats=frozenset({("a", "d"), ("b", "e"), ("c", "e")}), + ) + + assert cardinality_precedence(framework, categoriser_ranking(framework)) + + +def test_abstraction_and_independence_ignore_names_and_disconnected_context() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "x", "y"}), + defeats=frozenset({("a", "b"), ("x", "y")}), + ) + + assert abstraction(categoriser_ranking, framework) + assert independence(categoriser_ranking, framework) + + +def test_self_contradiction_ranks_self_attackers_no_higher_than_clean_arguments() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"self", "clean"}), + defeats=frozenset({("self", "self")}), + ) + + assert self_contradiction(framework, categoriser_ranking(framework)) + + +def test_defense_and_strict_addition_precedence_reward_defended_attackers() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"defended", "undefended", "attacker_a", "attacker_b", "helper"}), + defeats=frozenset({ + ("attacker_a", "defended"), + ("attacker_b", "undefended"), + ("helper", "attacker_a"), + }), + ) + result = categoriser_ranking(framework) + + assert defense_precedence(framework, result) + assert strict_addition_of_defense_branch(framework, result) + + +def test_counter_transitivity_variants_follow_attacker_group_quality() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"strong_attacker", "weak_attacker", "left", "right", "helper"}), + defeats=frozenset({ + ("strong_attacker", "left"), + ("weak_attacker", "right"), + ("helper", "strong_attacker"), + }), + ) + result = categoriser_ranking(framework) + + assert counter_transitivity(framework, result) + assert strict_counter_transitivity(framework, result) + assert quality_precedence(framework, result) + + +def test_distributed_defense_precedence_prefers_spread_defense() -> None: + framework = ArgumentationFramework( + arguments=frozenset({ + "distributed", + "concentrated", + "da", + "db", + "ca", + "cb", + "d1", + "d2", + "c1", + }), + defeats=frozenset({ + ("da", "distributed"), + ("db", "distributed"), + ("ca", "concentrated"), + ("cb", "concentrated"), + ("d1", "da"), + ("d2", "db"), + ("c1", "ca"), + ("c1", "cb"), + }), + ) + + assert distributed_defense_precedence(framework, categoriser_ranking(framework)) diff --git a/tests/test_weighted.py b/tests/ranking/test_weighted.py similarity index 89% rename from tests/test_weighted.py rename to tests/ranking/test_weighted.py index 8b51b61..b08c870 100644 --- a/tests/test_weighted.py +++ b/tests/ranking/test_weighted.py @@ -1,83 +1,77 @@ -from __future__ import annotations - -import pytest - -import argumentation -from argumentation.dung import grounded_extension -from argumentation.weighted import ( - WeightedArgumentationFramework, - minimum_budget_for_grounded_acceptance, - weighted_grounded_extensions, -) - - -def test_weighted_module_is_exported() -> None: - assert argumentation.weighted.WeightedArgumentationFramework is WeightedArgumentationFramework - assert "weighted" in argumentation.__all__ - - -def _mutual_weighted_framework() -> WeightedArgumentationFramework: - return WeightedArgumentationFramework( - arguments=frozenset({"a", "b"}), - attacks=frozenset({("a", "b"), ("b", "a")}), - weights={("a", "b"): 1.0, ("b", "a"): 2.0}, - ) - - -def test_weighted_framework_requires_positive_attack_weights() -> None: - with pytest.raises(ValueError, match="positive"): - WeightedArgumentationFramework( - arguments=frozenset({"a", "b"}), - attacks=frozenset({("a", "b")}), - weights={("a", "b"): 0.0}, - ) - - with pytest.raises(ValueError, match="exactly"): - WeightedArgumentationFramework( - arguments=frozenset({"a", "b"}), - attacks=frozenset({("a", "b")}), - weights={}, - ) - - -def test_zero_budget_recovers_unweighted_grounded_extension() -> None: - framework = _mutual_weighted_framework() - - weighted = weighted_grounded_extensions(framework, budget=0.0) - - assert [result.extension for result in weighted] == [ - grounded_extension(framework.as_dung_framework()) - ] - assert weighted[0].deleted_attacks == frozenset() - assert weighted[0].deleted_weight == pytest.approx(0.0) - - -def test_increasing_budget_adds_grounded_extensions_with_witnesses() -> None: - framework = _mutual_weighted_framework() - - beta_one = weighted_grounded_extensions(framework, budget=1.0) - beta_two = weighted_grounded_extensions(framework, budget=2.0) - - assert {result.extension for result in beta_one} == { - frozenset(), - frozenset({"b"}), - } - b_witness = next(result for result in beta_one if result.extension == frozenset({"b"})) - assert b_witness.deleted_attacks == frozenset({("a", "b")}) - assert b_witness.deleted_weight == pytest.approx(1.0) - - assert {result.extension for result in beta_two} == { - frozenset(), - frozenset({"a"}), - frozenset({"b"}), - } - a_witness = next(result for result in beta_two if result.extension == frozenset({"a"})) - assert a_witness.deleted_attacks == frozenset({("b", "a")}) - assert a_witness.deleted_weight == pytest.approx(2.0) - - -def test_minimum_budget_for_grounded_acceptance() -> None: - framework = _mutual_weighted_framework() - - assert minimum_budget_for_grounded_acceptance(framework, "a") == pytest.approx(2.0) - assert minimum_budget_for_grounded_acceptance(framework, "b") == pytest.approx(1.0) +from __future__ import annotations + +import pytest + +from argumentation.core.dung import grounded_extension +from argumentation.ranking.weighted import ( + WeightedArgumentationFramework, + minimum_budget_for_grounded_acceptance, + weighted_grounded_extensions, +) + + +def _mutual_weighted_framework() -> WeightedArgumentationFramework: + return WeightedArgumentationFramework( + arguments=frozenset({"a", "b"}), + attacks=frozenset({("a", "b"), ("b", "a")}), + weights={("a", "b"): 1.0, ("b", "a"): 2.0}, + ) + + +def test_weighted_framework_requires_positive_attack_weights() -> None: + with pytest.raises(ValueError, match="positive"): + WeightedArgumentationFramework( + arguments=frozenset({"a", "b"}), + attacks=frozenset({("a", "b")}), + weights={("a", "b"): 0.0}, + ) + + with pytest.raises(ValueError, match="exactly"): + WeightedArgumentationFramework( + arguments=frozenset({"a", "b"}), + attacks=frozenset({("a", "b")}), + weights={}, + ) + + +def test_zero_budget_recovers_unweighted_grounded_extension() -> None: + framework = _mutual_weighted_framework() + + weighted = weighted_grounded_extensions(framework, budget=0.0) + + assert [result.extension for result in weighted] == [ + grounded_extension(framework.as_dung_framework()) + ] + assert weighted[0].deleted_attacks == frozenset() + assert weighted[0].deleted_weight == pytest.approx(0.0) + + +def test_increasing_budget_adds_grounded_extensions_with_witnesses() -> None: + framework = _mutual_weighted_framework() + + beta_one = weighted_grounded_extensions(framework, budget=1.0) + beta_two = weighted_grounded_extensions(framework, budget=2.0) + + assert {result.extension for result in beta_one} == { + frozenset(), + frozenset({"b"}), + } + b_witness = next(result for result in beta_one if result.extension == frozenset({"b"})) + assert b_witness.deleted_attacks == frozenset({("a", "b")}) + assert b_witness.deleted_weight == pytest.approx(1.0) + + assert {result.extension for result in beta_two} == { + frozenset(), + frozenset({"a"}), + frozenset({"b"}), + } + a_witness = next(result for result in beta_two if result.extension == frozenset({"a"})) + assert a_witness.deleted_attacks == frozenset({("b", "a")}) + assert a_witness.deleted_weight == pytest.approx(2.0) + + +def test_minimum_budget_for_grounded_acceptance() -> None: + framework = _mutual_weighted_framework() + + assert minimum_budget_for_grounded_acceptance(framework, "a") == pytest.approx(2.0) + assert minimum_budget_for_grounded_acceptance(framework, "b") == pytest.approx(1.0) diff --git a/tests/solving/__init__.py b/tests/solving/__init__.py new file mode 100644 index 0000000..a0e0fbd --- /dev/null +++ b/tests/solving/__init__.py @@ -0,0 +1 @@ +"""Tests for the solving layer.""" diff --git a/tests/test_backend_selection.py b/tests/solving/test_backend_selection.py similarity index 96% rename from tests/test_backend_selection.py rename to tests/solving/test_backend_selection.py index 19c4bea..7039a8c 100644 --- a/tests/test_backend_selection.py +++ b/tests/solving/test_backend_selection.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation import backends +from argumentation.solving import backends def test_default_backend_uses_materialized_for_weakest_link() -> None: diff --git a/tests/test_iccma_cli.py b/tests/solving/test_iccma_cli.py similarity index 90% rename from tests/test_iccma_cli.py rename to tests/solving/test_iccma_cli.py index 57f6fb7..b891d48 100644 --- a/tests/test_iccma_cli.py +++ b/tests/solving/test_iccma_cli.py @@ -1,136 +1,136 @@ -from __future__ import annotations - -from argumentation import iccma_cli -from argumentation.labelling import ExactEnumerationExceeded - - -def test_iccma_cli_prints_single_extension(tmp_path, capsys) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 2\n1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "w 1\n" - assert captured.err == "" - - -def test_iccma_cli_prints_credulous_yes_certificate(tmp_path, capsys) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 2\n1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "DC-ST", "-f", str(path), "-a", "1"]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "YES\nw 1\n" - assert captured.err == "" - - -def test_iccma_cli_prints_skeptical_no_counterexample(tmp_path, capsys) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 2\n1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "DS-ST", "-f", str(path), "-a", "2"]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "NO\nw 1\n" - assert captured.err == "" - - -def test_iccma_cli_auto_uses_sat_for_stable_af(tmp_path, capsys, monkeypatch) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 1\n", encoding="utf-8") - calls: list[str] = [] - - def fake_solve_single_extension(framework, *, semantics, backend): - del framework - del semantics - calls.append(backend) - return iccma_cli.SingleExtensionSolverSuccess(extension=frozenset({"1"})) - - monkeypatch.setattr( - "argumentation.iccma_cli.solve_dung_single_extension", - fake_solve_single_extension, - ) - - status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "w 1\n" - assert calls == ["sat"] - - -def test_iccma_cli_rejects_acceptance_without_query(tmp_path, capsys) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 1\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "DC-ST", "-f", str(path)]) - - captured = capsys.readouterr() - assert status == 2 - assert captured.out == "" - assert captured.err == "DC tasks require -a/--argument\n" - - -def test_iccma_cli_reports_exact_enumeration_limits( - tmp_path, - capsys, - monkeypatch, -) -> None: - path = tmp_path / "instance.af" - path.write_text("p af 1\n", encoding="utf-8") - - def fake_solve_single_extension(*args, **kwargs): - raise ExactEnumerationExceeded("too many candidate subsets") - - monkeypatch.setattr( - "argumentation.iccma_cli.solve_dung_single_extension", - fake_solve_single_extension, - ) - - status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) - - captured = capsys.readouterr() - assert status == 2 - assert captured.out == "" - assert captured.err == "too many candidate subsets\n" - - -def test_iccma_cli_prints_aba_single_extension(tmp_path, capsys) -> None: - path = tmp_path / "instance.aba" - path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "w 1\n" - assert captured.err == "" - - -def test_iccma_cli_prints_aba_decision_answer(tmp_path, capsys) -> None: - path = tmp_path / "instance.aba" - path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "DC-ST", "-f", str(path), "-a", "1"]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "YES\n" - assert captured.err == "" - - -def test_iccma_cli_uses_sat_backend_for_stable_aba(tmp_path, capsys) -> None: - path = tmp_path / "instance.aba" - path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") - - status = iccma_cli.main(["-p", "SE-ST", "-f", str(path), "--backend", "sat"]) - - captured = capsys.readouterr() - assert status == 0 - assert captured.out == "w 1\n" - assert captured.err == "" +from __future__ import annotations + +from argumentation.solving import iccma_cli +from argumentation.core.labelling import ExactEnumerationExceeded + + +def test_iccma_cli_prints_single_extension(tmp_path, capsys) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 2\n1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "w 1\n" + assert captured.err == "" + + +def test_iccma_cli_prints_credulous_yes_certificate(tmp_path, capsys) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 2\n1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "DC-ST", "-f", str(path), "-a", "1"]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "YES\nw 1\n" + assert captured.err == "" + + +def test_iccma_cli_prints_skeptical_no_counterexample(tmp_path, capsys) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 2\n1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "DS-ST", "-f", str(path), "-a", "2"]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "NO\nw 1\n" + assert captured.err == "" + + +def test_iccma_cli_auto_uses_sat_for_stable_af(tmp_path, capsys, monkeypatch) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 1\n", encoding="utf-8") + calls: list[str] = [] + + def fake_solve_single_extension(framework, *, semantics, backend): + del framework + del semantics + calls.append(backend) + return iccma_cli.SingleExtensionSolverSuccess(extension=frozenset({"1"})) + + monkeypatch.setattr( + "argumentation.solving.iccma_cli.solve_dung_single_extension", + fake_solve_single_extension, + ) + + status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "w 1\n" + assert calls == ["sat"] + + +def test_iccma_cli_rejects_acceptance_without_query(tmp_path, capsys) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 1\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "DC-ST", "-f", str(path)]) + + captured = capsys.readouterr() + assert status == 2 + assert captured.out == "" + assert captured.err == "DC tasks require -a/--argument\n" + + +def test_iccma_cli_reports_exact_enumeration_limits( + tmp_path, + capsys, + monkeypatch, +) -> None: + path = tmp_path / "instance.af" + path.write_text("p af 1\n", encoding="utf-8") + + def fake_solve_single_extension(*args, **kwargs): + raise ExactEnumerationExceeded("too many candidate subsets") + + monkeypatch.setattr( + "argumentation.solving.iccma_cli.solve_dung_single_extension", + fake_solve_single_extension, + ) + + status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) + + captured = capsys.readouterr() + assert status == 2 + assert captured.out == "" + assert captured.err == "too many candidate subsets\n" + + +def test_iccma_cli_prints_aba_single_extension(tmp_path, capsys) -> None: + path = tmp_path / "instance.aba" + path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "SE-ST", "-f", str(path)]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "w 1\n" + assert captured.err == "" + + +def test_iccma_cli_prints_aba_decision_answer(tmp_path, capsys) -> None: + path = tmp_path / "instance.aba" + path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "DC-ST", "-f", str(path), "-a", "1"]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "YES\n" + assert captured.err == "" + + +def test_iccma_cli_uses_sat_backend_for_stable_aba(tmp_path, capsys) -> None: + path = tmp_path / "instance.aba" + path.write_text("p aba 2\na 1\nc 1 2\n", encoding="utf-8") + + status = iccma_cli.main(["-p", "SE-ST", "-f", str(path), "--backend", "sat"]) + + captured = capsys.readouterr() + assert status == 0 + assert captured.out == "w 1\n" + assert captured.err == "" diff --git a/tests/test_solver_adapters.py b/tests/solving/test_solver_adapters.py similarity index 94% rename from tests/test_solver_adapters.py rename to tests/solving/test_solver_adapters.py index 8dfab39..bcbeb43 100644 --- a/tests/test_solver_adapters.py +++ b/tests/solving/test_solver_adapters.py @@ -1,941 +1,941 @@ -from __future__ import annotations - -import os -from pathlib import Path -import shutil -import subprocess -from types import SimpleNamespace - -import pytest -from hypothesis import given, settings, strategies as st - -import argumentation.solver as solver_module -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.aba_sat import support_extensions -from argumentation.dung import ArgumentationFramework -from argumentation.dung import stable_extensions as native_stable_extensions -from argumentation.iccma import parse_aba -from argumentation.solver_adapters import iccma_af, iccma_aba -from argumentation.solver_adapters.iccma_aba import ( - ICCMAABAOutputKind, - ICCMAABAOutputParseError, - ICCMAABASolverError, - ICCMAABASolverProtocolError, - ICCMAABASolverSuccess, - parse_iccma_aba_output, - solve_aba_acceptance as solve_iccma_aba_acceptance, - solve_aba_extensions as solve_iccma_aba_extensions, -) -from argumentation.solver_adapters.iccma_af import ( - ICCMAOutputKind, - ICCMAOutputParseError, - ICCMASolverProtocolError, - ICCMASolverError, - ICCMASolverSuccess, - ICCMASolverUnavailable, - parse_iccma_output, - parse_extension_witnesses, - solve_af_acceptance, - solve_af_extensions, -) -from argumentation.solver_results import SolverUnavailable - - -def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: - return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) - - -def aba_framework( - size: int, - attacks: frozenset[tuple[int, int]] = frozenset(), -) -> ABAFramework: - assumptions = {literal(f"a{index}") for index in range(1, size + 1)} - contraries = {literal(f"c{index}") for index in range(1, size + 1)} - assumption_by_index = { - index: literal(f"a{index}") for index in range(1, size + 1) - } - contrary_by_index = { - index: literal(f"c{index}") for index in range(1, size + 1) - } - return ABAFramework( - language=frozenset(assumptions | contraries), - rules=frozenset( - Rule((assumption_by_index[attacker],), contrary_by_index[target], "strict") - for attacker, target in attacks - ), - assumptions=frozenset(assumptions), - contrary={ - assumption_by_index[index]: contrary_by_index[index] - for index in range(1, size + 1) - }, - ) - - -def literal(name: str) -> Literal: - return Literal(GroundAtom(name)) - - -def test_parse_iccma_witness_output() -> None: - assert parse_extension_witnesses("w 1 3\nw 2\n") == ( - frozenset({"1", "3"}), - frozenset({"2"}), - ) - - -def test_parse_iccma_2023_dc_yes_requires_query_in_certificate() -> None: - output = parse_iccma_output("DC-CO", "YES\nw 1 3\n", query="1") - - assert output.kind is ICCMAOutputKind.DECISION - assert output.answer is True - assert output.witness == frozenset({"1", "3"}) - - -def test_parse_iccma_2023_dc_yes_rejects_certificate_missing_query() -> None: - with pytest.raises(ICCMAOutputParseError, match="must contain query"): - parse_iccma_output("DC-CO", "YES\nw 2\n", query="1") - - -def test_parse_iccma_2023_dc_no_has_no_certificate() -> None: - output = parse_iccma_output("DC-CO", "NO\n", query="1") - - assert output.answer is False - assert output.witness is None - - -def test_parse_iccma_2023_ds_no_counterexample_omits_query() -> None: - output = parse_iccma_output("DS-PR", "NO\nw 2 3\n", query="1") - - assert output.answer is False - assert output.witness == frozenset({"2", "3"}) - - -def test_parse_iccma_2023_ds_no_rejects_counterexample_containing_query() -> None: - with pytest.raises(ICCMAOutputParseError, match="must omit query"): - parse_iccma_output("DS-PR", "NO\nw 1 2\n", query="1") - - -def test_parse_iccma_2023_ds_yes_has_no_certificate() -> None: - output = parse_iccma_output("DS-PR", "YES\n", query="1") - - assert output.answer is True - assert output.witness is None - - -def test_parse_iccma_2023_se_output_is_witness_or_no_extension() -> None: - witness = parse_iccma_output("SE-ST", "w 1\n") - no_extension = parse_iccma_output("SE-ST", "NO\n") - - assert witness.kind is ICCMAOutputKind.SINGLE_EXTENSION - assert witness.extensions == (frozenset({"1"}),) - assert no_extension.extensions == () - assert no_extension.no_extension is True - - -def test_parse_iccma_2023_approximate_decision_outputs_need_no_witness() -> None: - output = parse_iccma_output("DC-CO", "YES\n", query="1", certificate_required=False) - - assert output.answer is True - assert output.witness is None - - -def test_parse_iccma_output_rejects_malformed_witness_line() -> None: - with pytest.raises(ICCMAOutputParseError, match="witness"): - parse_iccma_output("SE-ST", "w 1 nope\n") - - -def test_iccma_af_adapter_invokes_official_2023_cli_for_single_extension(monkeypatch) -> None: - framework = af({"1", "2"}, {("1", "2")}) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - input_path = command[-1] - assert command[:-1] == ["fake-iccma-solver", "-p", "SE-ST", "-f"] - assert input_path.endswith(".af") - return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - fake_run, - ) - - result = solve_af_extensions( - framework, - semantics="stable", - binary="fake-iccma-solver", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMASolverSuccess) - assert result.extensions == (frozenset({"1"}),) - assert calls and calls[0][1:4] == ["-p", "SE-ST", "-f"] - - -def test_iccma_af_adapter_invokes_official_2023_cli_for_acceptance(monkeypatch) -> None: - framework = af({"1", "2"}, {("1", "2")}) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[:4] == ["fake-iccma-solver", "-p", "DC-ST", "-f"] - assert command[4].endswith(".af") - assert command[5:] == ["-a", "1"] - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - return SimpleNamespace(returncode=0, stdout="YES\nw 1\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - fake_run, - ) - - result = solve_af_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - binary="fake-iccma-solver", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMASolverSuccess) - assert result.answer is True - assert result.witness == frozenset({"1"}) - assert calls and calls[0][-2:] == ["-a", "1"] - - -def test_iccma_af_adapter_accepts_python_module_command(monkeypatch) -> None: - framework = af({"1", "2"}, {("1", "2")}) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[:5] == ["uv", "run", "python", "-m", "argumentation.iccma_cli"] - assert command[5:8] == ["-p", "SE-ST", "-f"] - assert command[8].endswith(".af") - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - fake_run, - ) - - result = solve_af_extensions( - framework, - semantics="stable", - binary="uv run python -m argumentation.iccma_cli", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMASolverSuccess) - assert result.extensions == (frozenset({"1"}),) - assert calls - - -def test_iccma_af_adapter_uses_local_stable_certificate_validation(monkeypatch) -> None: - arguments = {str(index) for index in range(1, 71)} - defeats = {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} - framework = af(arguments, defeats) - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="w 1\n", stderr=""), - ) - - result = solve_af_extensions( - framework, - semantics="stable", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverSuccess) - assert result.extensions == (frozenset({"1"}),) - - -def test_iccma_af_adapter_reports_missing_binary(monkeypatch) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: None, - ) - - result = solve_af_extensions( - af({"1"}, set()), - semantics="grounded", - binary="missing-solver", - ) - - assert isinstance(result, ICCMASolverUnavailable) - assert result.backend == "missing-solver" - - -def test_iccma_af_adapter_reports_solver_error(monkeypatch) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace(returncode=2, stdout="", stderr="bad input"), - ) - - result = solve_af_extensions( - af({"1"}, set()), - semantics="stable", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverError) - assert result.returncode == 2 - assert result.stderr == "bad input" - - -def test_iccma_af_adapter_reports_protocol_error(monkeypatch) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="maybe\n", stderr=""), - ) - - result = solve_af_extensions( - af({"1"}, set()), - semantics="stable", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.stdout == "maybe\n" - - -OFFICIAL_ICCMA_2023_MAIN_AF_PROBLEMS = { - ("DC", "complete"), - ("DC", "stable"), - ("DC", "semi-stable"), - ("DC", "stage"), - ("DS", "preferred"), - ("DS", "stable"), - ("DS", "semi-stable"), - ("DS", "stage"), - ("SE", "preferred"), - ("SE", "stable"), - ("SE", "semi-stable"), - ("SE", "stage"), - ("SE", "ideal"), -} - - -@given( - st.sampled_from(["DC", "DS", "SE"]), - st.sampled_from( - [ - "complete", - "grounded", - "preferred", - "stable", - "semi-stable", - "stage", - "ideal", - "cf2", - ] - ), -) -@settings(deadline=10000, max_examples=60) -def test_iccma_af_capability_table_matches_official_2023_main_track( - task: str, - semantics: str, -) -> None: - assert iccma_af.supports_af_problem(task, semantics) is ( - (task, semantics) in OFFICIAL_ICCMA_2023_MAIN_AF_PROBLEMS - ) - - -def test_iccma_af_rejects_unsupported_se_without_subprocess(monkeypatch) -> None: - calls = [] - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - - def fake_run(*args, **kwargs): - calls.append((args, kwargs)) - return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") - - monkeypatch.setattr("argumentation.solver_adapters.iccma_af.subprocess.run", fake_run) - - result = solve_af_extensions( - af({"1"}, set()), - semantics="complete", - binary="fake-iccma-solver", - ) - - assert isinstance(result, SolverUnavailable) - assert result.reason == "unsupported ICCMA 2023 AF problem: SE-CO" - assert calls == [] - - -def test_iccma_af_rejects_unsupported_acceptance_without_subprocess(monkeypatch) -> None: - calls = [] - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - - def fake_run(*args, **kwargs): - calls.append((args, kwargs)) - return SimpleNamespace(returncode=0, stdout="YES\nw 1\n", stderr="") - - monkeypatch.setattr("argumentation.solver_adapters.iccma_af.subprocess.run", fake_run) - - result = solve_af_acceptance( - af({"1"}, set()), - semantics="preferred", - task="credulous", - query="1", - binary="fake-iccma-solver", - ) - - assert isinstance(result, SolverUnavailable) - assert result.reason == "unsupported ICCMA 2023 AF problem: DC-PR" - assert calls == [] - - -@given(st.integers(min_value=1, max_value=5)) -@settings(deadline=10000, max_examples=20) -def test_iccma_se_stable_witness_must_pass_local_certificate_check(size: int) -> None: - # Stable SE witnesses must attack every argument outside the witness. - framework = af({str(index) for index in range(1, size + 1)}, set()) - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout="w\n", - stderr="", - ), - ) - - result = solve_af_extensions( - framework, - semantics="stable", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.stdout == "w\n" - - -@given(st.integers(min_value=2, max_value=5)) -@settings(deadline=10000, max_examples=20) -def test_iccma_dc_yes_stable_witness_must_pass_local_certificate_check( - size: int, -) -> None: - # Stable DC YES certificates must contain the query and attack every outsider. - framework = af({str(index) for index in range(1, size + 1)}, set()) - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout="YES\nw 1\n", - stderr="", - ), - ) - - result = solve_af_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.stdout == "YES\nw 1\n" - - -@given(st.integers(min_value=2, max_value=5)) -@settings(deadline=10000, max_examples=20) -def test_iccma_ds_no_stable_counterexample_must_pass_local_certificate_check( - size: int, -) -> None: - # Stable DS NO counterexamples must omit the query and attack every outsider. - framework = af({str(index) for index in range(1, size + 1)}, set()) - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout="NO\nw 2\n", - stderr="", - ), - ) - - result = solve_af_acceptance( - framework, - semantics="stable", - task="skeptical", - query="1", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.stdout == "NO\nw 2\n" - - -def test_iccma_backend_failures_use_shared_solver_result_classes() -> None: - assert ICCMASolverUnavailable is solver_module.SolverBackendUnavailable - assert ICCMASolverError is solver_module.SolverBackendError - assert ICCMASolverProtocolError is solver_module.SolverProtocolError - - -@given(st.from_regex(r"[A-Za-z_]{1,12}", fullmatch=True)) -@settings(deadline=10000, max_examples=30) -def test_iccma_source_derived_malformed_witnesses_are_protocol_errors( - bad_argument: str, -) -> None: - # ICCMA 2023 AF witness lines use indexed positive-integer arguments. - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_af.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout=f"w 1 {bad_argument}\n", - stderr="protocol stderr", - ), - ) - - result = solve_af_extensions( - af({"1"}, set()), - semantics="stable", - binary="fake-iccma-solver", - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.problem == "SE-ST" - assert result.stdout == f"w 1 {bad_argument}\n" - assert result.stderr == "protocol stderr" - - -def test_optional_real_iccma_af_solver_smoke() -> None: - binary = os.environ.get("ICCMA_AF_SOLVER") - if not binary or (shutil.which(binary) is None and not Path(binary).exists()): - pytest.skip("set ICCMA_AF_SOLVER to an ICCMA 2023 AF solver executable") - - framework = af({"1", "2"}, {("1", "2")}) - result = solve_af_extensions(framework, semantics="stable", binary=binary) - - assert isinstance(result, ICCMASolverSuccess) - assert set(result.extensions) <= set(native_stable_extensions(framework)) - - -def test_parse_iccma_aba_2023_se_output_maps_numeric_assumption_ids() -> None: - framework = aba_framework(2) - - output = parse_iccma_aba_output("SE-ST", "w 1 2\n", framework=framework) - - assert output.kind is ICCMAABAOutputKind.SINGLE_EXTENSION - assert output.witness == frozenset({literal("a1"), literal("a2")}) - - -def test_parse_iccma_aba_2023_decision_output_has_no_certificate() -> None: - framework = aba_framework(1) - - dc = parse_iccma_aba_output( - "DC-CO", - "YES\n", - framework=framework, - query=literal("a1"), - ) - ds = parse_iccma_aba_output( - "DS-ST", - "NO\n", - framework=framework, - query=literal("a1"), - ) - - assert dc.kind is ICCMAABAOutputKind.DECISION - assert dc.answer is True - assert dc.witness is None - assert ds.answer is False - assert ds.witness is None - - -def test_parse_iccma_aba_2023_decision_output_rejects_certificates() -> None: - with pytest.raises(ICCMAABAOutputParseError, match="YES or NO"): - parse_iccma_aba_output( - "DC-ST", - "YES\nw 1\n", - framework=aba_framework(1), - query=literal("a1"), - ) - - -OFFICIAL_ASPFORABA_ICCMA_2023_PROBLEMS = { - ("DC", "complete"), - ("DC", "stable"), - ("DS", "preferred"), - ("DS", "stable"), - ("SE", "preferred"), - ("SE", "stable"), -} - - -@given( - st.sampled_from(["DC", "DS", "SE"]), - st.sampled_from( - [ - "complete", - "grounded", - "preferred", - "stable", - "semi-stable", - "stage", - "ideal", - "cf2", - ] - ), -) -@settings(deadline=10000, max_examples=60) -def test_iccma_aba_capability_table_matches_aspforaba_iccma_2023_source( - task: str, - semantics: str, -) -> None: - assert iccma_aba.supports_aba_problem(task, semantics) is ( - (task, semantics) in OFFICIAL_ASPFORABA_ICCMA_2023_PROBLEMS - ) - - -def test_iccma_aba_adapter_invokes_official_2023_cli_and_writes_numeric_aba( - monkeypatch, -) -> None: - framework = aba_framework(2) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[:4] == ["fake-aspforaba", "-p", "SE-ST", "-f"] - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - written = Path(command[4]).read_text(encoding="utf-8") - assert written.startswith("p aba 4\n") - parsed = parse_aba(written) - assert {item.atom.predicate for item in parsed.assumptions} == {"1", "2"} - return SimpleNamespace(returncode=0, stdout="w 1 2\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - fake_run, - ) - - result = solve_iccma_aba_extensions( - framework, - semantics="stable", - binary="fake-aspforaba", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.extensions == (frozenset({literal("a1"), literal("a2")}),) - assert calls and calls[0][1:4] == ["-p", "SE-ST", "-f"] - - -def test_iccma_aba_adapter_missing_binary_is_typed_unavailable(monkeypatch) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: None, - ) - - result = solve_iccma_aba_extensions( - aba_framework(1), - semantics="stable", - binary="definitely-missing-aspforaba", - ) - - assert isinstance(result, SolverUnavailable) - assert result.backend == "definitely-missing-aspforaba" - assert "not found" in result.reason - - -def test_iccma_aba_adapter_timeout_is_distinct_from_nonzero_and_protocol( - monkeypatch, -) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - raise subprocess.TimeoutExpired(command, timeout, output="partial out", stderr="partial err") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - fake_run, - ) - - result = solve_iccma_aba_extensions( - aba_framework(1), - semantics="stable", - binary="fake-aspforaba", - timeout_seconds=0.01, - ) - - assert isinstance(result, ICCMAABASolverError) - assert result.returncode == -1 - assert result.reason == "solver exited with code -1" - assert result.stdout == "partial out" - assert result.stderr == "partial err" - - -def test_iccma_aba_adapter_nonzero_exit_is_process_error(monkeypatch) -> None: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=2, - stdout="", - stderr="bad flags", - ), - ) - - result = solve_iccma_aba_extensions( - aba_framework(1), - semantics="stable", - binary="fake-aspforaba", - ) - - assert isinstance(result, ICCMAABASolverError) - assert result.returncode == 2 - assert result.stderr == "bad flags" - - -def test_iccma_aba_adapter_invokes_acceptance_with_query_atom(monkeypatch) -> None: - framework = aba_framework(1) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[:4] == ["fake-aspforaba", "-p", "DC-CO", "-f"] - assert command[5:] == ["-a", "1"] - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - return SimpleNamespace(returncode=0, stdout="YES\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - fake_run, - ) - - result = solve_iccma_aba_acceptance( - framework, - semantics="complete", - task="credulous", - query=literal("a1"), - binary="fake-aspforaba", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.answer is True - assert calls and calls[0][-2:] == ["-a", "1"] - - -def test_iccma_aba_adapter_accepts_python_module_command(monkeypatch) -> None: - framework = aba_framework(1) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[:5] == ["uv", "run", "python", "-m", "argumentation.iccma_cli"] - assert command[5:8] == ["-p", "SE-ST", "-f"] - assert command[8].endswith(".aba") - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") - - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - fake_run, - ) - - result = solve_iccma_aba_extensions( - framework, - semantics="stable", - binary="uv run python -m argumentation.iccma_cli", - timeout_seconds=5.0, - ) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.extensions == (frozenset({literal("a1")}),) - assert calls - - -@given(st.integers(min_value=2, max_value=5)) -@settings(deadline=10000, max_examples=20) -def test_iccma_aba_se_witness_must_be_protocol_valid_assumptions(size: int) -> None: - framework = aba_framework(size) - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout="w 1\n", - stderr="", - ), - ) - - result = solve_iccma_aba_extensions( - framework, - semantics="stable", - binary="fake-aspforaba", - ) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.extensions == (frozenset({literal("a1")}),) - - -@given(st.integers(min_value=1, max_value=5)) -@settings(deadline=10000, max_examples=20) -def test_iccma_aba_generated_external_witnesses_validate_locally(size: int) -> None: - framework = aba_framework(size) - witness = " ".join(str(index) for index in range(1, size + 1)) - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout=f"w {witness}\n", - stderr="", - ), - ) - - result = solve_iccma_aba_extensions( - framework, - semantics="stable", - binary="fake-aspforaba", - ) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.extensions[0] in support_extensions(framework, "stable") - - -@given(st.from_regex(r"[A-Za-z_]{1,12}", fullmatch=True)) -@settings(deadline=10000, max_examples=30) -def test_iccma_aba_malformed_witnesses_are_protocol_errors( - bad_atom: str, -) -> None: - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.iccma_aba.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout=f"w 1 {bad_atom}\n", - stderr="protocol stderr", - ), - ) - - result = solve_iccma_aba_extensions( - aba_framework(1), - semantics="stable", - binary="fake-aspforaba", - ) - - assert isinstance(result, ICCMAABASolverProtocolError) - assert result.problem == "SE-ST" - assert result.stdout == f"w 1 {bad_atom}\n" - assert result.stderr == "protocol stderr" - - -def test_optional_real_aspforaba_solver_smoke() -> None: - binary = os.environ.get("ASPFORABA_SOLVER") or os.environ.get("ICCMA_ABA_SOLVER") - if not binary or (shutil.which(binary) is None and not Path(binary).exists()): - pytest.skip("set ASPFORABA_SOLVER or ICCMA_ABA_SOLVER to an ICCMA 2023 ABA solver") - - framework = aba_framework(2) - result = solve_iccma_aba_extensions(framework, semantics="stable", binary=binary) - - assert isinstance(result, ICCMAABASolverSuccess) - assert result.extensions == (framework.assumptions,) +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings, strategies as st + +import argumentation.solving.solver as solver_module +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba_sat import support_extensions +from argumentation.core.dung import ArgumentationFramework +from argumentation.core.dung import stable_extensions as native_stable_extensions +from argumentation.interop.iccma import parse_aba +from argumentation.solver_adapters import iccma_af, iccma_aba +from argumentation.solver_adapters.iccma_aba import ( + ICCMAABAOutputKind, + ICCMAABAOutputParseError, + ICCMAABASolverError, + ICCMAABASolverProtocolError, + ICCMAABASolverSuccess, + parse_iccma_aba_output, + solve_aba_acceptance as solve_iccma_aba_acceptance, + solve_aba_extensions as solve_iccma_aba_extensions, +) +from argumentation.solver_adapters.iccma_af import ( + ICCMAOutputKind, + ICCMAOutputParseError, + ICCMASolverProtocolError, + ICCMASolverError, + ICCMASolverSuccess, + ICCMASolverUnavailable, + parse_iccma_output, + parse_extension_witnesses, + solve_af_acceptance, + solve_af_extensions, +) +from argumentation.core.solver_results import SolverUnavailable + + +def af(args: set[str], defeats: set[tuple[str, str]]) -> ArgumentationFramework: + return ArgumentationFramework(arguments=frozenset(args), defeats=frozenset(defeats)) + + +def aba_framework( + size: int, + attacks: frozenset[tuple[int, int]] = frozenset(), +) -> ABAFramework: + assumptions = {literal(f"a{index}") for index in range(1, size + 1)} + contraries = {literal(f"c{index}") for index in range(1, size + 1)} + assumption_by_index = { + index: literal(f"a{index}") for index in range(1, size + 1) + } + contrary_by_index = { + index: literal(f"c{index}") for index in range(1, size + 1) + } + return ABAFramework( + language=frozenset(assumptions | contraries), + rules=frozenset( + Rule((assumption_by_index[attacker],), contrary_by_index[target], "strict") + for attacker, target in attacks + ), + assumptions=frozenset(assumptions), + contrary={ + assumption_by_index[index]: contrary_by_index[index] + for index in range(1, size + 1) + }, + ) + + +def literal(name: str) -> Literal: + return Literal(GroundAtom(name)) + + +def test_parse_iccma_witness_output() -> None: + assert parse_extension_witnesses("w 1 3\nw 2\n") == ( + frozenset({"1", "3"}), + frozenset({"2"}), + ) + + +def test_parse_iccma_2023_dc_yes_requires_query_in_certificate() -> None: + output = parse_iccma_output("DC-CO", "YES\nw 1 3\n", query="1") + + assert output.kind is ICCMAOutputKind.DECISION + assert output.answer is True + assert output.witness == frozenset({"1", "3"}) + + +def test_parse_iccma_2023_dc_yes_rejects_certificate_missing_query() -> None: + with pytest.raises(ICCMAOutputParseError, match="must contain query"): + parse_iccma_output("DC-CO", "YES\nw 2\n", query="1") + + +def test_parse_iccma_2023_dc_no_has_no_certificate() -> None: + output = parse_iccma_output("DC-CO", "NO\n", query="1") + + assert output.answer is False + assert output.witness is None + + +def test_parse_iccma_2023_ds_no_counterexample_omits_query() -> None: + output = parse_iccma_output("DS-PR", "NO\nw 2 3\n", query="1") + + assert output.answer is False + assert output.witness == frozenset({"2", "3"}) + + +def test_parse_iccma_2023_ds_no_rejects_counterexample_containing_query() -> None: + with pytest.raises(ICCMAOutputParseError, match="must omit query"): + parse_iccma_output("DS-PR", "NO\nw 1 2\n", query="1") + + +def test_parse_iccma_2023_ds_yes_has_no_certificate() -> None: + output = parse_iccma_output("DS-PR", "YES\n", query="1") + + assert output.answer is True + assert output.witness is None + + +def test_parse_iccma_2023_se_output_is_witness_or_no_extension() -> None: + witness = parse_iccma_output("SE-ST", "w 1\n") + no_extension = parse_iccma_output("SE-ST", "NO\n") + + assert witness.kind is ICCMAOutputKind.SINGLE_EXTENSION + assert witness.extensions == (frozenset({"1"}),) + assert no_extension.extensions == () + assert no_extension.no_extension is True + + +def test_parse_iccma_2023_approximate_decision_outputs_need_no_witness() -> None: + output = parse_iccma_output("DC-CO", "YES\n", query="1", certificate_required=False) + + assert output.answer is True + assert output.witness is None + + +def test_parse_iccma_output_rejects_malformed_witness_line() -> None: + with pytest.raises(ICCMAOutputParseError, match="witness"): + parse_iccma_output("SE-ST", "w 1 nope\n") + + +def test_iccma_af_adapter_invokes_official_2023_cli_for_single_extension(monkeypatch) -> None: + framework = af({"1", "2"}, {("1", "2")}) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + input_path = command[-1] + assert command[:-1] == ["fake-iccma-solver", "-p", "SE-ST", "-f"] + assert input_path.endswith(".af") + return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + fake_run, + ) + + result = solve_af_extensions( + framework, + semantics="stable", + binary="fake-iccma-solver", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMASolverSuccess) + assert result.extensions == (frozenset({"1"}),) + assert calls and calls[0][1:4] == ["-p", "SE-ST", "-f"] + + +def test_iccma_af_adapter_invokes_official_2023_cli_for_acceptance(monkeypatch) -> None: + framework = af({"1", "2"}, {("1", "2")}) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[:4] == ["fake-iccma-solver", "-p", "DC-ST", "-f"] + assert command[4].endswith(".af") + assert command[5:] == ["-a", "1"] + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + return SimpleNamespace(returncode=0, stdout="YES\nw 1\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + fake_run, + ) + + result = solve_af_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + binary="fake-iccma-solver", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMASolverSuccess) + assert result.answer is True + assert result.witness == frozenset({"1"}) + assert calls and calls[0][-2:] == ["-a", "1"] + + +def test_iccma_af_adapter_accepts_python_module_command(monkeypatch) -> None: + framework = af({"1", "2"}, {("1", "2")}) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[:5] == ["uv", "run", "python", "-m", "argumentation.solving.iccma_cli"] + assert command[5:8] == ["-p", "SE-ST", "-f"] + assert command[8].endswith(".af") + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + fake_run, + ) + + result = solve_af_extensions( + framework, + semantics="stable", + binary="uv run python -m argumentation.solving.iccma_cli", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMASolverSuccess) + assert result.extensions == (frozenset({"1"}),) + assert calls + + +def test_iccma_af_adapter_uses_local_stable_certificate_validation(monkeypatch) -> None: + arguments = {str(index) for index in range(1, 71)} + defeats = {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} + framework = af(arguments, defeats) + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="w 1\n", stderr=""), + ) + + result = solve_af_extensions( + framework, + semantics="stable", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverSuccess) + assert result.extensions == (frozenset({"1"}),) + + +def test_iccma_af_adapter_reports_missing_binary(monkeypatch) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: None, + ) + + result = solve_af_extensions( + af({"1"}, set()), + semantics="grounded", + binary="missing-solver", + ) + + assert isinstance(result, ICCMASolverUnavailable) + assert result.backend == "missing-solver" + + +def test_iccma_af_adapter_reports_solver_error(monkeypatch) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=2, stdout="", stderr="bad input"), + ) + + result = solve_af_extensions( + af({"1"}, set()), + semantics="stable", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverError) + assert result.returncode == 2 + assert result.stderr == "bad input" + + +def test_iccma_af_adapter_reports_protocol_error(monkeypatch) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="maybe\n", stderr=""), + ) + + result = solve_af_extensions( + af({"1"}, set()), + semantics="stable", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.stdout == "maybe\n" + + +OFFICIAL_ICCMA_2023_MAIN_AF_PROBLEMS = { + ("DC", "complete"), + ("DC", "stable"), + ("DC", "semi-stable"), + ("DC", "stage"), + ("DS", "preferred"), + ("DS", "stable"), + ("DS", "semi-stable"), + ("DS", "stage"), + ("SE", "preferred"), + ("SE", "stable"), + ("SE", "semi-stable"), + ("SE", "stage"), + ("SE", "ideal"), +} + + +@given( + st.sampled_from(["DC", "DS", "SE"]), + st.sampled_from( + [ + "complete", + "grounded", + "preferred", + "stable", + "semi-stable", + "stage", + "ideal", + "cf2", + ] + ), +) +@settings(deadline=10000, max_examples=60) +def test_iccma_af_capability_table_matches_official_2023_main_track( + task: str, + semantics: str, +) -> None: + assert iccma_af.supports_af_problem(task, semantics) is ( + (task, semantics) in OFFICIAL_ICCMA_2023_MAIN_AF_PROBLEMS + ) + + +def test_iccma_af_rejects_unsupported_se_without_subprocess(monkeypatch) -> None: + calls = [] + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") + + monkeypatch.setattr("argumentation.solver_adapters.iccma_af.subprocess.run", fake_run) + + result = solve_af_extensions( + af({"1"}, set()), + semantics="complete", + binary="fake-iccma-solver", + ) + + assert isinstance(result, SolverUnavailable) + assert result.reason == "unsupported ICCMA 2023 AF problem: SE-CO" + assert calls == [] + + +def test_iccma_af_rejects_unsupported_acceptance_without_subprocess(monkeypatch) -> None: + calls = [] + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stdout="YES\nw 1\n", stderr="") + + monkeypatch.setattr("argumentation.solver_adapters.iccma_af.subprocess.run", fake_run) + + result = solve_af_acceptance( + af({"1"}, set()), + semantics="preferred", + task="credulous", + query="1", + binary="fake-iccma-solver", + ) + + assert isinstance(result, SolverUnavailable) + assert result.reason == "unsupported ICCMA 2023 AF problem: DC-PR" + assert calls == [] + + +@given(st.integers(min_value=1, max_value=5)) +@settings(deadline=10000, max_examples=20) +def test_iccma_se_stable_witness_must_pass_local_certificate_check(size: int) -> None: + # Stable SE witnesses must attack every argument outside the witness. + framework = af({str(index) for index in range(1, size + 1)}, set()) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="w\n", + stderr="", + ), + ) + + result = solve_af_extensions( + framework, + semantics="stable", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.stdout == "w\n" + + +@given(st.integers(min_value=2, max_value=5)) +@settings(deadline=10000, max_examples=20) +def test_iccma_dc_yes_stable_witness_must_pass_local_certificate_check( + size: int, +) -> None: + # Stable DC YES certificates must contain the query and attack every outsider. + framework = af({str(index) for index in range(1, size + 1)}, set()) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="YES\nw 1\n", + stderr="", + ), + ) + + result = solve_af_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.stdout == "YES\nw 1\n" + + +@given(st.integers(min_value=2, max_value=5)) +@settings(deadline=10000, max_examples=20) +def test_iccma_ds_no_stable_counterexample_must_pass_local_certificate_check( + size: int, +) -> None: + # Stable DS NO counterexamples must omit the query and attack every outsider. + framework = af({str(index) for index in range(1, size + 1)}, set()) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="NO\nw 2\n", + stderr="", + ), + ) + + result = solve_af_acceptance( + framework, + semantics="stable", + task="skeptical", + query="1", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.stdout == "NO\nw 2\n" + + +def test_iccma_backend_failures_use_shared_solver_result_classes() -> None: + assert ICCMASolverUnavailable is solver_module.SolverBackendUnavailable + assert ICCMASolverError is solver_module.SolverBackendError + assert ICCMASolverProtocolError is solver_module.SolverProtocolError + + +@given(st.from_regex(r"[A-Za-z_]{1,12}", fullmatch=True)) +@settings(deadline=10000, max_examples=30) +def test_iccma_source_derived_malformed_witnesses_are_protocol_errors( + bad_argument: str, +) -> None: + # ICCMA 2023 AF witness lines use indexed positive-integer arguments. + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_af.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=f"w 1 {bad_argument}\n", + stderr="protocol stderr", + ), + ) + + result = solve_af_extensions( + af({"1"}, set()), + semantics="stable", + binary="fake-iccma-solver", + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.problem == "SE-ST" + assert result.stdout == f"w 1 {bad_argument}\n" + assert result.stderr == "protocol stderr" + + +def test_optional_real_iccma_af_solver_smoke() -> None: + binary = os.environ.get("ICCMA_AF_SOLVER") + if not binary or (shutil.which(binary) is None and not Path(binary).exists()): + pytest.skip("set ICCMA_AF_SOLVER to an ICCMA 2023 AF solver executable") + + framework = af({"1", "2"}, {("1", "2")}) + result = solve_af_extensions(framework, semantics="stable", binary=binary) + + assert isinstance(result, ICCMASolverSuccess) + assert set(result.extensions) <= set(native_stable_extensions(framework)) + + +def test_parse_iccma_aba_2023_se_output_maps_numeric_assumption_ids() -> None: + framework = aba_framework(2) + + output = parse_iccma_aba_output("SE-ST", "w 1 2\n", framework=framework) + + assert output.kind is ICCMAABAOutputKind.SINGLE_EXTENSION + assert output.witness == frozenset({literal("a1"), literal("a2")}) + + +def test_parse_iccma_aba_2023_decision_output_has_no_certificate() -> None: + framework = aba_framework(1) + + dc = parse_iccma_aba_output( + "DC-CO", + "YES\n", + framework=framework, + query=literal("a1"), + ) + ds = parse_iccma_aba_output( + "DS-ST", + "NO\n", + framework=framework, + query=literal("a1"), + ) + + assert dc.kind is ICCMAABAOutputKind.DECISION + assert dc.answer is True + assert dc.witness is None + assert ds.answer is False + assert ds.witness is None + + +def test_parse_iccma_aba_2023_decision_output_rejects_certificates() -> None: + with pytest.raises(ICCMAABAOutputParseError, match="YES or NO"): + parse_iccma_aba_output( + "DC-ST", + "YES\nw 1\n", + framework=aba_framework(1), + query=literal("a1"), + ) + + +OFFICIAL_ASPFORABA_ICCMA_2023_PROBLEMS = { + ("DC", "complete"), + ("DC", "stable"), + ("DS", "preferred"), + ("DS", "stable"), + ("SE", "preferred"), + ("SE", "stable"), +} + + +@given( + st.sampled_from(["DC", "DS", "SE"]), + st.sampled_from( + [ + "complete", + "grounded", + "preferred", + "stable", + "semi-stable", + "stage", + "ideal", + "cf2", + ] + ), +) +@settings(deadline=10000, max_examples=60) +def test_iccma_aba_capability_table_matches_aspforaba_iccma_2023_source( + task: str, + semantics: str, +) -> None: + assert iccma_aba.supports_aba_problem(task, semantics) is ( + (task, semantics) in OFFICIAL_ASPFORABA_ICCMA_2023_PROBLEMS + ) + + +def test_iccma_aba_adapter_invokes_official_2023_cli_and_writes_numeric_aba( + monkeypatch, +) -> None: + framework = aba_framework(2) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[:4] == ["fake-aspforaba", "-p", "SE-ST", "-f"] + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + written = Path(command[4]).read_text(encoding="utf-8") + assert written.startswith("p aba 4\n") + parsed = parse_aba(written) + assert {item.atom.predicate for item in parsed.assumptions} == {"1", "2"} + return SimpleNamespace(returncode=0, stdout="w 1 2\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + fake_run, + ) + + result = solve_iccma_aba_extensions( + framework, + semantics="stable", + binary="fake-aspforaba", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.extensions == (frozenset({literal("a1"), literal("a2")}),) + assert calls and calls[0][1:4] == ["-p", "SE-ST", "-f"] + + +def test_iccma_aba_adapter_missing_binary_is_typed_unavailable(monkeypatch) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: None, + ) + + result = solve_iccma_aba_extensions( + aba_framework(1), + semantics="stable", + binary="definitely-missing-aspforaba", + ) + + assert isinstance(result, SolverUnavailable) + assert result.backend == "definitely-missing-aspforaba" + assert "not found" in result.reason + + +def test_iccma_aba_adapter_timeout_is_distinct_from_nonzero_and_protocol( + monkeypatch, +) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + raise subprocess.TimeoutExpired(command, timeout, output="partial out", stderr="partial err") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + fake_run, + ) + + result = solve_iccma_aba_extensions( + aba_framework(1), + semantics="stable", + binary="fake-aspforaba", + timeout_seconds=0.01, + ) + + assert isinstance(result, ICCMAABASolverError) + assert result.returncode == -1 + assert result.reason == "solver exited with code -1" + assert result.stdout == "partial out" + assert result.stderr == "partial err" + + +def test_iccma_aba_adapter_nonzero_exit_is_process_error(monkeypatch) -> None: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=2, + stdout="", + stderr="bad flags", + ), + ) + + result = solve_iccma_aba_extensions( + aba_framework(1), + semantics="stable", + binary="fake-aspforaba", + ) + + assert isinstance(result, ICCMAABASolverError) + assert result.returncode == 2 + assert result.stderr == "bad flags" + + +def test_iccma_aba_adapter_invokes_acceptance_with_query_atom(monkeypatch) -> None: + framework = aba_framework(1) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[:4] == ["fake-aspforaba", "-p", "DC-CO", "-f"] + assert command[5:] == ["-a", "1"] + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + return SimpleNamespace(returncode=0, stdout="YES\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + fake_run, + ) + + result = solve_iccma_aba_acceptance( + framework, + semantics="complete", + task="credulous", + query=literal("a1"), + binary="fake-aspforaba", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.answer is True + assert calls and calls[0][-2:] == ["-a", "1"] + + +def test_iccma_aba_adapter_accepts_python_module_command(monkeypatch) -> None: + framework = aba_framework(1) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[:5] == ["uv", "run", "python", "-m", "argumentation.solving.iccma_cli"] + assert command[5:8] == ["-p", "SE-ST", "-f"] + assert command[8].endswith(".aba") + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + return SimpleNamespace(returncode=0, stdout="w 1\n", stderr="") + + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + fake_run, + ) + + result = solve_iccma_aba_extensions( + framework, + semantics="stable", + binary="uv run python -m argumentation.solving.iccma_cli", + timeout_seconds=5.0, + ) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.extensions == (frozenset({literal("a1")}),) + assert calls + + +@given(st.integers(min_value=2, max_value=5)) +@settings(deadline=10000, max_examples=20) +def test_iccma_aba_se_witness_must_be_protocol_valid_assumptions(size: int) -> None: + framework = aba_framework(size) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="w 1\n", + stderr="", + ), + ) + + result = solve_iccma_aba_extensions( + framework, + semantics="stable", + binary="fake-aspforaba", + ) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.extensions == (frozenset({literal("a1")}),) + + +@given(st.integers(min_value=1, max_value=5)) +@settings(deadline=10000, max_examples=20) +def test_iccma_aba_generated_external_witnesses_validate_locally(size: int) -> None: + framework = aba_framework(size) + witness = " ".join(str(index) for index in range(1, size + 1)) + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=f"w {witness}\n", + stderr="", + ), + ) + + result = solve_iccma_aba_extensions( + framework, + semantics="stable", + binary="fake-aspforaba", + ) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.extensions[0] in support_extensions(framework, "stable") + + +@given(st.from_regex(r"[A-Za-z_]{1,12}", fullmatch=True)) +@settings(deadline=10000, max_examples=30) +def test_iccma_aba_malformed_witnesses_are_protocol_errors( + bad_atom: str, +) -> None: + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.iccma_aba.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=f"w 1 {bad_atom}\n", + stderr="protocol stderr", + ), + ) + + result = solve_iccma_aba_extensions( + aba_framework(1), + semantics="stable", + binary="fake-aspforaba", + ) + + assert isinstance(result, ICCMAABASolverProtocolError) + assert result.problem == "SE-ST" + assert result.stdout == f"w 1 {bad_atom}\n" + assert result.stderr == "protocol stderr" + + +def test_optional_real_aspforaba_solver_smoke() -> None: + binary = os.environ.get("ASPFORABA_SOLVER") or os.environ.get("ICCMA_ABA_SOLVER") + if not binary or (shutil.which(binary) is None and not Path(binary).exists()): + pytest.skip("set ASPFORABA_SOLVER or ICCMA_ABA_SOLVER to an ICCMA 2023 ABA solver") + + framework = aba_framework(2) + result = solve_iccma_aba_extensions(framework, semantics="stable", binary=binary) + + assert isinstance(result, ICCMAABASolverSuccess) + assert result.extensions == (framework.assumptions,) diff --git a/tests/test_solver_availability.py b/tests/solving/test_solver_availability.py similarity index 93% rename from tests/test_solver_availability.py rename to tests/solving/test_solver_availability.py index c5825c1..72a75d6 100644 --- a/tests/test_solver_availability.py +++ b/tests/solving/test_solver_availability.py @@ -1,1042 +1,1042 @@ -import pytest - -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom -from argumentation.aspic import Literal -from argumentation.aspic import Rule -from argumentation.dung import ArgumentationFramework -from argumentation.dung import complete_extensions -from argumentation.dung import grounded_extension -from argumentation.dung import preferred_extensions -from argumentation.dung import stable_extensions -import argumentation.solver as solver_module -from argumentation.solver import ( - AcceptanceSolverSuccess, - ExtensionSolverSuccess, - SingleExtensionSolverSuccess, - SolverBackendError, - SolverBackendUnavailable, - solve_aba_acceptance, - solve_aba_single_extension, - solve_dung_acceptance, - solve_dung_extensions, - solve_dung_single_extension, -) -from hypothesis import given, settings, strategies as st -from tests.test_dung import argumentation_frameworks -from argumentation.solver_adapters.iccma_af import ( - ICCMAOutput, - ICCMAOutputKind, - ICCMASolverError, - ICCMASolverProtocolError, - ICCMASolverSuccess, - ICCMASolverUnavailable, -) - - -def _literal(name: str) -> Literal: - return Literal(GroundAtom(name)) - - -def _simple_aba_framework() -> ABAFramework: - a = _literal("a") - b = _literal("b") - ca = _literal("ca") - cb = _literal("cb") - return ABAFramework( - language=frozenset({a, b, ca, cb}), - rules=frozenset(), - assumptions=frozenset({a, b}), - contrary={a: ca, b: cb}, - ) - - -def _large_dense_aba_framework() -> ABAFramework: - assumptions = tuple(_literal(f"a{index}") for index in range(151)) - contraries = {_literal(f"a{index}"): _literal(f"ca{index}") for index in range(151)} - heads = tuple(_literal(f"h{index}_{offset}") for index in range(151) for offset in range(26)) - rules = frozenset( - Rule((assumptions[index],), heads[index * 26 + offset], "strict") - for index in range(151) - for offset in range(26) - ) - return ABAFramework( - language=frozenset(assumptions) | frozenset(contraries.values()) | frozenset(heads), - rules=rules, - assumptions=frozenset(assumptions), - contrary=contraries, - ) - - -NATIVE_EXTENSION_ORACLES = { - "complete": complete_extensions, - "grounded": lambda framework: [grounded_extension(framework)], - "preferred": preferred_extensions, - "stable": stable_extensions, - "semi-stable": solver_module.semi_stable_extensions, - "stage": solver_module.stage_extensions, - "ideal": lambda framework: [solver_module.ideal_extension(framework)], -} - - -def test_solve_dung_extensions_defaults_to_auto_backend() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - result = solve_dung_extensions(framework, semantics="stable") - - assert isinstance(result, ExtensionSolverSuccess) - assert result.extensions == (frozenset({"a"}),) - - -def test_default_aba_single_extension_uses_multishot_when_clingo_available( - monkeypatch, -) -> None: - pytest.importorskip("clingo") - framework = _simple_aba_framework() - - def forbidden_sat(*args, **kwargs): - raise AssertionError("ABA preferred witness should use clingo multishot") - - monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) - monkeypatch.setattr(solver_module, "sat_aba_support_extension", forbidden_sat) - - result = solve_aba_single_extension(framework, semantics="preferred") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension is not None - - -def test_default_aba_stable_single_extension_uses_multishot_when_clingo_available( - monkeypatch, -) -> None: - pytest.importorskip("clingo") - framework = _simple_aba_framework() - - def forbidden_sat(*args, **kwargs): - raise AssertionError("ABA stable witness should use clingo multishot") - - monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) - monkeypatch.setattr(solver_module, "sat_aba_stable_extension", forbidden_sat) - - result = solve_aba_single_extension(framework, semantics="stable") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension is not None - - -def test_large_dense_aba_stable_single_extension_auto_uses_sat_not_clingo( - monkeypatch, -) -> None: - framework = _large_dense_aba_framework() - witness = frozenset({min(framework.assumptions, key=repr)}) - - def forbidden_asp(*args, **kwargs): - raise AssertionError("large dense ABA stable auto route should use SAT") - - monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) - monkeypatch.setattr(solver_module, "_solve_asp_aba_single_extension", forbidden_asp) - monkeypatch.setattr(solver_module, "sat_aba_stable_extension", lambda framework: witness) - - result = solve_aba_single_extension(framework, semantics="stable") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == witness - - -def test_default_aba_acceptance_uses_multishot_when_clingo_available( - monkeypatch, -) -> None: - pytest.importorskip("clingo") - framework = _simple_aba_framework() - - def forbidden_sat(*args, **kwargs): - raise AssertionError("ABA auto should prefer clingo multishot over SAT") - - monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) - monkeypatch.setattr(solver_module, "sat_aba_support_acceptance", forbidden_sat) - - result = solve_aba_acceptance( - framework, - semantics="preferred", - task="skeptical", - query=_literal("a"), - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is True - - -def test_solve_dung_extensions_default_auto_uses_sat_for_stable(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default stable solving should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_extensions(framework, semantics="stable") - - assert isinstance(result, ExtensionSolverSuccess) - assert result.extensions == (frozenset({"a"}),) - - -@given( - argumentation_frameworks(max_args=4), - st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), -) -@settings(deadline=10000, max_examples=40) -def test_native_backend_matches_direct_dung_semantic_oracles( - framework: ArgumentationFramework, - semantics: str, -) -> None: - result = solve_dung_extensions(framework, semantics=semantics, backend="native") - - assert isinstance(result, ExtensionSolverSuccess) - assert set(result.extensions) == set(NATIVE_EXTENSION_ORACLES[semantics](framework)) - - -def test_solve_dung_extensions_rejects_deleted_labelling_backend() -> None: - framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - - result = solve_dung_extensions(framework, semantics="stable", backend="labelling") - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "labelling" - assert result.install_hint == "Use backend='native'." - - -def test_solve_dung_extensions_rejects_deleted_z3_backend() -> None: - framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) - - result = solve_dung_extensions(framework, semantics="stable", backend="z3") - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "z3" - assert result.install_hint == "Use backend='native'." - - -def test_solve_dung_extensions_reports_unavailable_external_sat_backend() -> None: - framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) - - result = solve_dung_extensions( - framework, - semantics="stable", - backend="sat", - sat=solver_module.SATConfig(require_external=True), - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "sat" - assert result.reason == "external SAT backend is not configured" - - -def test_sat_backend_solves_stable_single_extension_without_native_enumeration() -> None: - arguments = frozenset(str(index) for index in range(1, 71)) - defeats = frozenset( - {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} - ) - framework = ArgumentationFramework(arguments=arguments, defeats=defeats) - - result = solve_dung_single_extension( - framework, - semantics="stable", - backend="sat", - ) - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"1"}) - - -def test_sat_backend_solves_stable_acceptance_without_native_enumeration() -> None: - arguments = frozenset(str(index) for index in range(1, 71)) - defeats = frozenset( - {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} - ) - framework = ArgumentationFramework(arguments=arguments, defeats=defeats) - - result = solve_dung_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - backend="sat", - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is True - assert result.witness == frozenset({"1"}) - - -def test_default_single_extension_uses_auto_stable_sat_backend() -> None: - arguments = frozenset(str(index) for index in range(1, 71)) - defeats = frozenset( - {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} - ) - framework = ArgumentationFramework(arguments=arguments, defeats=defeats) - - result = solve_dung_single_extension(framework, semantics="stable") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"1"}) - - -def test_default_acceptance_uses_auto_stable_sat_backend() -> None: - arguments = frozenset(str(index) for index in range(1, 71)) - defeats = frozenset( - {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} - ) - framework = ArgumentationFramework(arguments=arguments, defeats=defeats) - - result = solve_dung_acceptance( - framework, - semantics="stable", - task="skeptical", - query="2", - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is False - assert result.counterexample == frozenset({"1"}) - - -def test_default_single_extension_uses_auto_complete_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default complete solving should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_single_extension(framework, semantics="complete") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"a"}) - - -def test_default_acceptance_uses_auto_complete_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default complete solving should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - credulous = solve_dung_acceptance( - framework, - semantics="complete", - task="credulous", - query="a", - ) - skeptical = solve_dung_acceptance( - framework, - semantics="complete", - task="skeptical", - query="b", - ) - - assert isinstance(credulous, AcceptanceSolverSuccess) - assert credulous.answer is True - assert credulous.witness == frozenset({"a"}) - assert isinstance(skeptical, AcceptanceSolverSuccess) - assert skeptical.answer is False - assert skeptical.counterexample == frozenset({"a"}) - - -def test_default_single_extension_uses_auto_preferred_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default preferred witness should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_single_extension(framework, semantics="preferred") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"a"}) - - -def test_default_credulous_acceptance_uses_auto_preferred_sat_backend( - monkeypatch, -) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default preferred acceptance should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_acceptance( - framework, - semantics="preferred", - task="credulous", - query="a", - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is True - assert result.witness == frozenset({"a"}) - - -def test_default_skeptical_preferred_acceptance_uses_auto_sat_backend( - monkeypatch, -) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default skeptical preferred acceptance should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_acceptance( - framework, - semantics="preferred", - task="skeptical", - query="b", - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is False - assert result.counterexample is None - - -def test_default_single_extension_uses_auto_semi_stable_sat_backend( - monkeypatch, -) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default semi-stable witness should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_single_extension(framework, semantics="semi-stable") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"a"}) - - -def test_default_single_extension_uses_auto_stage_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default stage witness should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - result = solve_dung_single_extension(framework, semantics="stage") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"a"}) - - -def test_default_single_extension_uses_auto_ideal_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default ideal witness should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - monkeypatch.setattr( - solver_module, - "preferred_extensions", - forbidden_native_extensions, - ) - - result = solve_dung_single_extension(framework, semantics="ideal") - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"a"}) - - -def test_default_acceptance_uses_auto_ideal_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default ideal acceptance should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - monkeypatch.setattr( - solver_module, - "preferred_extensions", - forbidden_native_extensions, - ) - - result = solve_dung_acceptance( - framework, - semantics="ideal", - task="skeptical", - query="a", - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is True - - -def test_default_acceptance_uses_auto_semi_stable_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default semi-stable acceptance should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - credulous = solve_dung_acceptance( - framework, - semantics="semi-stable", - task="credulous", - query="a", - ) - skeptical = solve_dung_acceptance( - framework, - semantics="semi-stable", - task="skeptical", - query="b", - ) - - assert isinstance(credulous, AcceptanceSolverSuccess) - assert credulous.answer is True - assert credulous.witness == frozenset({"a"}) - assert isinstance(skeptical, AcceptanceSolverSuccess) - assert skeptical.answer is False - assert skeptical.counterexample == frozenset({"a"}) - - -def test_default_acceptance_uses_auto_stage_sat_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - def forbidden_native_extensions(*args, **kwargs): - raise AssertionError("default stage acceptance should not call native enumeration") - - monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) - - credulous = solve_dung_acceptance( - framework, - semantics="stage", - task="credulous", - query="a", - ) - skeptical = solve_dung_acceptance( - framework, - semantics="stage", - task="skeptical", - query="b", - ) - - assert isinstance(credulous, AcceptanceSolverSuccess) - assert credulous.answer is True - assert credulous.witness == frozenset({"a"}) - assert isinstance(skeptical, AcceptanceSolverSuccess) - assert skeptical.answer is False - assert skeptical.counterexample == frozenset({"a"}) - - -@given( - argumentation_frameworks(max_args=4), - st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), -) -@settings(deadline=10000, max_examples=40) -def test_sat_backend_enumeration_matches_native_dung_oracles( - framework: ArgumentationFramework, - semantics: str, -) -> None: - result = solve_dung_extensions(framework, semantics=semantics, backend="sat") - - assert isinstance(result, ExtensionSolverSuccess) - assert set(result.extensions) == set(NATIVE_EXTENSION_ORACLES[semantics](framework)) - - -@given( - argumentation_frameworks(max_args=4), - st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), -) -@settings(deadline=10000, max_examples=40) -def test_sat_backend_acceptance_matches_native_backend( - framework: ArgumentationFramework, - semantics: str, -) -> None: - query = sorted(framework.arguments)[0] - - for task in ("credulous", "skeptical"): - sat_result = solve_dung_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - backend="sat", - ) - native_result = solve_dung_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - backend="native", - ) - - assert isinstance(sat_result, AcceptanceSolverSuccess) - assert isinstance(native_result, AcceptanceSolverSuccess) - assert sat_result.answer is native_result.answer - _assert_dung_acceptance_witness_is_semantic( - framework, - semantics, - task, - query, - sat_result, - ) - - -def _assert_dung_acceptance_witness_is_semantic( - framework: ArgumentationFramework, - semantics: str, - task: str, - query: str, - result: AcceptanceSolverSuccess, -) -> None: - extensions = set(NATIVE_EXTENSION_ORACLES[semantics](framework)) - if task == "credulous": - if result.answer: - assert result.witness in extensions - assert result.witness is not None and query in result.witness - else: - assert result.witness is None - return - if result.answer: - assert result.counterexample is None - elif result.counterexample is not None: - assert result.counterexample in extensions - assert query not in result.counterexample - - -def test_solve_dung_extensions_rejects_iccma_single_witness_backend() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"1", "2", "3"}), - defeats=frozenset({("1", "2"), ("2", "1")}), - ) - - result = solve_dung_extensions( - framework, - semantics="preferred", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="fake-iccma"), - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.reason == "ICCMA AF SE tasks return one extension witness, not enumeration" - - -def test_solve_dung_single_extension_routes_explicit_iccma_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"1", "2"}), - defeats=frozenset({("1", "2")}), - ) - calls = [] - - def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): - calls.append((framework, semantics, binary, timeout_seconds)) - return ICCMASolverSuccess( - backend=binary, - problem="SE-ST", - stdout="w 1\n", - output=ICCMAOutput( - problem="SE-ST", - kind=ICCMAOutputKind.SINGLE_EXTENSION, - raw_stdout="w 1\n", - extensions=(frozenset({"1"}),), - witness=frozenset({"1"}), - ), - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_extensions", - fake_solve_af_extensions, - ) - - result = solve_dung_single_extension( - framework, - semantics="stable", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="fake-iccma", timeout_seconds=7.5), - ) - - assert isinstance(result, SingleExtensionSolverSuccess) - assert result.extension == frozenset({"1"}) - assert calls == [(framework, "stable", "fake-iccma", 7.5)] - - -def test_solve_dung_single_extension_maps_iccma_unavailable(monkeypatch) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - - def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): - return ICCMASolverUnavailable( - backend=binary, - reason="binary not found on PATH", - install_hint="install solver", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_extensions", - fake_solve_af_extensions, - ) - - result = solve_dung_single_extension( - framework, - semantics="grounded", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="missing"), - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "missing" - assert result.reason == "binary not found on PATH" - - -def test_solve_dung_single_extension_requires_iccma_config_before_subprocess( - monkeypatch, -) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - calls = [] - - def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): - calls.append((framework, semantics, binary, timeout_seconds)) - return ICCMASolverUnavailable( - backend=binary, - reason="should not be reached", - install_hint="should not be reached", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_extensions", - fake_solve_af_extensions, - ) - - result = solve_dung_single_extension( - framework, - semantics="grounded", - backend="iccma", - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "iccma" - assert result.reason == "missing ICCMA solver configuration" - assert calls == [] - - -def test_solve_dung_single_extension_maps_iccma_solver_error(monkeypatch) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - - def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): - return ICCMASolverError( - backend=binary, - problem="SE-ST", - returncode=2, - stderr="bad input", - stdout="", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_extensions", - fake_solve_af_extensions, - ) - - result = solve_dung_single_extension( - framework, - semantics="stable", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="bad-solver"), - ) - - assert isinstance(result, SolverBackendError) - assert result.backend == "bad-solver" - assert result.reason == "solver exited with code 2" - assert result.details["stderr"] == "bad input" - - -def test_solve_dung_single_extension_preserves_iccma_protocol_error(monkeypatch) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - - def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): - return ICCMASolverProtocolError( - backend=binary, - problem="SE-ST", - message="SE output must be one witness line or NO", - stderr="solver stderr", - stdout="w 1\nw 2\n", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_extensions", - fake_solve_af_extensions, - ) - - result = solve_dung_single_extension( - framework, - semantics="stable", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="bad-protocol"), - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.problem == "SE-ST" - assert result.stdout == "w 1\nw 2\n" - - -def test_solve_dung_acceptance_preserves_iccma_protocol_error(monkeypatch) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - - def fake_solve_af_acceptance( - *, - framework, - semantics, - task, - query, - binary, - timeout_seconds, - certificate_required, - ): - return ICCMASolverProtocolError( - backend=binary, - problem="DC-ST", - message="decision output must start with YES or NO", - stderr="solver stderr", - stdout="MAYBE\n", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_acceptance", - fake_solve_af_acceptance, - ) - - result = solve_dung_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="bad-protocol"), - ) - - assert isinstance(result, ICCMASolverProtocolError) - assert result.problem == "DC-ST" - assert result.stdout == "MAYBE\n" - - -def test_solve_dung_acceptance_requires_iccma_config_before_subprocess( - monkeypatch, -) -> None: - framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) - calls = [] - - def fake_solve_af_acceptance( - *, - framework, - semantics, - task, - query, - binary, - timeout_seconds, - certificate_required, - ): - calls.append( - ( - framework, - semantics, - task, - query, - binary, - timeout_seconds, - certificate_required, - ) - ) - return ICCMASolverUnavailable( - backend=binary, - reason="should not be reached", - install_hint="should not be reached", - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_acceptance", - fake_solve_af_acceptance, - ) - - result = solve_dung_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - backend="iccma", - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "iccma" - assert result.reason == "missing ICCMA solver configuration" - assert calls == [] - - -def test_solve_dung_acceptance_native_backend_returns_witnesses() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b")}), - ) - - credulous = solve_dung_acceptance( - framework, - semantics="stable", - task="credulous", - query="a", - ) - skeptical = solve_dung_acceptance( - framework, - semantics="stable", - task="skeptical", - query="b", - ) - - assert isinstance(credulous, AcceptanceSolverSuccess) - assert credulous.answer is True - assert credulous.witness == frozenset({"a"}) - assert isinstance(skeptical, AcceptanceSolverSuccess) - assert skeptical.answer is False - assert skeptical.counterexample == frozenset({"a"}) - - -def test_solve_dung_acceptance_routes_explicit_iccma_backend(monkeypatch) -> None: - framework = ArgumentationFramework( - arguments=frozenset({"1", "2"}), - defeats=frozenset({("1", "2")}), - ) - calls = [] - - def fake_solve_af_acceptance( - *, - framework, - semantics, - task, - query, - binary, - timeout_seconds, - certificate_required, - ): - calls.append( - ( - framework, - semantics, - task, - query, - binary, - timeout_seconds, - certificate_required, - ) - ) - return ICCMASolverSuccess( - backend=binary, - problem="DC-ST", - stdout="YES\nw 1\n", - output=ICCMAOutput( - problem="DC-ST", - kind=ICCMAOutputKind.DECISION, - raw_stdout="YES\nw 1\n", - answer=True, - witness=frozenset({"1"}), - extensions=(frozenset({"1"}),), - ), - ) - - monkeypatch.setattr( - "argumentation.solver.iccma_af.solve_af_acceptance", - fake_solve_af_acceptance, - ) - - result = solve_dung_acceptance( - framework, - semantics="stable", - task="credulous", - query="1", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="fake-iccma", timeout_seconds=7.5), - ) - - assert isinstance(result, AcceptanceSolverSuccess) - assert result.answer is True - assert result.witness == frozenset({"1"}) - assert calls == [(framework, "stable", "credulous", "1", "fake-iccma", 7.5, True)] - - -def complete_mutual_attack_frameworks(): - return st.integers(min_value=2, max_value=5).map( - lambda size: ArgumentationFramework( - arguments=frozenset(str(index) for index in range(1, size + 1)), - defeats=frozenset( - (str(attacker), str(target)) - for attacker in range(1, size + 1) - for target in range(1, size + 1) - if attacker != target - ), - ) - ) - - -def test_solver_success_result_types_are_task_specific() -> None: - assert ExtensionSolverSuccess is not SingleExtensionSolverSuccess - assert ExtensionSolverSuccess is not AcceptanceSolverSuccess - assert SingleExtensionSolverSuccess is not AcceptanceSolverSuccess - - -@given(complete_mutual_attack_frameworks()) -@settings(deadline=10000, max_examples=20) -def test_iccma_single_extension_backend_is_not_enumeration_for_multi_extension_afs( - framework: ArgumentationFramework, -) -> None: - # Complete mutual attack graphs have one preferred extension per argument. - assert len(preferred_extensions(framework)) == len(framework.arguments) - - enumeration = solve_dung_extensions( - framework, - semantics="preferred", - backend="iccma", - iccma=solver_module.ICCMAConfig(binary="fake-iccma"), - ) - single = solve_dung_single_extension(framework, semantics="preferred") - - assert isinstance(enumeration, SolverBackendUnavailable) - assert enumeration.reason == "ICCMA AF SE tasks return one extension witness, not enumeration" - assert isinstance(single, SingleExtensionSolverSuccess) - assert not isinstance(single, ExtensionSolverSuccess) +import pytest + +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom +from argumentation.structured.aspic.aspic import Literal +from argumentation.structured.aspic.aspic import Rule +from argumentation.core.dung import ArgumentationFramework +from argumentation.core.dung import complete_extensions +from argumentation.core.dung import grounded_extension +from argumentation.core.dung import preferred_extensions +from argumentation.core.dung import stable_extensions +import argumentation.solving.solver as solver_module +from argumentation.solving.solver import ( + AcceptanceSolverSuccess, + ExtensionSolverSuccess, + SingleExtensionSolverSuccess, + SolverBackendError, + SolverBackendUnavailable, + solve_aba_acceptance, + solve_aba_single_extension, + solve_dung_acceptance, + solve_dung_extensions, + solve_dung_single_extension, +) +from hypothesis import given, settings, strategies as st +from tests.core.test_dung import argumentation_frameworks +from argumentation.solver_adapters.iccma_af import ( + ICCMAOutput, + ICCMAOutputKind, + ICCMASolverError, + ICCMASolverProtocolError, + ICCMASolverSuccess, + ICCMASolverUnavailable, +) + + +def _literal(name: str) -> Literal: + return Literal(GroundAtom(name)) + + +def _simple_aba_framework() -> ABAFramework: + a = _literal("a") + b = _literal("b") + ca = _literal("ca") + cb = _literal("cb") + return ABAFramework( + language=frozenset({a, b, ca, cb}), + rules=frozenset(), + assumptions=frozenset({a, b}), + contrary={a: ca, b: cb}, + ) + + +def _large_dense_aba_framework() -> ABAFramework: + assumptions = tuple(_literal(f"a{index}") for index in range(151)) + contraries = {_literal(f"a{index}"): _literal(f"ca{index}") for index in range(151)} + heads = tuple(_literal(f"h{index}_{offset}") for index in range(151) for offset in range(26)) + rules = frozenset( + Rule((assumptions[index],), heads[index * 26 + offset], "strict") + for index in range(151) + for offset in range(26) + ) + return ABAFramework( + language=frozenset(assumptions) | frozenset(contraries.values()) | frozenset(heads), + rules=rules, + assumptions=frozenset(assumptions), + contrary=contraries, + ) + + +NATIVE_EXTENSION_ORACLES = { + "complete": complete_extensions, + "grounded": lambda framework: [grounded_extension(framework)], + "preferred": preferred_extensions, + "stable": stable_extensions, + "semi-stable": solver_module.semi_stable_extensions, + "stage": solver_module.stage_extensions, + "ideal": lambda framework: [solver_module.ideal_extension(framework)], +} + + +def test_solve_dung_extensions_defaults_to_auto_backend() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + result = solve_dung_extensions(framework, semantics="stable") + + assert isinstance(result, ExtensionSolverSuccess) + assert result.extensions == (frozenset({"a"}),) + + +def test_default_aba_single_extension_uses_multishot_when_clingo_available( + monkeypatch, +) -> None: + pytest.importorskip("clingo") + framework = _simple_aba_framework() + + def forbidden_sat(*args, **kwargs): + raise AssertionError("ABA preferred witness should use clingo multishot") + + monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) + monkeypatch.setattr(solver_module, "sat_aba_support_extension", forbidden_sat) + + result = solve_aba_single_extension(framework, semantics="preferred") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension is not None + + +def test_default_aba_stable_single_extension_uses_multishot_when_clingo_available( + monkeypatch, +) -> None: + pytest.importorskip("clingo") + framework = _simple_aba_framework() + + def forbidden_sat(*args, **kwargs): + raise AssertionError("ABA stable witness should use clingo multishot") + + monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) + monkeypatch.setattr(solver_module, "sat_aba_stable_extension", forbidden_sat) + + result = solve_aba_single_extension(framework, semantics="stable") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension is not None + + +def test_large_dense_aba_stable_single_extension_auto_uses_sat_not_clingo( + monkeypatch, +) -> None: + framework = _large_dense_aba_framework() + witness = frozenset({min(framework.assumptions, key=repr)}) + + def forbidden_asp(*args, **kwargs): + raise AssertionError("large dense ABA stable auto route should use SAT") + + monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) + monkeypatch.setattr(solver_module, "_solve_asp_aba_single_extension", forbidden_asp) + monkeypatch.setattr(solver_module, "sat_aba_stable_extension", lambda framework: witness) + + result = solve_aba_single_extension(framework, semantics="stable") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == witness + + +def test_default_aba_acceptance_uses_multishot_when_clingo_available( + monkeypatch, +) -> None: + pytest.importorskip("clingo") + framework = _simple_aba_framework() + + def forbidden_sat(*args, **kwargs): + raise AssertionError("ABA auto should prefer clingo multishot over SAT") + + monkeypatch.setattr(solver_module, "_has_clingo", lambda: True) + monkeypatch.setattr(solver_module, "sat_aba_support_acceptance", forbidden_sat) + + result = solve_aba_acceptance( + framework, + semantics="preferred", + task="skeptical", + query=_literal("a"), + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is True + + +def test_solve_dung_extensions_default_auto_uses_sat_for_stable(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default stable solving should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_extensions(framework, semantics="stable") + + assert isinstance(result, ExtensionSolverSuccess) + assert result.extensions == (frozenset({"a"}),) + + +@given( + argumentation_frameworks(max_args=4), + st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), +) +@settings(deadline=10000, max_examples=40) +def test_native_backend_matches_direct_dung_semantic_oracles( + framework: ArgumentationFramework, + semantics: str, +) -> None: + result = solve_dung_extensions(framework, semantics=semantics, backend="native") + + assert isinstance(result, ExtensionSolverSuccess) + assert set(result.extensions) == set(NATIVE_EXTENSION_ORACLES[semantics](framework)) + + +def test_solve_dung_extensions_rejects_deleted_labelling_backend() -> None: + framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + + result = solve_dung_extensions(framework, semantics="stable", backend="labelling") + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "labelling" + assert result.install_hint == "Use backend='native'." + + +def test_solve_dung_extensions_rejects_deleted_z3_backend() -> None: + framework = ArgumentationFramework(arguments=frozenset(), defeats=frozenset()) + + result = solve_dung_extensions(framework, semantics="stable", backend="z3") + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "z3" + assert result.install_hint == "Use backend='native'." + + +def test_solve_dung_extensions_reports_unavailable_external_sat_backend() -> None: + framework = ArgumentationFramework(arguments=frozenset({"a"}), defeats=frozenset()) + + result = solve_dung_extensions( + framework, + semantics="stable", + backend="sat", + sat=solver_module.SATConfig(require_external=True), + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "sat" + assert result.reason == "external SAT backend is not configured" + + +def test_sat_backend_solves_stable_single_extension_without_native_enumeration() -> None: + arguments = frozenset(str(index) for index in range(1, 71)) + defeats = frozenset( + {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} + ) + framework = ArgumentationFramework(arguments=arguments, defeats=defeats) + + result = solve_dung_single_extension( + framework, + semantics="stable", + backend="sat", + ) + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"1"}) + + +def test_sat_backend_solves_stable_acceptance_without_native_enumeration() -> None: + arguments = frozenset(str(index) for index in range(1, 71)) + defeats = frozenset( + {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} + ) + framework = ArgumentationFramework(arguments=arguments, defeats=defeats) + + result = solve_dung_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + backend="sat", + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is True + assert result.witness == frozenset({"1"}) + + +def test_default_single_extension_uses_auto_stable_sat_backend() -> None: + arguments = frozenset(str(index) for index in range(1, 71)) + defeats = frozenset( + {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} + ) + framework = ArgumentationFramework(arguments=arguments, defeats=defeats) + + result = solve_dung_single_extension(framework, semantics="stable") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"1"}) + + +def test_default_acceptance_uses_auto_stable_sat_backend() -> None: + arguments = frozenset(str(index) for index in range(1, 71)) + defeats = frozenset( + {("1", str(index)) for index in range(2, 71)} | {("2", "3"), ("3", "2")} + ) + framework = ArgumentationFramework(arguments=arguments, defeats=defeats) + + result = solve_dung_acceptance( + framework, + semantics="stable", + task="skeptical", + query="2", + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is False + assert result.counterexample == frozenset({"1"}) + + +def test_default_single_extension_uses_auto_complete_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default complete solving should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_single_extension(framework, semantics="complete") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"a"}) + + +def test_default_acceptance_uses_auto_complete_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default complete solving should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + credulous = solve_dung_acceptance( + framework, + semantics="complete", + task="credulous", + query="a", + ) + skeptical = solve_dung_acceptance( + framework, + semantics="complete", + task="skeptical", + query="b", + ) + + assert isinstance(credulous, AcceptanceSolverSuccess) + assert credulous.answer is True + assert credulous.witness == frozenset({"a"}) + assert isinstance(skeptical, AcceptanceSolverSuccess) + assert skeptical.answer is False + assert skeptical.counterexample == frozenset({"a"}) + + +def test_default_single_extension_uses_auto_preferred_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default preferred witness should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_single_extension(framework, semantics="preferred") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"a"}) + + +def test_default_credulous_acceptance_uses_auto_preferred_sat_backend( + monkeypatch, +) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default preferred acceptance should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_acceptance( + framework, + semantics="preferred", + task="credulous", + query="a", + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is True + assert result.witness == frozenset({"a"}) + + +def test_default_skeptical_preferred_acceptance_uses_auto_sat_backend( + monkeypatch, +) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default skeptical preferred acceptance should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_acceptance( + framework, + semantics="preferred", + task="skeptical", + query="b", + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is False + assert result.counterexample is None + + +def test_default_single_extension_uses_auto_semi_stable_sat_backend( + monkeypatch, +) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default semi-stable witness should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_single_extension(framework, semantics="semi-stable") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"a"}) + + +def test_default_single_extension_uses_auto_stage_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default stage witness should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + result = solve_dung_single_extension(framework, semantics="stage") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"a"}) + + +def test_default_single_extension_uses_auto_ideal_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default ideal witness should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + monkeypatch.setattr( + solver_module, + "preferred_extensions", + forbidden_native_extensions, + ) + + result = solve_dung_single_extension(framework, semantics="ideal") + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"a"}) + + +def test_default_acceptance_uses_auto_ideal_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default ideal acceptance should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + monkeypatch.setattr( + solver_module, + "preferred_extensions", + forbidden_native_extensions, + ) + + result = solve_dung_acceptance( + framework, + semantics="ideal", + task="skeptical", + query="a", + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is True + + +def test_default_acceptance_uses_auto_semi_stable_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default semi-stable acceptance should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + credulous = solve_dung_acceptance( + framework, + semantics="semi-stable", + task="credulous", + query="a", + ) + skeptical = solve_dung_acceptance( + framework, + semantics="semi-stable", + task="skeptical", + query="b", + ) + + assert isinstance(credulous, AcceptanceSolverSuccess) + assert credulous.answer is True + assert credulous.witness == frozenset({"a"}) + assert isinstance(skeptical, AcceptanceSolverSuccess) + assert skeptical.answer is False + assert skeptical.counterexample == frozenset({"a"}) + + +def test_default_acceptance_uses_auto_stage_sat_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + def forbidden_native_extensions(*args, **kwargs): + raise AssertionError("default stage acceptance should not call native enumeration") + + monkeypatch.setattr(solver_module, "_dung_extensions", forbidden_native_extensions) + + credulous = solve_dung_acceptance( + framework, + semantics="stage", + task="credulous", + query="a", + ) + skeptical = solve_dung_acceptance( + framework, + semantics="stage", + task="skeptical", + query="b", + ) + + assert isinstance(credulous, AcceptanceSolverSuccess) + assert credulous.answer is True + assert credulous.witness == frozenset({"a"}) + assert isinstance(skeptical, AcceptanceSolverSuccess) + assert skeptical.answer is False + assert skeptical.counterexample == frozenset({"a"}) + + +@given( + argumentation_frameworks(max_args=4), + st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), +) +@settings(deadline=10000, max_examples=40) +def test_sat_backend_enumeration_matches_native_dung_oracles( + framework: ArgumentationFramework, + semantics: str, +) -> None: + result = solve_dung_extensions(framework, semantics=semantics, backend="sat") + + assert isinstance(result, ExtensionSolverSuccess) + assert set(result.extensions) == set(NATIVE_EXTENSION_ORACLES[semantics](framework)) + + +@given( + argumentation_frameworks(max_args=4), + st.sampled_from(sorted(NATIVE_EXTENSION_ORACLES)), +) +@settings(deadline=10000, max_examples=40) +def test_sat_backend_acceptance_matches_native_backend( + framework: ArgumentationFramework, + semantics: str, +) -> None: + query = sorted(framework.arguments)[0] + + for task in ("credulous", "skeptical"): + sat_result = solve_dung_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + backend="sat", + ) + native_result = solve_dung_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + backend="native", + ) + + assert isinstance(sat_result, AcceptanceSolverSuccess) + assert isinstance(native_result, AcceptanceSolverSuccess) + assert sat_result.answer is native_result.answer + _assert_dung_acceptance_witness_is_semantic( + framework, + semantics, + task, + query, + sat_result, + ) + + +def _assert_dung_acceptance_witness_is_semantic( + framework: ArgumentationFramework, + semantics: str, + task: str, + query: str, + result: AcceptanceSolverSuccess, +) -> None: + extensions = set(NATIVE_EXTENSION_ORACLES[semantics](framework)) + if task == "credulous": + if result.answer: + assert result.witness in extensions + assert result.witness is not None and query in result.witness + else: + assert result.witness is None + return + if result.answer: + assert result.counterexample is None + elif result.counterexample is not None: + assert result.counterexample in extensions + assert query not in result.counterexample + + +def test_solve_dung_extensions_rejects_iccma_single_witness_backend() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"1", "2", "3"}), + defeats=frozenset({("1", "2"), ("2", "1")}), + ) + + result = solve_dung_extensions( + framework, + semantics="preferred", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="fake-iccma"), + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.reason == "ICCMA AF SE tasks return one extension witness, not enumeration" + + +def test_solve_dung_single_extension_routes_explicit_iccma_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"1", "2"}), + defeats=frozenset({("1", "2")}), + ) + calls = [] + + def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): + calls.append((framework, semantics, binary, timeout_seconds)) + return ICCMASolverSuccess( + backend=binary, + problem="SE-ST", + stdout="w 1\n", + output=ICCMAOutput( + problem="SE-ST", + kind=ICCMAOutputKind.SINGLE_EXTENSION, + raw_stdout="w 1\n", + extensions=(frozenset({"1"}),), + witness=frozenset({"1"}), + ), + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_extensions", + fake_solve_af_extensions, + ) + + result = solve_dung_single_extension( + framework, + semantics="stable", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="fake-iccma", timeout_seconds=7.5), + ) + + assert isinstance(result, SingleExtensionSolverSuccess) + assert result.extension == frozenset({"1"}) + assert calls == [(framework, "stable", "fake-iccma", 7.5)] + + +def test_solve_dung_single_extension_maps_iccma_unavailable(monkeypatch) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + + def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): + return ICCMASolverUnavailable( + backend=binary, + reason="binary not found on PATH", + install_hint="install solver", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_extensions", + fake_solve_af_extensions, + ) + + result = solve_dung_single_extension( + framework, + semantics="grounded", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="missing"), + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "missing" + assert result.reason == "binary not found on PATH" + + +def test_solve_dung_single_extension_requires_iccma_config_before_subprocess( + monkeypatch, +) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + calls = [] + + def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): + calls.append((framework, semantics, binary, timeout_seconds)) + return ICCMASolverUnavailable( + backend=binary, + reason="should not be reached", + install_hint="should not be reached", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_extensions", + fake_solve_af_extensions, + ) + + result = solve_dung_single_extension( + framework, + semantics="grounded", + backend="iccma", + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "iccma" + assert result.reason == "missing ICCMA solver configuration" + assert calls == [] + + +def test_solve_dung_single_extension_maps_iccma_solver_error(monkeypatch) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + + def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): + return ICCMASolverError( + backend=binary, + problem="SE-ST", + returncode=2, + stderr="bad input", + stdout="", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_extensions", + fake_solve_af_extensions, + ) + + result = solve_dung_single_extension( + framework, + semantics="stable", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="bad-solver"), + ) + + assert isinstance(result, SolverBackendError) + assert result.backend == "bad-solver" + assert result.reason == "solver exited with code 2" + assert result.details["stderr"] == "bad input" + + +def test_solve_dung_single_extension_preserves_iccma_protocol_error(monkeypatch) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + + def fake_solve_af_extensions(*, framework, semantics, binary, timeout_seconds): + return ICCMASolverProtocolError( + backend=binary, + problem="SE-ST", + message="SE output must be one witness line or NO", + stderr="solver stderr", + stdout="w 1\nw 2\n", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_extensions", + fake_solve_af_extensions, + ) + + result = solve_dung_single_extension( + framework, + semantics="stable", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="bad-protocol"), + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.problem == "SE-ST" + assert result.stdout == "w 1\nw 2\n" + + +def test_solve_dung_acceptance_preserves_iccma_protocol_error(monkeypatch) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + + def fake_solve_af_acceptance( + *, + framework, + semantics, + task, + query, + binary, + timeout_seconds, + certificate_required, + ): + return ICCMASolverProtocolError( + backend=binary, + problem="DC-ST", + message="decision output must start with YES or NO", + stderr="solver stderr", + stdout="MAYBE\n", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_acceptance", + fake_solve_af_acceptance, + ) + + result = solve_dung_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="bad-protocol"), + ) + + assert isinstance(result, ICCMASolverProtocolError) + assert result.problem == "DC-ST" + assert result.stdout == "MAYBE\n" + + +def test_solve_dung_acceptance_requires_iccma_config_before_subprocess( + monkeypatch, +) -> None: + framework = ArgumentationFramework(arguments=frozenset({"1"}), defeats=frozenset()) + calls = [] + + def fake_solve_af_acceptance( + *, + framework, + semantics, + task, + query, + binary, + timeout_seconds, + certificate_required, + ): + calls.append( + ( + framework, + semantics, + task, + query, + binary, + timeout_seconds, + certificate_required, + ) + ) + return ICCMASolverUnavailable( + backend=binary, + reason="should not be reached", + install_hint="should not be reached", + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_acceptance", + fake_solve_af_acceptance, + ) + + result = solve_dung_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + backend="iccma", + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "iccma" + assert result.reason == "missing ICCMA solver configuration" + assert calls == [] + + +def test_solve_dung_acceptance_native_backend_returns_witnesses() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b")}), + ) + + credulous = solve_dung_acceptance( + framework, + semantics="stable", + task="credulous", + query="a", + ) + skeptical = solve_dung_acceptance( + framework, + semantics="stable", + task="skeptical", + query="b", + ) + + assert isinstance(credulous, AcceptanceSolverSuccess) + assert credulous.answer is True + assert credulous.witness == frozenset({"a"}) + assert isinstance(skeptical, AcceptanceSolverSuccess) + assert skeptical.answer is False + assert skeptical.counterexample == frozenset({"a"}) + + +def test_solve_dung_acceptance_routes_explicit_iccma_backend(monkeypatch) -> None: + framework = ArgumentationFramework( + arguments=frozenset({"1", "2"}), + defeats=frozenset({("1", "2")}), + ) + calls = [] + + def fake_solve_af_acceptance( + *, + framework, + semantics, + task, + query, + binary, + timeout_seconds, + certificate_required, + ): + calls.append( + ( + framework, + semantics, + task, + query, + binary, + timeout_seconds, + certificate_required, + ) + ) + return ICCMASolverSuccess( + backend=binary, + problem="DC-ST", + stdout="YES\nw 1\n", + output=ICCMAOutput( + problem="DC-ST", + kind=ICCMAOutputKind.DECISION, + raw_stdout="YES\nw 1\n", + answer=True, + witness=frozenset({"1"}), + extensions=(frozenset({"1"}),), + ), + ) + + monkeypatch.setattr( + "argumentation.solving.solver.iccma_af.solve_af_acceptance", + fake_solve_af_acceptance, + ) + + result = solve_dung_acceptance( + framework, + semantics="stable", + task="credulous", + query="1", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="fake-iccma", timeout_seconds=7.5), + ) + + assert isinstance(result, AcceptanceSolverSuccess) + assert result.answer is True + assert result.witness == frozenset({"1"}) + assert calls == [(framework, "stable", "credulous", "1", "fake-iccma", 7.5, True)] + + +def complete_mutual_attack_frameworks(): + return st.integers(min_value=2, max_value=5).map( + lambda size: ArgumentationFramework( + arguments=frozenset(str(index) for index in range(1, size + 1)), + defeats=frozenset( + (str(attacker), str(target)) + for attacker in range(1, size + 1) + for target in range(1, size + 1) + if attacker != target + ), + ) + ) + + +def test_solver_success_result_types_are_task_specific() -> None: + assert ExtensionSolverSuccess is not SingleExtensionSolverSuccess + assert ExtensionSolverSuccess is not AcceptanceSolverSuccess + assert SingleExtensionSolverSuccess is not AcceptanceSolverSuccess + + +@given(complete_mutual_attack_frameworks()) +@settings(deadline=10000, max_examples=20) +def test_iccma_single_extension_backend_is_not_enumeration_for_multi_extension_afs( + framework: ArgumentationFramework, +) -> None: + # Complete mutual attack graphs have one preferred extension per argument. + assert len(preferred_extensions(framework)) == len(framework.arguments) + + enumeration = solve_dung_extensions( + framework, + semantics="preferred", + backend="iccma", + iccma=solver_module.ICCMAConfig(binary="fake-iccma"), + ) + single = solve_dung_single_extension(framework, semantics="preferred") + + assert isinstance(enumeration, SolverBackendUnavailable) + assert enumeration.reason == "ICCMA AF SE tasks return one extension witness, not enumeration" + assert isinstance(single, SingleExtensionSolverSuccess) + assert not isinstance(single, ExtensionSolverSuccess) diff --git a/tests/test_solver_differential.py b/tests/solving/test_solver_differential.py similarity index 89% rename from tests/test_solver_differential.py rename to tests/solving/test_solver_differential.py index d408980..667179a 100644 --- a/tests/test_solver_differential.py +++ b/tests/solving/test_solver_differential.py @@ -1,148 +1,148 @@ -from __future__ import annotations - -import json - -import pytest -from hypothesis import given, settings, strategies as st - -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.dung import ArgumentationFramework -from argumentation.solver import ( - AcceptanceSolverSuccess, - ExtensionSolverSuccess, - SingleExtensionSolverSuccess, - solve_aba_acceptance, - solve_dung_extensions, -) -from argumentation.solver_differential import ( - assert_solver_results_agree, - load_benchmark_manifest, - run_benchmark_smoke, - solver_capability_matrix, -) -from tests.test_dung import argumentation_frameworks - - -@st.composite -def flat_aba_frameworks(draw): - size = draw(st.integers(min_value=1, max_value=3)) - attacks = draw( - st.frozensets( - st.tuples( - st.integers(min_value=1, max_value=size), - st.integers(min_value=1, max_value=size), - ), - max_size=size * size, - ) - ) - assumptions = {literal(f"a{index}") for index in range(1, size + 1)} - contraries = {literal(f"c{index}") for index in range(1, size + 1)} - assumption_by_index = { - index: literal(f"a{index}") for index in range(1, size + 1) - } - contrary_by_index = { - index: literal(f"c{index}") for index in range(1, size + 1) - } - return ABAFramework( - language=frozenset(assumptions | contraries), - rules=frozenset( - Rule((assumption_by_index[attacker],), contrary_by_index[target], "strict") - for attacker, target in attacks - ), - assumptions=frozenset(assumptions), - contrary={ - assumption_by_index[index]: contrary_by_index[index] - for index in range(1, size + 1) - }, - ) - - -@given(argumentation_frameworks(max_args=4), st.sampled_from(["complete", "stable"])) -@settings(deadline=10000, max_examples=30) -def test_differential_helper_compares_generated_dung_enumeration( - framework: ArgumentationFramework, - semantics: str, -) -> None: - native = solve_dung_extensions(framework, semantics=semantics, backend="native") - sat = solve_dung_extensions(framework, semantics=semantics, backend="sat") - - assert_solver_results_agree("enumeration", native, sat) - - -@given( - flat_aba_frameworks(), - st.sampled_from(["complete", "stable"]), - st.sampled_from(["credulous", "skeptical"]), -) -@settings(deadline=10000, max_examples=30) -def test_differential_helper_compares_generated_aba_acceptance( - framework: ABAFramework, - semantics: str, - task: str, -) -> None: - query = sorted(framework.language, key=repr)[0] - native = solve_aba_acceptance( - framework, - semantics=semantics, - task=task, - query=query, - ) - - assert_solver_results_agree("acceptance", native, native) - - -def test_differential_helper_rejects_enumeration_single_extension_mismatch() -> None: - with pytest.raises( - AssertionError, - match="cannot compare enumeration result to single-extension result", - ): - assert_solver_results_agree( - "enumeration", - ExtensionSolverSuccess((frozenset({"a"}),)), - SingleExtensionSolverSuccess(frozenset({"a"})), - ) - - -def test_benchmark_smoke_reads_manifest_without_external_solver_execution(tmp_path) -> None: - manifest_path = tmp_path / "manifest.json" - manifest_path.write_text( - json.dumps( - [ - { - "id": "tiny-af", - "formalism": "af", - "task": "SE", - "semantics": "stable", - "path": "fixtures/tiny.af", - } - ] - ), - encoding="utf-8", - ) - - manifest = load_benchmark_manifest(manifest_path) - result = run_benchmark_smoke(manifest, execute_external=False) - - assert result.total == 1 - assert result.executed_external == 0 - assert result.skipped_external == 1 - - -def test_capability_matrix_reports_unsupported_combinations_explicitly() -> None: - matrix = solver_capability_matrix() - - assert matrix - assert any(not entry.supported for entry in matrix) - assert all(entry.reason for entry in matrix if not entry.supported) - assert any( - entry.formalism == "aba" - and entry.backend == "iccma" - and entry.task == "single-extension" - and entry.semantics == "stable" - and entry.supported - for entry in matrix - ) - -def literal(name: str) -> Literal: - return Literal(GroundAtom(name)) +from __future__ import annotations + +import json + +import pytest +from hypothesis import given, settings, strategies as st + +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.core.dung import ArgumentationFramework +from argumentation.solving.solver import ( + AcceptanceSolverSuccess, + ExtensionSolverSuccess, + SingleExtensionSolverSuccess, + solve_aba_acceptance, + solve_dung_extensions, +) +from argumentation.solving.solver_differential import ( + assert_solver_results_agree, + load_benchmark_manifest, + run_benchmark_smoke, + solver_capability_matrix, +) +from tests.core.test_dung import argumentation_frameworks + + +@st.composite +def flat_aba_frameworks(draw): + size = draw(st.integers(min_value=1, max_value=3)) + attacks = draw( + st.frozensets( + st.tuples( + st.integers(min_value=1, max_value=size), + st.integers(min_value=1, max_value=size), + ), + max_size=size * size, + ) + ) + assumptions = {literal(f"a{index}") for index in range(1, size + 1)} + contraries = {literal(f"c{index}") for index in range(1, size + 1)} + assumption_by_index = { + index: literal(f"a{index}") for index in range(1, size + 1) + } + contrary_by_index = { + index: literal(f"c{index}") for index in range(1, size + 1) + } + return ABAFramework( + language=frozenset(assumptions | contraries), + rules=frozenset( + Rule((assumption_by_index[attacker],), contrary_by_index[target], "strict") + for attacker, target in attacks + ), + assumptions=frozenset(assumptions), + contrary={ + assumption_by_index[index]: contrary_by_index[index] + for index in range(1, size + 1) + }, + ) + + +@given(argumentation_frameworks(max_args=4), st.sampled_from(["complete", "stable"])) +@settings(deadline=10000, max_examples=30) +def test_differential_helper_compares_generated_dung_enumeration( + framework: ArgumentationFramework, + semantics: str, +) -> None: + native = solve_dung_extensions(framework, semantics=semantics, backend="native") + sat = solve_dung_extensions(framework, semantics=semantics, backend="sat") + + assert_solver_results_agree("enumeration", native, sat) + + +@given( + flat_aba_frameworks(), + st.sampled_from(["complete", "stable"]), + st.sampled_from(["credulous", "skeptical"]), +) +@settings(deadline=10000, max_examples=30) +def test_differential_helper_compares_generated_aba_acceptance( + framework: ABAFramework, + semantics: str, + task: str, +) -> None: + query = sorted(framework.language, key=repr)[0] + native = solve_aba_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + ) + + assert_solver_results_agree("acceptance", native, native) + + +def test_differential_helper_rejects_enumeration_single_extension_mismatch() -> None: + with pytest.raises( + AssertionError, + match="cannot compare enumeration result to single-extension result", + ): + assert_solver_results_agree( + "enumeration", + ExtensionSolverSuccess((frozenset({"a"}),)), + SingleExtensionSolverSuccess(frozenset({"a"})), + ) + + +def test_benchmark_smoke_reads_manifest_without_external_solver_execution(tmp_path) -> None: + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + [ + { + "id": "tiny-af", + "formalism": "af", + "task": "SE", + "semantics": "stable", + "path": "fixtures/tiny.af", + } + ] + ), + encoding="utf-8", + ) + + manifest = load_benchmark_manifest(manifest_path) + result = run_benchmark_smoke(manifest, execute_external=False) + + assert result.total == 1 + assert result.executed_external == 0 + assert result.skipped_external == 1 + + +def test_capability_matrix_reports_unsupported_combinations_explicitly() -> None: + matrix = solver_capability_matrix() + + assert matrix + assert any(not entry.supported for entry in matrix) + assert all(entry.reason for entry in matrix if not entry.supported) + assert any( + entry.formalism == "aba" + and entry.backend == "iccma" + and entry.task == "single-extension" + and entry.semantics == "stable" + and entry.supported + for entry in matrix + ) + +def literal(name: str) -> Literal: + return Literal(GroundAtom(name)) diff --git a/tests/test_solver_encoding.py b/tests/solving/test_solver_encoding.py similarity index 96% rename from tests/test_solver_encoding.py rename to tests/solving/test_solver_encoding.py index 727c16b..73715b1 100644 --- a/tests/test_solver_encoding.py +++ b/tests/solving/test_solver_encoding.py @@ -5,8 +5,8 @@ import pytest from hypothesis import given, settings -import argumentation.af_sat as af_sat -from argumentation.dung import ( +import argumentation.solving.af_sat as af_sat +from argumentation.core.dung import ( ArgumentationFramework, _attackers_index, admissible, @@ -19,15 +19,15 @@ stable_extensions, stage_extensions, ) -from argumentation.af_sat import ( +from argumentation.solving.af_sat import ( AfSatKernel, PreferredSkepticalTaskSolver, - PreferredSuperCoreSolver, - RangeMaximalTaskSolver, - SATCheck, - StableUnsatExplanation, - explain_stable_unsat, - find_complete_extension, + PreferredSuperCoreSolver, + RangeMaximalTaskSolver, + SATCheck, + StableUnsatExplanation, + explain_stable_unsat, + find_complete_extension, find_ideal_extension, find_preferred_extension, is_preferred_skeptically_accepted, @@ -35,15 +35,15 @@ find_stable_extension, find_stage_extension, ) -from argumentation.iccma import parse_apx -from argumentation.sat_encoding import ( +from argumentation.interop.iccma import parse_apx +from argumentation.solving.sat_encoding import ( CNFEncoding, encode_stable_extensions, sat_extensions, stable_extensions_from_encoding, ) -from argumentation.solver import solve_dung_acceptance -from tests.test_dung import af, argumentation_frameworks +from argumentation.solving.solver import solve_dung_acceptance +from tests.core.test_dung import af, argumentation_frameworks SAT_EXTENSION_ORACLES = { @@ -108,65 +108,65 @@ def test_kernel_complete_extension_handles_required_labels() -> None: assert find_complete_extension(framework, require_out="b") == frozenset({"a"}) -def test_kernel_preferred_extension_handles_required_labels() -> None: +def test_kernel_preferred_extension_handles_required_labels() -> None: framework = af({"a", "b", "c"}, {("a", "b"), ("b", "a"), ("c", "c")}) assert find_preferred_extension(framework, require_in="a") == frozenset({"a"}) assert find_preferred_extension(framework, require_in="b") == frozenset({"b"}) assert find_preferred_extension(framework, require_in="c") is None - assert find_preferred_extension(framework, require_out="a") == frozenset({"b"}) - - -def test_stable_unsat_explanation_odd_cycle_names_coverage_core() -> None: - framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) - - explanation = explain_stable_unsat(framework, simplify=False) - - assert isinstance(explanation, StableUnsatExplanation) - assert explanation.status == "unsat" - assert explanation.stable_exists is False - assert explanation.solver_result == "unsat" - assert set(explanation.coverage_argument_ids) == {"a", "b", "c"} - assert set(explanation.core_argument_ids) == {"a", "b", "c"} - assert explanation.clause_group_count == 6 - assert explanation.to_dict()["stable_exists"] is False - - -def test_stable_unsat_explanation_self_loop_names_conflict_and_coverage() -> None: - framework = af({"s"}, {("s", "s")}) - - explanation = explain_stable_unsat(framework, simplify=False) - - assert explanation.status == "unsat" - assert explanation.stable_exists is False - assert explanation.core_attack_ids == (("s", "s"),) - assert explanation.coverage_argument_ids == ("s",) - assert explanation.core_argument_ids == ("s",) - - -def test_stable_unsat_explanation_sat_case_has_no_fake_core() -> None: - framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) - - explanation = explain_stable_unsat(framework, simplify=False) - - assert explanation.status == "sat" - assert explanation.stable_exists is True - assert explanation.core_argument_ids == tuple() - assert explanation.core_attack_ids == tuple() - assert explanation.coverage_argument_ids == tuple() - assert explanation.model_extension_size == 1 - - -def test_stable_unsat_explanation_context_dependent_shape_can_be_unblocked() -> None: - blocked = af({"x", "y", "z"}, {("x", "y"), ("y", "z"), ("z", "x")}) - unblocked = af({"x", "y", "z"}, {("y", "z"), ("z", "x")}) - - blocked_explanation = explain_stable_unsat(blocked, simplify=False) - unblocked_explanation = explain_stable_unsat(unblocked, simplify=False) - - assert blocked_explanation.status == "unsat" - assert unblocked_explanation.status == "sat" - assert unblocked_explanation.core_argument_ids == tuple() + assert find_preferred_extension(framework, require_out="a") == frozenset({"b"}) + + +def test_stable_unsat_explanation_odd_cycle_names_coverage_core() -> None: + framework = af({"a", "b", "c"}, {("a", "b"), ("b", "c"), ("c", "a")}) + + explanation = explain_stable_unsat(framework, simplify=False) + + assert isinstance(explanation, StableUnsatExplanation) + assert explanation.status == "unsat" + assert explanation.stable_exists is False + assert explanation.solver_result == "unsat" + assert set(explanation.coverage_argument_ids) == {"a", "b", "c"} + assert set(explanation.core_argument_ids) == {"a", "b", "c"} + assert explanation.clause_group_count == 6 + assert explanation.to_dict()["stable_exists"] is False + + +def test_stable_unsat_explanation_self_loop_names_conflict_and_coverage() -> None: + framework = af({"s"}, {("s", "s")}) + + explanation = explain_stable_unsat(framework, simplify=False) + + assert explanation.status == "unsat" + assert explanation.stable_exists is False + assert explanation.core_attack_ids == (("s", "s"),) + assert explanation.coverage_argument_ids == ("s",) + assert explanation.core_argument_ids == ("s",) + + +def test_stable_unsat_explanation_sat_case_has_no_fake_core() -> None: + framework = af({"a", "b"}, {("a", "b"), ("b", "a")}) + + explanation = explain_stable_unsat(framework, simplify=False) + + assert explanation.status == "sat" + assert explanation.stable_exists is True + assert explanation.core_argument_ids == tuple() + assert explanation.core_attack_ids == tuple() + assert explanation.coverage_argument_ids == tuple() + assert explanation.model_extension_size == 1 + + +def test_stable_unsat_explanation_context_dependent_shape_can_be_unblocked() -> None: + blocked = af({"x", "y", "z"}, {("x", "y"), ("y", "z"), ("z", "x")}) + unblocked = af({"x", "y", "z"}, {("y", "z"), ("z", "x")}) + + blocked_explanation = explain_stable_unsat(blocked, simplify=False) + unblocked_explanation = explain_stable_unsat(unblocked, simplify=False) + + assert blocked_explanation.status == "unsat" + assert unblocked_explanation.status == "sat" + assert unblocked_explanation.core_argument_ids == tuple() def test_kernel_direct_skeptical_preferred_handles_basic_counterexample() -> None: @@ -621,58 +621,58 @@ def test_kernel_ideal_extension_handles_mutual_defense_example() -> None: assert find_ideal_extension(framework) == ideal_extension(framework) -def test_kernel_ideal_extension_uses_direct_ideal_utilities() -> None: - framework = af({"a", "b"}, {("a", "b")}) - checks: list[SATCheck] = [] - - assert find_ideal_extension(framework, trace_sink=checks.append, simplify=False) == frozenset({"a"}) +def test_kernel_ideal_extension_uses_direct_ideal_utilities() -> None: + framework = af({"a", "b"}, {("a", "b")}) + checks: list[SATCheck] = [] + + assert find_ideal_extension(framework, trace_sink=checks.append, simplify=False) == frozenset({"a"}) utility_names = [check.utility_name for check in checks] assert utility_names assert "preferred_seed" not in utility_names - assert "preferred_grow" not in utility_names - assert "ideal_admissible_attacker" in utility_names - - -def test_kernel_ideal_extension_prunes_undefended_current_arguments() -> None: - framework = af( - {"a", "aa", "b", "c"}, - { - ("a", "aa"), - ("aa", "a"), - ("a", "b"), - ("aa", "b"), - ("b", "c"), - }, - ) - - ideal = find_ideal_extension(framework) - - assert ideal == ideal_extension(framework) - assert admissible(ideal, framework.arguments, framework.defeats) - - -def test_preferred_super_core_prunes_undefended_current_arguments() -> None: - framework = af( - {"a", "aa", "b", "c"}, - { - ("a", "aa"), - ("aa", "a"), - ("a", "b"), - ("aa", "b"), - ("b", "c"), - }, - ) - - super_core = PreferredSuperCoreSolver(framework).compute() - - assert admissible(super_core, framework.arguments, framework.defeats) - assert all(super_core <= extension for extension in preferred_extensions(framework)) - - -@given(argumentation_frameworks(max_args=4)) -@settings(deadline=10000, max_examples=30) -def test_stable_encoding_matches_brute_force_reference(framework) -> None: + assert "preferred_grow" not in utility_names + assert "ideal_admissible_attacker" in utility_names + + +def test_kernel_ideal_extension_prunes_undefended_current_arguments() -> None: + framework = af( + {"a", "aa", "b", "c"}, + { + ("a", "aa"), + ("aa", "a"), + ("a", "b"), + ("aa", "b"), + ("b", "c"), + }, + ) + + ideal = find_ideal_extension(framework) + + assert ideal == ideal_extension(framework) + assert admissible(ideal, framework.arguments, framework.defeats) + + +def test_preferred_super_core_prunes_undefended_current_arguments() -> None: + framework = af( + {"a", "aa", "b", "c"}, + { + ("a", "aa"), + ("aa", "a"), + ("a", "b"), + ("aa", "b"), + ("b", "c"), + }, + ) + + super_core = PreferredSuperCoreSolver(framework).compute() + + assert admissible(super_core, framework.arguments, framework.defeats) + assert all(super_core <= extension for extension in preferred_extensions(framework)) + + +@given(argumentation_frameworks(max_args=4)) +@settings(deadline=10000, max_examples=30) +def test_stable_encoding_matches_brute_force_reference(framework) -> None: encoding = encode_stable_extensions(framework) assert set(stable_extensions_from_encoding(encoding)) == set( diff --git a/tests/test_solver_external_boundaries.py b/tests/solving/test_solver_external_boundaries.py similarity index 90% rename from tests/test_solver_external_boundaries.py rename to tests/solving/test_solver_external_boundaries.py index 29b1503..eadfae1 100644 --- a/tests/test_solver_external_boundaries.py +++ b/tests/solving/test_solver_external_boundaries.py @@ -1,136 +1,136 @@ -from __future__ import annotations - -from hypothesis import given, settings, strategies as st - -import argumentation.solver as solver_module -from argumentation.adf import ( - AbstractDialecticalFramework, - ThreeValued, - dung_to_adf, - interpretation_to_mapping, -) -from argumentation.dung import ( - ArgumentationFramework, - complete_extensions, - grounded_extension, - preferred_extensions, - stable_extensions, -) -from argumentation.setaf import SETAF -from argumentation.solver import ( - ExtensionSolverSuccess, - SolverBackendUnavailable, - solve_adf_models, - solve_setaf_extensions, -) -from tests.test_dung import argumentation_frameworks - - -ADF_DUNG_ORACLES = { - "complete": complete_extensions, - "grounded": lambda framework: [grounded_extension(framework)], - "preferred": preferred_extensions, - "stable": stable_extensions, -} - -SETAF_DUNG_ORACLES = { - "complete": complete_extensions, - "grounded": lambda framework: [grounded_extension(framework)], - "preferred": preferred_extensions, - "stable": stable_extensions, - "semi-stable": solver_module.semi_stable_extensions, - "stage": solver_module.stage_extensions, -} - - -@given( - argumentation_frameworks(max_args=4), - st.sampled_from(sorted(ADF_DUNG_ORACLES)), -) -@settings(deadline=10000, max_examples=40) -def test_adf_native_models_preserve_dung_encoding( - framework: ArgumentationFramework, - semantics: str, -) -> None: - result = solve_adf_models(dung_to_adf(framework), semantics=semantics) - - assert isinstance(result, ExtensionSolverSuccess) - assert {_true_statements(model) for model in result.extensions} == set( - ADF_DUNG_ORACLES[semantics](framework) - ) - - -@given( - argumentation_frameworks(max_args=4), - st.sampled_from(sorted(SETAF_DUNG_ORACLES)), -) -@settings(deadline=10000, max_examples=40) -def test_setaf_native_extensions_preserve_singleton_tail_dung_reduction( - framework: ArgumentationFramework, - semantics: str, -) -> None: - setaf = SETAF( - arguments=framework.arguments, - attacks=frozenset( - (frozenset({attacker}), target) - for attacker, target in framework.defeats - ), - ) - - result = solve_setaf_extensions(setaf, semantics=semantics) - - assert isinstance(result, ExtensionSolverSuccess) - assert set(result.extensions) == set(SETAF_DUNG_ORACLES[semantics](framework)) - - -@given(st.sampled_from(sorted(ADF_DUNG_ORACLES))) -@settings(deadline=10000, max_examples=10) -def test_adf_external_backend_is_unavailable_before_any_subprocess_claim( - semantics: str, -) -> None: - result = solve_adf_models( - empty_adf(), - semantics=semantics, - backend="diamond", - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "diamond" - assert result.reason == "external ADF solver backend is not source-backed" - - -@given(st.sampled_from(sorted(SETAF_DUNG_ORACLES))) -@settings(deadline=10000, max_examples=10) -def test_setaf_external_backend_is_unavailable_before_any_subprocess_claim( - semantics: str, -) -> None: - result = solve_setaf_extensions( - SETAF(arguments=frozenset({"a"}), attacks=frozenset()), - semantics=semantics, - backend="aspartix", - ) - - assert isinstance(result, SolverBackendUnavailable) - assert result.backend == "aspartix" - assert result.reason == "external SETAF solver backend is not source-backed" - - -def empty_adf() -> AbstractDialecticalFramework: - return dung_to_adf(ArgumentationFramework(arguments=frozenset(), defeats=frozenset())) - - -def _true_statements(model: frozenset[object]) -> frozenset[str]: - values = interpretation_to_mapping(_adf_interpretation(model)) - return frozenset( - statement - for statement, value in values.items() - if value is ThreeValued.T - ) - - -def _adf_interpretation(model: frozenset[object]): - return frozenset( - (statement, value) - for statement, value in model - if isinstance(statement, str) and isinstance(value, ThreeValued) - ) +from __future__ import annotations + +from hypothesis import given, settings, strategies as st + +import argumentation.solving.solver as solver_module +from argumentation.frameworks.adf import ( + AbstractDialecticalFramework, + ThreeValued, + dung_to_adf, + interpretation_to_mapping, +) +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + grounded_extension, + preferred_extensions, + stable_extensions, +) +from argumentation.frameworks.setaf import SETAF +from argumentation.solving.solver import ( + ExtensionSolverSuccess, + SolverBackendUnavailable, + solve_adf_models, + solve_setaf_extensions, +) +from tests.core.test_dung import argumentation_frameworks + + +ADF_DUNG_ORACLES = { + "complete": complete_extensions, + "grounded": lambda framework: [grounded_extension(framework)], + "preferred": preferred_extensions, + "stable": stable_extensions, +} + +SETAF_DUNG_ORACLES = { + "complete": complete_extensions, + "grounded": lambda framework: [grounded_extension(framework)], + "preferred": preferred_extensions, + "stable": stable_extensions, + "semi-stable": solver_module.semi_stable_extensions, + "stage": solver_module.stage_extensions, +} + + +@given( + argumentation_frameworks(max_args=4), + st.sampled_from(sorted(ADF_DUNG_ORACLES)), +) +@settings(deadline=10000, max_examples=40) +def test_adf_native_models_preserve_dung_encoding( + framework: ArgumentationFramework, + semantics: str, +) -> None: + result = solve_adf_models(dung_to_adf(framework), semantics=semantics) + + assert isinstance(result, ExtensionSolverSuccess) + assert {_true_statements(model) for model in result.extensions} == set( + ADF_DUNG_ORACLES[semantics](framework) + ) + + +@given( + argumentation_frameworks(max_args=4), + st.sampled_from(sorted(SETAF_DUNG_ORACLES)), +) +@settings(deadline=10000, max_examples=40) +def test_setaf_native_extensions_preserve_singleton_tail_dung_reduction( + framework: ArgumentationFramework, + semantics: str, +) -> None: + setaf = SETAF( + arguments=framework.arguments, + attacks=frozenset( + (frozenset({attacker}), target) + for attacker, target in framework.defeats + ), + ) + + result = solve_setaf_extensions(setaf, semantics=semantics) + + assert isinstance(result, ExtensionSolverSuccess) + assert set(result.extensions) == set(SETAF_DUNG_ORACLES[semantics](framework)) + + +@given(st.sampled_from(sorted(ADF_DUNG_ORACLES))) +@settings(deadline=10000, max_examples=10) +def test_adf_external_backend_is_unavailable_before_any_subprocess_claim( + semantics: str, +) -> None: + result = solve_adf_models( + empty_adf(), + semantics=semantics, + backend="diamond", + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "diamond" + assert result.reason == "external ADF solver backend is not source-backed" + + +@given(st.sampled_from(sorted(SETAF_DUNG_ORACLES))) +@settings(deadline=10000, max_examples=10) +def test_setaf_external_backend_is_unavailable_before_any_subprocess_claim( + semantics: str, +) -> None: + result = solve_setaf_extensions( + SETAF(arguments=frozenset({"a"}), attacks=frozenset()), + semantics=semantics, + backend="aspartix", + ) + + assert isinstance(result, SolverBackendUnavailable) + assert result.backend == "aspartix" + assert result.reason == "external SETAF solver backend is not source-backed" + + +def empty_adf() -> AbstractDialecticalFramework: + return dung_to_adf(ArgumentationFramework(arguments=frozenset(), defeats=frozenset())) + + +def _true_statements(model: frozenset[object]) -> frozenset[str]: + values = interpretation_to_mapping(_adf_interpretation(model)) + return frozenset( + statement + for statement, value in values.items() + if value is ThreeValued.T + ) + + +def _adf_interpretation(model: frozenset[object]): + return frozenset( + (statement, value) + for statement, value in model + if isinstance(statement, str) and isinstance(value, ThreeValued) + ) diff --git a/tests/structured/__init__.py b/tests/structured/__init__.py new file mode 100644 index 0000000..2cbf322 --- /dev/null +++ b/tests/structured/__init__.py @@ -0,0 +1 @@ +"""Tests for the structured layer.""" diff --git a/tests/structured/aba/__init__.py b/tests/structured/aba/__init__.py new file mode 100644 index 0000000..a6ecc83 --- /dev/null +++ b/tests/structured/aba/__init__.py @@ -0,0 +1 @@ +"""Tests for the ABA structured layer.""" diff --git a/tests/test_aba.py b/tests/structured/aba/test_aba.py similarity index 75% rename from tests/test_aba.py rename to tests/structured/aba/test_aba.py index 847193b..46082c0 100644 --- a/tests/test_aba.py +++ b/tests/structured/aba/test_aba.py @@ -3,11 +3,13 @@ import pytest from hypothesis import given, settings, strategies as st -from argumentation import aba as native_aba -from argumentation import aba_sat -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.solver import ( +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba import aba_decomposition +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aba.aba_preprocessing import simplify_aba +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.solving.solver import ( AcceptanceSolverSuccess, ICCMAConfig, SingleExtensionSolverSuccess, @@ -114,7 +116,7 @@ def test_solve_aba_single_extension_auto_uses_stable_sat_without_native_enumerat def fail_native(*args, **kwargs): raise AssertionError("native ABA stable enumeration should not run") - monkeypatch.setattr("argumentation.solver.aba_semantics.stable_extensions", fail_native) + monkeypatch.setattr("argumentation.solving.solver.aba_semantics.stable_extensions", fail_native) result = solve_aba_single_extension(framework, semantics="stable") @@ -130,7 +132,7 @@ def test_solve_aba_acceptance_auto_uses_stable_sat_without_native_enumeration( def fail_native(*args, **kwargs): raise AssertionError("native ABA stable enumeration should not run") - monkeypatch.setattr("argumentation.solver.aba_semantics.stable_extensions", fail_native) + monkeypatch.setattr("argumentation.solving.solver.aba_semantics.stable_extensions", fail_native) result = solve_aba_acceptance( framework, @@ -155,7 +157,7 @@ def fail_support_enumeration(*args, **kwargs): raise AssertionError("ABA support extension enumeration should not run") monkeypatch.setattr( - "argumentation.solver.sat_aba_support_extensions", + "argumentation.solving.solver.sat_aba_support_extensions", fail_support_enumeration, ) @@ -178,7 +180,7 @@ def fail_support_enumeration(*args, **kwargs): raise AssertionError("ABA support acceptance enumeration should not run") monkeypatch.setattr( - "argumentation.solver.sat_aba_support_extensions", + "argumentation.solving.solver.sat_aba_support_extensions", fail_support_enumeration, ) @@ -425,6 +427,137 @@ def test_preferred_support_sat_preserves_required_assumptions( assert witness in native_aba.preferred_extensions(framework) +def _fixed_out_required_aba() -> ABAFramework: + """Framework where preprocessing forces a required assumption OUT. + + a3 is unattacked, so it is the grounded (well-founded) set -> fixed_in. + Rule a3 -> c2 then derives c2 = contrary(a2) from the grounded set alone, + so a2 is forced OUT -> fixed_out. The only preferred extension is {a3}. + This is the verbatim Hypothesis falsifying example for the KeyError bug. + """ + + def lit(name: str) -> Literal: + return Literal(GroundAtom(name)) + + a1, a2, a3 = lit("a1"), lit("a2"), lit("a3") + c1, c2, c3 = lit("c1"), lit("c2"), lit("c3") + return ABAFramework( + language=frozenset({a1, a2, a3, c1, c2, c3}), + rules=frozenset( + { + Rule(antecedents=(a3,), consequent=c2, kind="strict", name=None), + Rule(antecedents=(a1,), consequent=c1, kind="strict", name=None), + } + ), + assumptions=frozenset({a1, a2, a3}), + contrary={a1: c1, a2: c2, a3: c3}, + ) + + +def test_preferred_support_sat_fixed_out_required_assumption_is_unsatisfiable() -> None: + """A required assumption forced OUT by simplify_aba yields None, not KeyError. + + Regression for the decomposed-PrefSat bug: decomposed_prefsat_extension + subtracted only fixed_in from the required set, so a required fixed_out + assumption leaked into the residual solver and raised KeyError. A fixed_out + assumption is in no preferred extension (Bondarenko et al. 1997, Def. 2.2 + p.70 + Thm. 6.4 p.90), so the query is unsatisfiable -> None. + """ + framework = _fixed_out_required_aba() + + def lit(name: str) -> Literal: + return Literal(GroundAtom(name)) + + a1, a2, a3 = lit("a1"), lit("a2"), lit("a3") + + simplification = simplify_aba(framework, semantics="preferred") + assert simplification.fixed_in == frozenset({a3}) + assert simplification.fixed_out == frozenset({a2}) + + # Direct decomposed-PrefSat callers must see the unsatisfiable requirement, + # not a successful empty extension. + direct_fixed_out = aba_decomposition.decomposed_prefsat_extension( + framework, + require_assumptions=frozenset({a1, a2}), + ) + assert direct_fixed_out.extension is None + assert direct_fixed_out.telemetry["decomp_validation_success"] == 0 + assert direct_fixed_out.telemetry["decomp_lifted_extension_size"] == 0 + + # Required a2 is forced OUT -> no preferred extension satisfies it -> None. + assert ( + aba_sat.sat_support_extension( + framework, "preferred", require_assumptions=frozenset({a1, a2}) + ) + is None + ) + + # Required a3 is forced IN (the grounded set) -> still satisfiable: the + # fixed_in branch is left untouched by the fix. + witness_in = aba_sat.sat_support_extension( + framework, "preferred", require_assumptions=frozenset({a3}) + ) + assert witness_in is not None + assert a3 in witness_in + assert witness_in in native_aba.preferred_extensions(framework) + + # a1 is a genuine residual assumption (not fixed_in, not fixed_out): rule + # a1 -> c1 = contrary(a1) makes {a1} self-attacking, so no preferred + # extension contains it. The residual solver -- not the fixed_out guard -- + # correctly reports this as None. + assert a1 not in simplification.fixed_in + assert a1 not in simplification.fixed_out + assert not any(a1 in ext for ext in native_aba.preferred_extensions(framework)) + direct_residual_unsat = aba_decomposition.decomposed_prefsat_extension( + framework, + require_assumptions=frozenset({a1}), + ) + assert direct_residual_unsat.extension is None + assert direct_residual_unsat.telemetry["decomp_validation_success"] == 0 + assert ( + aba_sat.sat_support_extension( + framework, "preferred", require_assumptions=frozenset({a1}) + ) + is None + ) + + # With no requirement the decomposed path produces a real preferred + # extension (the grounded set {a3}) via the residual + lift. + witness_unconstrained = aba_sat.sat_support_extension(framework, "preferred") + assert witness_unconstrained == frozenset({a3}) + assert witness_unconstrained in native_aba.preferred_extensions(framework) + + +@given(flat_aba_frameworks(), st.data()) +@settings(deadline=10000, max_examples=40) +def test_preferred_support_sat_fixed_out_requirement_is_always_unsatisfiable( + framework: ABAFramework, + data: st.DataObject, +) -> None: + """Invariant: a required assumption in fixed_out makes the query None. + + A fixed_out assumption's contrary is forward-derivable from the grounded + set, which is contained in every preferred extension (Bondarenko et al. + 1997, Thm. 6.4 p.90); including it would break conflict-freeness (Def. 2.2 + p.70). So for any flat ABA framework, if a drawn required set intersects + fixed_out, sat_support_extension must return None. + """ + assumptions = tuple(sorted(framework.assumptions, key=repr)) + required = data.draw(st.frozensets(st.sampled_from(assumptions), max_size=2)) + + fixed_out = simplify_aba(framework, semantics="preferred").fixed_out + if not (required & fixed_out): + return + + witness = aba_sat.sat_support_extension( + framework, "preferred", require_assumptions=required + ) + assert witness is None + assert not any( + required <= extension for extension in native_aba.preferred_extensions(framework) + ) + + @given(flat_aba_frameworks(), st.sampled_from(["require_derived", "require_not_derived"])) @settings(deadline=10000, max_examples=40) def test_preferred_support_sat_query_constraints_match_derivability( diff --git a/tests/test_aba_abcgen_telemetry_workstream.py b/tests/structured/aba/test_aba_abcgen_telemetry_workstream.py similarity index 95% rename from tests/test_aba_abcgen_telemetry_workstream.py rename to tests/structured/aba/test_aba_abcgen_telemetry_workstream.py index 26bbd03..97a819c 100644 --- a/tests/test_aba_abcgen_telemetry_workstream.py +++ b/tests/structured/aba/test_aba_abcgen_telemetry_workstream.py @@ -4,7 +4,7 @@ from collections import Counter from pathlib import Path -from argumentation.aba_telemetry import STRUCTURAL_TELEMETRY_KEYS +from argumentation.structured.aba.aba_telemetry import STRUCTURAL_TELEMETRY_KEYS FIXTURE_PATH = Path("tests/manifests/iccma2025-abcgen-10x10.json") diff --git a/tests/test_aba_asp_differential.py b/tests/structured/aba/test_aba_asp_differential.py similarity index 87% rename from tests/test_aba_asp_differential.py rename to tests/structured/aba/test_aba_asp_differential.py index efd0650..f7fdf81 100644 --- a/tests/test_aba_asp_differential.py +++ b/tests/structured/aba/test_aba_asp_differential.py @@ -3,16 +3,11 @@ from hypothesis import given, settings from hypothesis import strategies as st -import argumentation -from argumentation import aba as native_aba -from argumentation.aba import ABAFramework -from argumentation.aba_asp import solve_aba_with_backend -from argumentation.aba_sat import support_extensions -from argumentation.aspic import GroundAtom, Literal, Rule - - -def test_aba_asp_module_is_exported_from_package() -> None: - assert "aba_asp" in argumentation.__all__ +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aba.aba_asp import solve_aba_with_backend +from argumentation.structured.aba.aba_sat import support_extensions +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def test_aba_asp_stable_matches_support_reference() -> None: diff --git a/tests/test_aba_bondarenko_examples.py b/tests/structured/aba/test_aba_bondarenko_examples.py similarity index 92% rename from tests/test_aba_bondarenko_examples.py rename to tests/structured/aba/test_aba_bondarenko_examples.py index 3727dd3..62b341b 100644 --- a/tests/test_aba_bondarenko_examples.py +++ b/tests/structured/aba/test_aba_bondarenko_examples.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.aba import ( +from argumentation.structured.aba.aba import ( ABAFramework, admissible, complete_extensions, @@ -10,7 +10,7 @@ stable_extensions, well_founded_extension, ) -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def lit(name: str) -> Literal: diff --git a/tests/test_aba_decomposed_prefsat_contract.py b/tests/structured/aba/test_aba_decomposed_prefsat_contract.py similarity index 94% rename from tests/test_aba_decomposed_prefsat_contract.py rename to tests/structured/aba/test_aba_decomposed_prefsat_contract.py index 5f44ced..c1ecd23 100644 --- a/tests/test_aba_decomposed_prefsat_contract.py +++ b/tests/structured/aba/test_aba_decomposed_prefsat_contract.py @@ -8,10 +8,10 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation import aba as native_aba -from argumentation import aba_sat -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule from tools.aba_shape_benchmark import compute_aba_shape, route_candidates_from_shape_data @@ -129,7 +129,7 @@ def test_decomposed_prefsat_page_image_contract() -> None: def test_decomposed_prefsat_matches_preferred_oracle_on_small_products( framework: ABAFramework, ) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition result = aba_decomposition.decomposed_prefsat_extension(framework) @@ -139,7 +139,7 @@ def test_decomposed_prefsat_matches_preferred_oracle_on_small_products( @given(layered_independent_aba_for_decomposition()) @settings(max_examples=25, deadline=None) def test_decomposition_reports_required_telemetry(framework: ABAFramework) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition result = aba_decomposition.decomposed_prefsat_extension(framework) @@ -147,7 +147,7 @@ def test_decomposition_reports_required_telemetry(framework: ABAFramework) -> No def test_reduced_product_never_calls_full_instance_prefsat(monkeypatch: pytest.MonkeyPatch) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition framework = _independent_product_framework(component_count=3) original_kernel = aba_sat.real_prefsat_extension @@ -174,7 +174,7 @@ def spy_real_prefsat( def test_no_reduction_calls_real_prefsat_once_and_reports_reason( monkeypatch: pytest.MonkeyPatch, ) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition framework = _single_component_framework(size=4) original_kernel = aba_sat.real_prefsat_extension @@ -198,7 +198,7 @@ def spy_real_prefsat( def test_decomposition_never_calls_aba_to_dung(monkeypatch: pytest.MonkeyPatch) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition def fail_aba_to_dung(_framework: ABAFramework) -> None: raise AssertionError("decomposed PrefSat must stay on direct ABA facts") @@ -215,11 +215,12 @@ def fail_aba_to_dung(_framework: ABAFramework) -> None: @given(layered_independent_aba_for_decomposition()) @settings(max_examples=25, deadline=None) def test_lifted_answer_validates_against_original_framework(framework: ABAFramework) -> None: - from argumentation import aba_decomposition + from argumentation.structured.aba import aba_decomposition result = aba_decomposition.decomposed_prefsat_extension(framework) assert result.telemetry["decomp_validation_success"] == 1 + assert result.extension is not None assert result.extension <= framework.assumptions diff --git a/tests/test_aba_dung_correspondence.py b/tests/structured/aba/test_aba_dung_correspondence.py similarity index 85% rename from tests/test_aba_dung_correspondence.py rename to tests/structured/aba/test_aba_dung_correspondence.py index d6126d9..17314ab 100644 --- a/tests/test_aba_dung_correspondence.py +++ b/tests/structured/aba/test_aba_dung_correspondence.py @@ -1,72 +1,72 @@ -from __future__ import annotations - -from argumentation.aba import ABAFramework, aba_to_dung, grounded_extension, preferred_extensions -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.dung import grounded_extension as dung_grounded_extension -from argumentation.dung import preferred_extensions as dung_preferred_extensions - - -def lit(name: str) -> Literal: - return Literal(GroundAtom(name)) - - -def argument_label(support: frozenset[Literal], conclusion: Literal) -> str: - support_text = ",".join(sorted(repr(assumption) for assumption in support)) - return f"{{{support_text}}} |- {conclusion!r}" - - -def project_assumptions(extension: frozenset[str], assumptions: frozenset[Literal]) -> frozenset[Literal]: - return frozenset( - assumption - for assumption in assumptions - if argument_label(frozenset({assumption}), assumption) in extension - ) - - -def test_flat_aba_to_dung_preserves_singleton_attack_semantics() -> None: - alpha = lit("alpha") - beta = lit("beta") - leave = lit("leave") - stay = lit("stay") - framework = ABAFramework( - language=frozenset({alpha, beta, leave, stay}), - rules=frozenset({Rule((alpha,), leave, "strict"), Rule((beta,), stay, "strict")}), - assumptions=frozenset({alpha, beta}), - contrary={alpha: stay, beta: leave}, - ) - dung = aba_to_dung(framework) - - assert dung_grounded_extension(dung) == { - argument_label(frozenset({literal}), literal) - for literal in grounded_extension(framework) - } - assert tuple( - project_assumptions(extension, framework.assumptions) - for extension in dung_preferred_extensions(dung) - ) == tuple( - extension - for extension in preferred_extensions(framework) - ) - - -def test_flat_aba_to_dung_preserves_joint_support_attacks() -> None: - """Bondarenko et al. 1997 p.76: a set attacks by deriving a contrary.""" - alpha = lit("alpha") - beta = lit("beta") - gamma = lit("gamma") - block_gamma = lit("block_gamma") - not_alpha = lit("not_alpha") - not_beta = lit("not_beta") - framework = ABAFramework( - language=frozenset({alpha, beta, gamma, block_gamma, not_alpha, not_beta}), - rules=frozenset({Rule((alpha, beta), block_gamma, "strict")}), - assumptions=frozenset({alpha, beta, gamma}), - contrary={alpha: not_alpha, beta: not_beta, gamma: block_gamma}, - ) - dung = aba_to_dung(framework) - - assert argument_label(frozenset({alpha, beta}), block_gamma) in dung.arguments - grounded = dung_grounded_extension(dung) - assert argument_label(frozenset({alpha}), alpha) in grounded - assert argument_label(frozenset({beta}), beta) in grounded - assert argument_label(frozenset({gamma}), gamma) not in grounded +from __future__ import annotations + +from argumentation.structured.aba.aba import ABAFramework, aba_to_dung, grounded_extension, preferred_extensions +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.core.dung import grounded_extension as dung_grounded_extension +from argumentation.core.dung import preferred_extensions as dung_preferred_extensions + + +def lit(name: str) -> Literal: + return Literal(GroundAtom(name)) + + +def argument_label(support: frozenset[Literal], conclusion: Literal) -> str: + support_text = ",".join(sorted(repr(assumption) for assumption in support)) + return f"{{{support_text}}} |- {conclusion!r}" + + +def project_assumptions(extension: frozenset[str], assumptions: frozenset[Literal]) -> frozenset[Literal]: + return frozenset( + assumption + for assumption in assumptions + if argument_label(frozenset({assumption}), assumption) in extension + ) + + +def test_flat_aba_to_dung_preserves_singleton_attack_semantics() -> None: + alpha = lit("alpha") + beta = lit("beta") + leave = lit("leave") + stay = lit("stay") + framework = ABAFramework( + language=frozenset({alpha, beta, leave, stay}), + rules=frozenset({Rule((alpha,), leave, "strict"), Rule((beta,), stay, "strict")}), + assumptions=frozenset({alpha, beta}), + contrary={alpha: stay, beta: leave}, + ) + dung = aba_to_dung(framework) + + assert dung_grounded_extension(dung) == { + argument_label(frozenset({literal}), literal) + for literal in grounded_extension(framework) + } + assert tuple( + project_assumptions(extension, framework.assumptions) + for extension in dung_preferred_extensions(dung) + ) == tuple( + extension + for extension in preferred_extensions(framework) + ) + + +def test_flat_aba_to_dung_preserves_joint_support_attacks() -> None: + """Bondarenko et al. 1997 p.76: a set attacks by deriving a contrary.""" + alpha = lit("alpha") + beta = lit("beta") + gamma = lit("gamma") + block_gamma = lit("block_gamma") + not_alpha = lit("not_alpha") + not_beta = lit("not_beta") + framework = ABAFramework( + language=frozenset({alpha, beta, gamma, block_gamma, not_alpha, not_beta}), + rules=frozenset({Rule((alpha, beta), block_gamma, "strict")}), + assumptions=frozenset({alpha, beta, gamma}), + contrary={alpha: not_alpha, beta: not_beta, gamma: block_gamma}, + ) + dung = aba_to_dung(framework) + + assert argument_label(frozenset({alpha, beta}), block_gamma) in dung.arguments + grounded = dung_grounded_extension(dung) + assert argument_label(frozenset({alpha}), alpha) in grounded + assert argument_label(frozenset({beta}), beta) in grounded + assert argument_label(frozenset({gamma}), gamma) not in grounded diff --git a/tests/test_aba_hard_bucket_manifest.py b/tests/structured/aba/test_aba_hard_bucket_manifest.py similarity index 100% rename from tests/test_aba_hard_bucket_manifest.py rename to tests/structured/aba/test_aba_hard_bucket_manifest.py diff --git a/tests/test_aba_hypothesis_generators.py b/tests/structured/aba/test_aba_hypothesis_generators.py similarity index 94% rename from tests/test_aba_hypothesis_generators.py rename to tests/structured/aba/test_aba_hypothesis_generators.py index 407a0b4..92a47b8 100644 --- a/tests/test_aba_hypothesis_generators.py +++ b/tests/structured/aba/test_aba_hypothesis_generators.py @@ -5,9 +5,9 @@ import pytest from hypothesis import given, settings -from argumentation.aba import ABAFramework, NotFlatABAError -from argumentation.aspic import Literal, Rule -from argumentation.iccma import parse_aba, write_aba +from argumentation.structured.aba.aba import ABAFramework, NotFlatABAError +from argumentation.structured.aspic.aspic import Literal, Rule +from argumentation.interop.iccma import parse_aba, write_aba from tests.aba_hypothesis_generators import ( cyclic_dependency_frameworks, flat_aba_frameworks, diff --git a/tests/test_aba_iccma_io.py b/tests/structured/aba/test_aba_iccma_io.py similarity index 92% rename from tests/test_aba_iccma_io.py rename to tests/structured/aba/test_aba_iccma_io.py index 818921b..1b074b0 100644 --- a/tests/test_aba_iccma_io.py +++ b/tests/structured/aba/test_aba_iccma_io.py @@ -2,9 +2,9 @@ import pytest -from argumentation.aba import ABAFramework, NotFlatABAError -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.iccma import parse_aba, parse_apx, parse_tgf, write_aba, write_numeric_aba +from argumentation.structured.aba.aba import ABAFramework, NotFlatABAError +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.interop.iccma import parse_aba, parse_apx, parse_tgf, write_aba, write_numeric_aba def lit(name: str) -> Literal: diff --git a/tests/test_aba_incremental_paper_properties.py b/tests/structured/aba/test_aba_incremental_paper_properties.py similarity index 94% rename from tests/test_aba_incremental_paper_properties.py rename to tests/structured/aba/test_aba_incremental_paper_properties.py index f50f1dd..5bd6c89 100644 --- a/tests/test_aba_incremental_paper_properties.py +++ b/tests/structured/aba/test_aba_incremental_paper_properties.py @@ -4,10 +4,10 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation import aba as native_aba -from argumentation.aba import ABAFramework, AssumptionSet, derives -from argumentation.aba_asp import encode_aba_theory, solve_aba_with_backend -from argumentation.aba_incremental import ( +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet, derives +from argumentation.structured.aba.aba_asp import encode_aba_theory, solve_aba_with_backend +from argumentation.structured.aba.aba_incremental import ( AbaIncrementalSolver, LEHTONEN_INCREMENTAL_ASP_CITATION, LEHTONEN_INCREMENTAL_ASP_PAGE_CITATIONS, diff --git a/tests/test_aba_multishot.py b/tests/structured/aba/test_aba_multishot.py similarity index 96% rename from tests/test_aba_multishot.py rename to tests/structured/aba/test_aba_multishot.py index 06b257b..47dd80b 100644 --- a/tests/test_aba_multishot.py +++ b/tests/structured/aba/test_aba_multishot.py @@ -22,17 +22,17 @@ import pytest -from argumentation import aba as native_aba -from argumentation import aba_asp -from argumentation import aba_sat -from argumentation.aba import ABAFramework, AssumptionSet, derives -from argumentation.aba_asp import solve_aba_with_backend -from argumentation.aba_incremental import ( +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba import aba_asp +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet, derives +from argumentation.structured.aba.aba_asp import solve_aba_with_backend +from argumentation.structured.aba.aba_incremental import ( AbaIncrementalSolver, ClingoSolveTimeout, IncrementalTelemetry, ) -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def lit(name: str) -> Literal: @@ -476,7 +476,7 @@ def solve(self, *args, **kwargs): class FakeClingo: Control = FakeControl - monkeypatch.setattr("argumentation.aba_incremental._load_clingo", lambda: FakeClingo) + monkeypatch.setattr("argumentation.structured.aba.aba_incremental._load_clingo", lambda: FakeClingo) solver = AbaIncrementalSolver(framework, control_args=("--configuration=frumpy",)) telemetry = IncrementalTelemetry() @@ -516,7 +516,7 @@ def solve(self, *args, **kwargs): class FakeClingo: Control = FakeControl - monkeypatch.setattr("argumentation.aba_incremental._load_clingo", lambda: FakeClingo) + monkeypatch.setattr("argumentation.structured.aba.aba_incremental._load_clingo", lambda: FakeClingo) solver = AbaIncrementalSolver(framework, collect_statistics=True) telemetry = IncrementalTelemetry() @@ -573,7 +573,7 @@ def solve(self, *args, **kwargs): class FakeClingo: Control = FakeControl - monkeypatch.setattr("argumentation.aba_incremental._load_clingo", lambda: FakeClingo) + monkeypatch.setattr("argumentation.structured.aba.aba_incremental._load_clingo", lambda: FakeClingo) solver = AbaIncrementalSolver( framework, @@ -634,7 +634,7 @@ def solve(self, *args, **kwargs): class FakeClingo: Control = FakeControl - monkeypatch.setattr("argumentation.aba_incremental._load_clingo", lambda: FakeClingo) + monkeypatch.setattr("argumentation.structured.aba.aba_incremental._load_clingo", lambda: FakeClingo) result = solve_aba_with_backend( framework, @@ -701,7 +701,7 @@ def solve(self, *args, **kwargs): class FakeClingo: Control = FakeControl - monkeypatch.setattr("argumentation.aba_incremental._load_clingo", lambda: FakeClingo) + monkeypatch.setattr("argumentation.structured.aba.aba_incremental._load_clingo", lambda: FakeClingo) solver = AbaIncrementalSolver(framework) telemetry = IncrementalTelemetry() diff --git a/tests/test_aba_native_cnf_prefsat.py b/tests/structured/aba/test_aba_native_cnf_prefsat.py similarity index 95% rename from tests/test_aba_native_cnf_prefsat.py rename to tests/structured/aba/test_aba_native_cnf_prefsat.py index 1f3f9ed..4404e0e 100644 --- a/tests/test_aba_native_cnf_prefsat.py +++ b/tests/structured/aba/test_aba_native_cnf_prefsat.py @@ -2,11 +2,11 @@ from hypothesis import given, settings -from argumentation import aba_sat -from argumentation import aba_decomposition -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from tests.test_aba_real_prefsat_contract import small_flat_aba_for_real_prefsat +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba import aba_decomposition +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from tests.structured.aba.test_aba_real_prefsat_contract import small_flat_aba_for_real_prefsat NATIVE_CNF_PREFSAT_PAGE_IMAGES = ( diff --git a/tests/test_aba_plus_cyras_2016.py b/tests/structured/aba/test_aba_plus_cyras_2016.py similarity index 90% rename from tests/test_aba_plus_cyras_2016.py rename to tests/structured/aba/test_aba_plus_cyras_2016.py index 5f77771..d80d990 100644 --- a/tests/test_aba_plus_cyras_2016.py +++ b/tests/structured/aba/test_aba_plus_cyras_2016.py @@ -1,6 +1,6 @@ from __future__ import annotations -from argumentation.aba import ( +from argumentation.structured.aba.aba import ( ABAFramework, ABAPlusFramework, attacks_with_preferences, @@ -9,7 +9,7 @@ preferred_extensions, stable_extensions, ) -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def lit(name: str) -> Literal: diff --git a/tests/test_aba_preprocessing.py b/tests/structured/aba/test_aba_preprocessing.py similarity index 97% rename from tests/test_aba_preprocessing.py rename to tests/structured/aba/test_aba_preprocessing.py index f590847..895abf3 100644 --- a/tests/test_aba_preprocessing.py +++ b/tests/structured/aba/test_aba_preprocessing.py @@ -12,16 +12,16 @@ import pytest -from argumentation import aba as native_aba -from argumentation import aba_sat -from argumentation.aba import ABAFramework, ABAPlusFramework -from argumentation.aba_preprocessing import ( +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework, ABAPlusFramework +from argumentation.structured.aba.aba_preprocessing import ( GROUNDED_REDUCT_ABA_SEMANTICS, grounded_assumption_set_via_supports, simplify_aba, ) -from argumentation.aba_asp import solve_aba_with_backend -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba_asp import solve_aba_with_backend +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def lit(name: str) -> Literal: diff --git a/tests/test_aba_real_prefsat_contract.py b/tests/structured/aba/test_aba_real_prefsat_contract.py similarity index 98% rename from tests/test_aba_real_prefsat_contract.py rename to tests/structured/aba/test_aba_real_prefsat_contract.py index 336b9fe..5f71765 100644 --- a/tests/test_aba_real_prefsat_contract.py +++ b/tests/structured/aba/test_aba_real_prefsat_contract.py @@ -7,9 +7,9 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation import aba_sat -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule from tests.aba_hypothesis_generators import renamed_framework from tools.aba_shape_benchmark import compute_aba_shape, route_candidates_from_shape_data diff --git a/tests/test_aba_route_properties.py b/tests/structured/aba/test_aba_route_properties.py similarity index 99% rename from tests/test_aba_route_properties.py rename to tests/structured/aba/test_aba_route_properties.py index 8c2e99e..6dc36d6 100644 --- a/tests/test_aba_route_properties.py +++ b/tests/structured/aba/test_aba_route_properties.py @@ -6,7 +6,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.aba import ABAFramework +from argumentation.structured.aba.aba import ABAFramework from tests.aba_hypothesis_generators import ( flat_aba_frameworks, flat_aba_specs, diff --git a/tests/test_aba_semantic_properties.py b/tests/structured/aba/test_aba_semantic_properties.py similarity index 96% rename from tests/test_aba_semantic_properties.py rename to tests/structured/aba/test_aba_semantic_properties.py index 66bf45a..7fc51cc 100644 --- a/tests/test_aba_semantic_properties.py +++ b/tests/structured/aba/test_aba_semantic_properties.py @@ -4,9 +4,9 @@ from hypothesis import given, settings -from argumentation import aba as native_aba -from argumentation.aba import ABAFramework, AssumptionSet -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule from tests.aba_hypothesis_generators import ( flat_aba_frameworks, normal_candidate_frameworks, diff --git a/tests/test_aba_shape_benchmark.py b/tests/structured/aba/test_aba_shape_benchmark.py similarity index 99% rename from tests/test_aba_shape_benchmark.py rename to tests/structured/aba/test_aba_shape_benchmark.py index d126f88..65dc4c3 100644 --- a/tests/test_aba_shape_benchmark.py +++ b/tests/structured/aba/test_aba_shape_benchmark.py @@ -4,9 +4,9 @@ from pathlib import Path import sys -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.iccma import write_aba +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.interop.iccma import write_aba from tools import aba_shape_benchmark from tools.aba_shape_benchmark import ( AbaShape, diff --git a/tests/test_aba_shape_contract.py b/tests/structured/aba/test_aba_shape_contract.py similarity index 96% rename from tests/test_aba_shape_contract.py rename to tests/structured/aba/test_aba_shape_contract.py index 0430804..717aea3 100644 --- a/tests/test_aba_shape_contract.py +++ b/tests/structured/aba/test_aba_shape_contract.py @@ -2,8 +2,8 @@ from dataclasses import asdict -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule from tools.aba_shape_benchmark import compute_aba_shape diff --git a/tests/test_aba_shape_properties.py b/tests/structured/aba/test_aba_shape_properties.py similarity index 98% rename from tests/test_aba_shape_properties.py rename to tests/structured/aba/test_aba_shape_properties.py index 688e1a2..cef956d 100644 --- a/tests/test_aba_shape_properties.py +++ b/tests/structured/aba/test_aba_shape_properties.py @@ -5,8 +5,8 @@ from hypothesis import given, settings -from argumentation.aba import ABAFramework -from argumentation.aspic import Literal, Rule +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import Literal, Rule from tests.aba_hypothesis_generators import ( flat_aba_frameworks, flat_aba_specs, diff --git a/tests/test_aba_sparse_narrow_native_sat.py b/tests/structured/aba/test_aba_sparse_narrow_native_sat.py similarity index 95% rename from tests/test_aba_sparse_narrow_native_sat.py rename to tests/structured/aba/test_aba_sparse_narrow_native_sat.py index 04ab4ce..bc6f708 100644 --- a/tests/test_aba_sparse_narrow_native_sat.py +++ b/tests/structured/aba/test_aba_sparse_narrow_native_sat.py @@ -3,9 +3,9 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation import aba_sat -from argumentation.aba import ABAFramework, AssumptionSet -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule @st.composite diff --git a/tests/test_aba_sparse_narrow_route_contract.py b/tests/structured/aba/test_aba_sparse_narrow_route_contract.py similarity index 96% rename from tests/test_aba_sparse_narrow_route_contract.py rename to tests/structured/aba/test_aba_sparse_narrow_route_contract.py index b8ba979..e5761c4 100644 --- a/tests/test_aba_sparse_narrow_route_contract.py +++ b/tests/structured/aba/test_aba_sparse_narrow_route_contract.py @@ -4,14 +4,14 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation import solver -from argumentation.aba import ABAFramework -from argumentation.aba_sat import NativeSparseNarrowSatResult -from argumentation.aba_route_policy import ( +from argumentation.solving import solver +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aba.aba_sat import NativeSparseNarrowSatResult +from argumentation.structured.aba.aba_route_policy import ( SPARSE_NARROW_NATIVE_SAT_PAGE_IMAGES, sparse_narrow_native_sat_shape, ) -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule FORBIDDEN_LOCATOR_KEYS = { diff --git a/tests/test_aba_structural_telemetry.py b/tests/structured/aba/test_aba_structural_telemetry.py similarity index 90% rename from tests/test_aba_structural_telemetry.py rename to tests/structured/aba/test_aba_structural_telemetry.py index 9047ab5..af78c4e 100644 --- a/tests/test_aba_structural_telemetry.py +++ b/tests/structured/aba/test_aba_structural_telemetry.py @@ -3,8 +3,8 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.aba import ABAFramework -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule # Paper anchors reread before authoring: @@ -102,7 +102,7 @@ def small_flat_aba_frameworks(draw): @given(small_flat_aba_frameworks()) @settings(max_examples=40) def test_telemetry_is_deterministic_and_omits_identity_keys(framework) -> None: - from argumentation.aba_telemetry import aba_structural_telemetry + from argumentation.structured.aba.aba_telemetry import aba_structural_telemetry first = aba_structural_telemetry(framework) second = aba_structural_telemetry(framework) @@ -119,7 +119,7 @@ def test_telemetry_is_deterministic_and_omits_identity_keys(framework) -> None: @given(small_flat_aba_frameworks()) @settings(max_examples=40) def test_telemetry_is_rule_order_invariant(framework) -> None: - from argumentation.aba_telemetry import aba_structural_telemetry + from argumentation.structured.aba.aba_telemetry import aba_structural_telemetry reordered = ABAFramework( language=framework.language, @@ -132,8 +132,8 @@ def test_telemetry_is_rule_order_invariant(framework) -> None: def test_duplicate_syntactic_rules_do_not_create_fake_atoms_or_assumptions() -> None: - from argumentation.aba_telemetry import aba_structural_telemetry - from argumentation.iccma import parse_aba + from argumentation.structured.aba.aba_telemetry import aba_structural_telemetry + from argumentation.interop.iccma import parse_aba framework = parse_aba( "\n".join( @@ -156,7 +156,7 @@ def test_duplicate_syntactic_rules_do_not_create_fake_atoms_or_assumptions() -> def test_deep_rule_dependency_chain_does_not_recurse_over_python_stack() -> None: - from argumentation.aba_telemetry import aba_structural_telemetry + from argumentation.structured.aba.aba_telemetry import aba_structural_telemetry assumption = lit("a") contrary = lit("ca") diff --git a/tests/test_aba_toni_2014_tutorial.py b/tests/structured/aba/test_aba_toni_2014_tutorial.py similarity index 87% rename from tests/test_aba_toni_2014_tutorial.py rename to tests/structured/aba/test_aba_toni_2014_tutorial.py index bca654a..f1c6e3d 100644 --- a/tests/test_aba_toni_2014_tutorial.py +++ b/tests/structured/aba/test_aba_toni_2014_tutorial.py @@ -2,8 +2,8 @@ import pytest -from argumentation.aba import ABAArgument, ABAFramework, NotFlatABAError, argument_for, attacks, derives -from argumentation.aspic import GroundAtom, Literal, Rule +from argumentation.structured.aba.aba import ABAArgument, ABAFramework, NotFlatABAError, argument_for, attacks, derives +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule def lit(name: str) -> Literal: diff --git a/tests/test_analyze_aba_route_evidence.py b/tests/structured/aba/test_analyze_aba_route_evidence.py similarity index 100% rename from tests/test_analyze_aba_route_evidence.py rename to tests/structured/aba/test_analyze_aba_route_evidence.py diff --git a/tests/structured/aspic/__init__.py b/tests/structured/aspic/__init__.py new file mode 100644 index 0000000..65d74a7 --- /dev/null +++ b/tests/structured/aspic/__init__.py @@ -0,0 +1 @@ +"""Tests for the ASPIC+ structured layer.""" diff --git a/tests/test_aspic.py b/tests/structured/aspic/test_aspic.py similarity index 97% rename from tests/test_aspic.py rename to tests/structured/aspic/test_aspic.py index 0ac1ecc..fafd26b 100644 --- a/tests/test_aspic.py +++ b/tests/structured/aspic/test_aspic.py @@ -1,2859 +1,2859 @@ -"""Tests for ASPIC+ logical language, contrariness, rules, and transposition. - -Property-based tests verify formal definitions from: - Modgil, S. & Prakken, H. (2018). A general account of argumentation - with preferences. Artificial Intelligence, 248, 51-104. - - Def 1 (p.8): Logical language L - - Def 2 (p.8): Contrariness function, contradictories vs contraries - - Def 2 (p.8): Strict rules R_s, defeasible rules R_d, naming function n - - Def 12 (p.13): Transposition closure for strict rules - - Prakken, H. (2010). An abstract framework for argumentation with - structured arguments. Argument & Computation, 1(2), 93-124. - - Def 3.1: Argumentation system tuple - - Def 3.2: Contrariness — symmetric (contradictory) vs asymmetric (contrary) - - Def 3.4 (p.47-48): Strict vs defeasible rules - - Def 5.1 (p.141-142): Transposition of strict rules - - Def 5.2: Closure under transposition - -Concrete regression tests verify hand-constructed examples. -""" - -from __future__ import annotations - -import pytest -from hypothesis import HealthCheck, given, settings, assume -from hypothesis import strategies as st - -from argumentation.aspic import ( - Literal, GroundAtom, ContrarinessFn, Rule, transposition_closure, - PremiseArg, StrictArg, DefeasibleArg, Argument, Attack, - KnowledgeBase, ArgumentationSystem, PreferenceConfig, - build_arguments, compute_attacks, compute_defeats, - _set_strictly_less, _strictly_weaker, _is_preference_independent_attack, - conc, prem, sub, top_rule, - def_rules, last_def_rules, prem_p, is_firm, is_strict, - CSAF, is_c_consistent, strict_closure, -) -from argumentation.dung import ( - ArgumentationFramework, - complete_extensions, - grounded_extension, - conflict_free, -) - - -# ── Hypothesis strategies ─────────────────────────────────────────── - - -@st.composite -def logical_language(draw, max_atoms=4): - """Generate a logical language L with contrariness function. - - Modgil & Prakken 2018, Defs 1-2 (p.8). - L consists of atoms and their negations. - Contradictory pairs: (p, ~p) are symmetric — if φ ∈ ¯ψ then ψ ∈ ¯φ. - """ - pool = ["p", "q", "r", "s", "t"] - # Draw 2-4 distinct atoms - atoms = draw( - st.lists( - st.sampled_from(pool), - min_size=2, - max_size=max_atoms, - unique=True, - ) - ) - # Build literals: each atom and its negation - literals = frozenset( - Literal(atom=GroundAtom(a), negated=n) for a in atoms for n in (False, True) - ) - # Build contrariness function: each atom and its negation are contradictories - contradictory_pairs = frozenset( - (Literal(atom=GroundAtom(a), negated=False), Literal(atom=GroundAtom(a), negated=True)) - for a in atoms - ) - cfn = ContrarinessFn(contradictories=contradictory_pairs) - return literals, cfn - - -# ── Property tests ───────────────────────────────────────────────── - - -class TestLanguageProperties: - """Property tests for logical language L. - - Every property cites the formal definition it verifies. - """ - - @given(logical_language()) - @settings(deadline=None) - def test_every_literal_has_negation_in_L(self, lang_cfn): - """For every literal in L, its .contrary is also in L. - - Modgil & Prakken 2018, Def 1 (p.8): L is closed — - every formula's contraries/contradictories are in L. - """ - L, _cfn = lang_cfn - for lit in L: - assert lit.contrary in L, ( - f"{lit}.contrary = {lit.contrary} not in L" - ) - - @given(logical_language()) - @settings(deadline=None) - def test_contrary_is_involutory(self, lang_cfn): - """For every literal a in L, a.contrary.contrary == a. - - Negation is an involution: applying it twice returns - the original formula. Follows from the symmetric structure - of contradictories (Modgil & Prakken 2018, Def 2, p.8). - """ - L, _cfn = lang_cfn - for lit in L: - assert lit.contrary.contrary == lit, ( - f"{lit}.contrary.contrary = {lit.contrary.contrary} != {lit}" - ) - - @given(logical_language()) - @settings(deadline=None) - def test_contradictories_are_symmetric(self, lang_cfn): - """If (a, b) is a contradictory pair, then (b, a) is also. - - Modgil & Prakken 2018, Def 2 (p.8): φ and ψ are contradictories - iff φ ∈ ¯ψ AND ψ ∈ ¯φ (symmetric relation). - Prakken 2010, Def 3.2: same symmetry condition. - """ - L, cfn = lang_cfn - for a in L: - for b in L: - if cfn.is_contradictory(a, b): - assert cfn.is_contradictory(b, a), ( - f"({a}, {b}) contradictory but ({b}, {a}) is not" - ) - - @given(logical_language()) - @settings(deadline=None) - def test_no_self_contrary(self, lang_cfn): - """No literal is contrary or contradictory to itself. - - A formula cannot be its own contrary or contradictory — - self-conflict is not permitted in a well-formed language. - Modgil & Prakken 2018, Def 2 (p.8): contrariness maps - formulas to *other* formulas. - """ - L, cfn = lang_cfn - for lit in L: - assert not cfn.is_contrary(lit, lit), ( - f"{lit} is contrary to itself" - ) - assert not cfn.is_contradictory(lit, lit), ( - f"{lit} is contradictory to itself" - ) - - @given(logical_language()) - @settings(deadline=None) - def test_language_nonempty(self, lang_cfn): - """L has at least 2 literals (an atom and its negation). - - A language must contain at least one atom and its negation - to support any argumentation. Follows from the strategy - drawing min_size=2 atoms, each producing 2 literals. - """ - L, _cfn = lang_cfn - assert len(L) >= 2 - - @given(logical_language()) - @settings(deadline=None) - def test_language_even_size(self, lang_cfn): - """|L| is even: every atom has exactly one negation. - - Since L is built from atoms and their negations, each atom - contributes exactly 2 literals. |L| = 2 * |atoms|. - """ - L, _cfn = lang_cfn - assert len(L) % 2 == 0, f"|L| = {len(L)} is odd" - - -# ── Concrete regression tests ────────────────────────────────────── - - -class TestLanguageConcrete: - """Hand-constructed examples for verifying language properties.""" - - def test_simple_two_atom_language(self): - """Manually construct L = {p, ~p, q, ~q}. - - Contradictories: {(p, ~p), (~p, p), (q, ~q), (~q, q)}. - Verifies all properties from TestLanguageProperties hold - on a concrete, known-good instance. - """ - p = Literal(atom=GroundAtom("p"), negated=False) - not_p = Literal(atom=GroundAtom("p"), negated=True) - q = Literal(atom=GroundAtom("q"), negated=False) - not_q = Literal(atom=GroundAtom("q"), negated=True) - - L = frozenset({p, not_p, q, not_q}) - - contradictory_pairs = frozenset({(p, not_p), (q, not_q)}) - cfn = ContrarinessFn(contradictories=contradictory_pairs) - - # Every literal's negation is in L - for lit in L: - assert lit.contrary in L - - # Involution - for lit in L: - assert lit.contrary.contrary == lit - - # Contradictories are symmetric - assert cfn.is_contradictory(p, not_p) - assert cfn.is_contradictory(not_p, p) - assert cfn.is_contradictory(q, not_q) - assert cfn.is_contradictory(not_q, q) - - # No self-contrary - for lit in L: - assert not cfn.is_contrary(lit, lit) - assert not cfn.is_contradictory(lit, lit) - - # Non-empty and even - assert len(L) == 4 - assert len(L) % 2 == 0 - - # Cross-atom pairs are not contradictory - assert not cfn.is_contradictory(p, q) - assert not cfn.is_contradictory(p, not_q) - assert not cfn.is_contradictory(not_p, q) - assert not cfn.is_contradictory(not_p, not_q) - - def test_asymmetric_contrary_is_one_way(self): - """A contrary is directional, while contradictories remain symmetric.""" - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - cfn = ContrarinessFn( - contradictories=frozenset(), - contraries=frozenset({(p, q)}), - ) - - assert cfn.is_contrary(p, q) is True - assert cfn.is_contrary(q, p) is False - assert cfn.is_contradictory(p, q) is False - - def test_asymmetric_contrary_generates_one_way_attack(self): - """Only the attacking direction licensed by the contrary should appear.""" - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - system = ArgumentationSystem( - language=frozenset({p, q}), - contrariness=ContrarinessFn( - contradictories=frozenset(), - contraries=frozenset({(p, q)}), - ), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, q}), - ) - - arg_p = PremiseArg(premise=p, is_axiom=False) - arg_q = PremiseArg(premise=q, is_axiom=False) - - attacks = compute_attacks(build_arguments(system, kb), system) - attack_pairs = {(attack.attacker, attack.target) for attack in attacks} - - assert (arg_p, arg_q) in attack_pairs - assert (arg_q, arg_p) not in attack_pairs - - -# ── Phase 2: Rule strategies ───────────────────────────────────── - - -@st.composite -def strict_rules(draw, language, contrariness, max_rules=4): - """Generate strict rules over L with transposition closure. - - Modgil & Prakken 2018, Defs 2, 12 (pp.8, 13). - Prakken 2010, Defs 3.4, 5.1-5.2 (pp.47-48, 141-142). - - 1. Draw 0-max_rules seed rules with 1-2 antecedents from L, consequent from L. - 2. Filter: consequent not in antecedents. - 3. Compute transposition closure (calls transposition_closure which does not - exist yet -- tests will fail with ImportError in red phase). - 4. Return the closed rule set. - """ - L_list = sorted(language, key=repr) - n_rules = draw(st.integers(min_value=0, max_value=max_rules)) - seed_rules: list[Rule] = [] - for _ in range(n_rules): - n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) - antecedents = tuple( - draw(st.sampled_from(L_list)) for _ in range(n_ante) - ) - consequent = draw(st.sampled_from(L_list)) - # Filter: consequent must not appear in antecedents - if consequent in antecedents: - continue - seed_rules.append( - Rule( - antecedents=antecedents, - consequent=consequent, - kind="strict", - name=None, - ) - ) - closed, _post_language = transposition_closure( - frozenset(seed_rules), - language, - contrariness, - ) - return closed - - -@st.composite -def strict_seed_rules(draw, language, contrariness, max_rules=4): - """Generate well-formed strict seed rules before transposition closure.""" - L_list = sorted(language, key=repr) - n_rules = draw(st.integers(min_value=0, max_value=max_rules)) - seed_rules: list[Rule] = [] - for _ in range(n_rules): - n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) - antecedents = tuple( - draw(st.sampled_from(L_list)) for _ in range(n_ante) - ) - consequent = draw(st.sampled_from(L_list)) - if consequent in antecedents: - continue - if any( - contrariness.is_contradictory(left, right) - for index, left in enumerate(antecedents) - for right in antecedents[index + 1:] - ): - continue - seed_rules.append( - Rule( - antecedents=antecedents, - consequent=consequent, - kind="strict", - name=None, - ) - ) - return frozenset(seed_rules) - - -def _contradictories_in_language( - literal: Literal, - language: frozenset[Literal], - contrariness: ContrarinessFn, -) -> tuple[Literal, ...]: - return tuple( - sorted( - ( - other - for other in language - if other != literal and contrariness.is_contradictory(literal, other) - ), - key=repr, - ) - ) - - -def _schema_transpositions( - rule: Rule, - language: frozenset[Literal], - contrariness: ContrarinessFn, -) -> frozenset[Rule]: - transpositions: set[Rule] = set() - if rule.kind != "strict": - return frozenset() - for index, antecedent in enumerate(rule.antecedents): - for contrary_consequent in _contradictories_in_language( - rule.consequent, - language, - contrariness, - ): - for contrary_antecedent in _contradictories_in_language( - antecedent, - language, - contrariness, - ): - transposed_antecedents = list(rule.antecedents) - transposed_antecedents[index] = contrary_consequent - if contrary_antecedent in transposed_antecedents: - continue - candidate = Rule( - antecedents=tuple(transposed_antecedents), - consequent=contrary_antecedent, - kind="strict", - name=None, - ) - if any( - contrariness.is_contradictory(left, right) - for left_index, left in enumerate(candidate.antecedents) - for right in candidate.antecedents[left_index + 1:] - ): - continue - transpositions.add(candidate) - return frozenset(transpositions) - - -@st.composite -def defeasible_rules(draw, language, max_rules=4): - """Generate defeasible rules with naming function. - - Modgil & Prakken 2018, Def 2 (p.8): each defeasible rule r has a - name n(r) in L, enabling undercutting attacks on the inference step. - - Prakken 2010, Def 3.4 (p.47-48): defeasible rules use => (presumptive). - - Names are strings "d0", "d1", etc. — these are rule identifiers, not - Literals. The name-Literals are created during argument construction - in Phase 3. - """ - L_list = sorted(language, key=repr) - n_rules = draw(st.integers(min_value=0, max_value=max_rules)) - rules: list[Rule] = [] - for i in range(n_rules): - n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) - antecedents = tuple( - draw(st.sampled_from(L_list)) for _ in range(n_ante) - ) - consequent = draw(st.sampled_from(L_list)) - # Filter: consequent must not appear in antecedents - if consequent in antecedents: - continue - rules.append( - Rule( - antecedents=antecedents, - consequent=consequent, - kind="defeasible", - name=f"d{i}", - ) - ) - return frozenset(rules) - - -# ── Phase 2: Rule property tests ───────────────────────────────── - - -class TestRuleProperties: - """Property tests for strict and defeasible rules. - - Verifies structural invariants of rules generated by the strategies. - Modgil & Prakken 2018, Def 2 (p.8); Prakken 2010, Def 3.4 (p.47-48). - """ - - pytestmark = pytest.mark.property - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_strict_rules_all_strict(self, lc_rules): - """Every rule from strict_rules() has kind == 'strict'. - - Modgil & Prakken 2018, Def 2 (p.8): R_s contains only strict rules. - """ - (_L, _cfn), rules = lc_rules - for r in rules: - assert r.kind == "strict", f"Rule {r} has kind={r.kind}, expected 'strict'" - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - defeasible_rules(language=lc[0]), - ) - )) - @settings(deadline=None) - def test_defeasible_rules_all_defeasible(self, lc_rules): - """Every rule from defeasible_rules() has kind == 'defeasible'. - - Modgil & Prakken 2018, Def 2 (p.8): R_d contains only defeasible rules. - """ - (_L, _cfn), rules = lc_rules - for r in rules: - assert r.kind == "defeasible", ( - f"Rule {r} has kind={r.kind}, expected 'defeasible'" - ) - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - defeasible_rules(language=lc[0]), - ) - )) - @settings(deadline=None) - def test_defeasible_rules_all_named(self, lc_rules): - """Every defeasible rule has name is not None. - - Modgil & Prakken 2018, Def 2 (p.8): the naming function n maps - each defeasible rule to a name n(r), enabling undercutting attacks. - """ - (_L, _cfn), rules = lc_rules - for r in rules: - assert r.name is not None, f"Defeasible rule {r} has no name" - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - defeasible_rules(language=lc[0]), - ) - )) - @settings(deadline=None) - def test_rule_antecedents_in_language(self, lc_sr_dr): - """All antecedents and consequents are in L. - - Modgil & Prakken 2018, Def 2 (p.8): rules are over the language L. - Prakken 2010, Def 3.4 (p.47-48): antecedents and consequent in L. - """ - (L, _cfn), s_rules, d_rules = lc_sr_dr - for r in s_rules | d_rules: - for ante in r.antecedents: - assert ante in L, f"Antecedent {ante} of rule {r} not in L" - assert r.consequent in L, f"Consequent {r.consequent} of rule {r} not in L" - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - defeasible_rules(language=lc[0]), - ) - )) - @settings(deadline=None) - def test_consequent_not_in_antecedents(self, lc_sr_dr): - """No rule has its consequent appearing in its antecedents. - - A rule phi_1, ..., phi_n -> psi must have psi not in {phi_1, ..., phi_n}. - This is a well-formedness constraint: a rule cannot trivially conclude - one of its own premises. - """ - (L, _cfn), s_rules, d_rules = lc_sr_dr - for r in s_rules | d_rules: - assert r.consequent not in r.antecedents, ( - f"Rule {r} has consequent {r.consequent} in antecedents" - ) - - -class TestTranspositionClosure: - """Property tests for transposition closure of strict rules. - - Modgil & Prakken 2018, Def 12 (p.13): if A1,...,An -> C is a strict - rule, then for each i, A1,...,~C,...,An -> ~Ai must also be in R_s. - - Prakken 2010, Defs 5.1-5.2 (pp.141-142): transposition and closure. - - Theorem 6.10 (Prakken 2010): closure under transposition is REQUIRED - for the rationality postulates to hold. - """ - - pytestmark = pytest.mark.property - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_seed_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_transposition_closure_is_extensive_for_well_formed_seed_rules(self, lc_rules): - """Seed strict rules are retained by closure. - - Grounded in - `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`: - Prakken 2010 Defs. 5.1-5.2 define closure by adding transpositions. - """ - (language, cfn), seed_rules = lc_rules - - closed, _post_language = transposition_closure(seed_rules, language, cfn) - assert seed_rules <= closed - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_seed_rules(language=lc[0], contrariness=lc[1]), - strict_seed_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_transposition_closure_is_monotone_over_added_seed_rules(self, lc_rules): - """Adding strict rules cannot shrink the transposition closure. - - Grounded in - `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`. - """ - (language, cfn), seed_rules, added_rules = lc_rules - - closed_seed, _seed_language = transposition_closure(seed_rules, language, cfn) - closed_union, _union_language = transposition_closure( - seed_rules | added_rules, - language, - cfn, - ) - - assert closed_seed <= closed_union - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_seed_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_every_added_rule_matches_prakken_2010_page_12_transposition_schema(self, lc_rules): - """Every non-seed closure member is generated by the transposition schema. - - Grounded in - `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`: - replacing one antecedent with a contradictory of the consequent yields - a strict rule whose consequent is a contradictory of that antecedent. - """ - (language, cfn), seed_rules = lc_rules - closed, _post_language = transposition_closure(seed_rules, language, cfn) - schema_rules = frozenset( - transposed - for rule in closed - for transposed in _schema_transpositions(rule, language, cfn) - ) - - assert closed - seed_rules <= schema_rules - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_transposition_closure_complete(self, lc_rules): - """For every strict rule A1,...,An -> C, for every i, the transposed - rule A1,...,~C,...,An -> ~Ai exists in R_s. - - Modgil & Prakken 2018, Def 12 (p.13). - Prakken 2010, Def 5.1 (p.141-142): a transposition of - phi_1,...,phi_n -> psi is phi_1,...,-psi,...,phi_n -> -phi_i. - """ - (L, _cfn), rules = lc_rules - for r in rules: - for i, ante_i in enumerate(r.antecedents): - # Build the transposed rule: replace antecedent i with ~C, - # consequent becomes ~ante_i - transposed_antes = list(r.antecedents) - transposed_antes[i] = r.consequent.contrary - transposed = Rule( - antecedents=tuple(transposed_antes), - consequent=ante_i.contrary, - kind="strict", - name=None, - ) - assert transposed in rules, ( - f"Transposition of {r} at position {i} missing: {transposed}" - ) - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_transposition_closure_idempotent(self, lc_rules): - """Applying transposition_closure again produces no new rules. - - Prakken 2010, Def 5.2: Cl_{R_s}(R_s) is the smallest set closed - under transposition. Applying closure to an already-closed set - must be a fixed point. - """ - (L, cfn), rules = lc_rules - closed_again, _post_language = transposition_closure(rules, L, cfn) - assert closed_again == rules, ( - f"Closure not idempotent: {len(closed_again)} rules vs {len(rules)}" - ) - - @given(logical_language().flatmap( - lambda lc: st.tuples( - st.just(lc), - strict_rules(language=lc[0], contrariness=lc[1]), - ) - )) - @settings(deadline=None) - def test_transposition_closure_preserves_kind(self, lc_rules): - """All transposed rules have kind == 'strict'. - - Transposition applies only to strict rules and produces strict rules. - Modgil & Prakken 2018, Def 12 (p.13): transposition is defined - only for strict rules in R_s. - """ - (_L, _cfn), rules = lc_rules - for r in rules: - assert r.kind == "strict", ( - f"Transposed rule {r} has kind={r.kind}, expected 'strict'" - ) - - def test_empty_rules_closure_is_empty(self): - """Transposition closure of the empty set is the empty set. - - Trivial base case: no rules means no transpositions to generate. - """ - L = frozenset({Literal(GroundAtom("p")), Literal(GroundAtom("p"), negated=True)}) - cfn = ContrarinessFn( - contradictories=frozenset({(Literal(GroundAtom("p")), Literal(GroundAtom("p"), negated=True))}) - ) - result, _post_language = transposition_closure(frozenset(), L, cfn) - assert result == frozenset(), f"Expected empty set, got {result}" - - def test_transposition_closure_does_not_erase_unrelated_rules_on_singleton_inconsistency(self): - """Transposition closure must not wipe out unrelated strict rules. - - Prakken 2010, Defs 5.1-5.3 (pp. 141-142; local page image - ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png``) - defines closure under transposition purely as the least fixpoint under - adding transpositions. It does not say to delete the whole strict theory - when one singleton closure is inconsistent. - """ - - p = Literal(GroundAtom("p")) - not_p = Literal(GroundAtom("p"), negated=True) - q = Literal(GroundAtom("q")) - not_q = Literal(GroundAtom("q"), negated=True) - r = Literal(GroundAtom("r")) - not_r = Literal(GroundAtom("r"), negated=True) - s = Literal(GroundAtom("s")) - not_s = Literal(GroundAtom("s"), negated=True) - language = frozenset({p, not_p, q, not_q, r, not_r, s, not_s}) - cfn = ContrarinessFn( - contradictories=frozenset({ - (p, not_p), - (q, not_q), - (r, not_r), - (s, not_s), - }) - ) - unrelated = Rule(antecedents=(r,), consequent=s, kind="strict") - rules = frozenset( - { - Rule(antecedents=(not_p,), consequent=q, kind="strict"), - Rule(antecedents=(q,), consequent=p, kind="strict"), - unrelated, - } - ) - - closed, _post_language = transposition_closure(rules, language, cfn) - - assert unrelated in closed - assert closed != frozenset() - - def test_transposition_closure_uses_explicit_contradictories_from_contrariness(self): - """Transposition must follow the supplied contradictory relation. - - Prakken 2010, Def. 5.1 (p. 141; local page image - ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png``) - defines transposition with the argumentation system's ``-`` operator. - If ``-`` is represented by ``ContrarinessFn``, the implementation must - use that relation instead of hard-coding ``Literal.contrary``. - """ - - p = Literal(GroundAtom("p")) - not_p = Literal(GroundAtom("p"), negated=True) - q = Literal(GroundAtom("q")) - not_q = Literal(GroundAtom("q"), negated=True) - r = Literal(GroundAtom("r")) - not_r = Literal(GroundAtom("r"), negated=True) - s = Literal(GroundAtom("s")) - not_s = Literal(GroundAtom("s"), negated=True) - language = frozenset({p, not_p, q, not_q, r, not_r, s, not_s}) - cfn = ContrarinessFn( - contradictories=frozenset({ - (p, q), - (not_p, not_q), - (r, s), - (not_r, not_s), - }) - ) - original = Rule(antecedents=(p,), consequent=r, kind="strict") - - closed, _post_language = transposition_closure(frozenset({original}), language, cfn) - - assert Rule(antecedents=(s,), consequent=q, kind="strict") in closed - - -class TestRuleConcrete: - """Hand-constructed examples for verifying rule and transposition properties.""" - - def test_married_bachelor_transposition(self): - """Classic example: married -> ~bachelor transposes to bachelor -> ~married. - - Modgil & Prakken 2014, Example 4.4. - Prakken 2010, Def 5.1 (p.141-142). - - Given strict rule: married -> ~bachelor - Transposition must produce: bachelor -> ~married - (Replace the single antecedent 'married' with ~(~bachelor) = bachelor, - and the consequent becomes ~married.) - """ - married = Literal(GroundAtom("married")) - not_married = Literal(GroundAtom("married"), negated=True) - bachelor = Literal(GroundAtom("bachelor")) - not_bachelor = Literal(GroundAtom("bachelor"), negated=True) - - L = frozenset({married, not_married, bachelor, not_bachelor}) - cfn = ContrarinessFn( - contradictories=frozenset({ - (married, not_married), - (bachelor, not_bachelor), - }) - ) - - # Original rule: married -> ~bachelor - original = Rule( - antecedents=(married,), - consequent=not_bachelor, - kind="strict", - name=None, - ) - - # Expected transposition: bachelor -> ~married - # (ante[0]=married replaced by ~(~bachelor)=bachelor, consequent=~married) - expected_transposition = Rule( - antecedents=(bachelor,), - consequent=not_married, - kind="strict", - name=None, - ) - - # Compute closure - closed, _post_language = transposition_closure(frozenset({original}), L, cfn) - - # Original must be preserved - assert original in closed, "Original rule missing from closure" - - # Transposition must be present - assert expected_transposition in closed, ( - f"Expected transposition {expected_transposition} not in closure: {closed}" - ) - - -# ── Phase 3: Knowledge base strategy ───────────────────────────── - - -@st.composite -def knowledge_base(draw, language, strict_rules, defeasible_rules): - """Generate a knowledge base with non-triviality guarantee. - - Modgil & Prakken 2018, Def 4 (p.9): K = K_n ∪ K_p where - K_n (axioms) are not attackable, K_p (ordinary premises) are attackable. - - Non-triviality: forces at least one rule's antecedents into K_p, - ensuring at least one non-premise argument can be constructed. - See reports/hypothesis-aspic-feasibility.md Section 3, Level 4. - """ - L_list = sorted(language, key=repr) - all_rules = list(strict_rules) + list(defeasible_rules) - - # Force at least one rule's antecedents into K_p for non-triviality - forced_premises: frozenset[Literal] = frozenset() - if all_rules: - target = draw(st.sampled_from(all_rules)) - forced_premises = frozenset(target.antecedents) - - # Draw additional K_p members from L (up to 4 total) - extra_kp = draw( - st.frozensets(st.sampled_from(L_list), max_size=4) - ) - K_p = forced_premises | extra_kp - - # Draw K_n members from L (up to 2), ensuring consistency - # (no literal and its contrary both in K_n) - K_n_candidates = draw( - st.frozensets(st.sampled_from(L_list), max_size=2) - ) - # Filter: no contradictory pair in K_n - K_n = frozenset() - for lit in K_n_candidates: - if lit.contrary not in K_n: - K_n = K_n | {lit} - - # Ensure K_n and K_p are disjoint - K_n = K_n - K_p - - return KnowledgeBase(axioms=K_n, premises=K_p) - - -@st.composite -def well_defined_knowledge_base(draw, language, strict_rules, defeasible_rules, contrariness): - """Generate a KB whose axioms and full base are c-consistent.""" - L_list = sorted(language, key=repr) - all_rules = list(strict_rules) + list(defeasible_rules) - - forced_premises: frozenset[Literal] = frozenset() - candidate_rules = [ - rule for rule in all_rules - if is_c_consistent(frozenset(rule.antecedents), strict_rules, contrariness) - ] - if candidate_rules: - target = draw(st.sampled_from(candidate_rules)) - forced_premises = frozenset(target.antecedents) - - extra_kp = draw( - st.frozensets(st.sampled_from(L_list), max_size=4) - ) - K_p = frozenset(forced_premises) - for lit in extra_kp: - tentative = K_p | {lit} - if is_c_consistent(tentative, strict_rules, contrariness): - K_p = tentative - - K_n_candidates = draw( - st.frozensets(st.sampled_from(L_list), max_size=2) - ) - K_n = frozenset() - for lit in K_n_candidates: - if lit in K_p: - continue - tentative = K_n | {lit} - if ( - is_c_consistent(tentative, strict_rules, contrariness) - and is_c_consistent(tentative | K_p, strict_rules, contrariness) - ): - K_n = tentative - - # Guard the full knowledge base, not just the incremental construction path. - # This keeps the rationality-postulate generators on genuinely well-defined - # c-SAF inputs even when Hypothesis shrinks toward edge cases. - assume(is_c_consistent(K_n | K_p, strict_rules, contrariness)) - return KnowledgeBase(axioms=K_n, premises=K_p) - - -# ── Phase 3: Argument construction property tests ───────────────── - - -class TestArgumentConstructionProperties: - """Property tests for recursive argument construction. - - Modgil & Prakken 2018, Def 5 (pp.9-10): arguments are constructed - recursively from premises and rules, with computed properties - Prem(A), Conc(A), Sub(A), DefRules(A), TopRule(A), LastDefRules(A). - - Prakken 2010, Def 3.6 (p.36): argument construction. - """ - - @given(data=st.data()) - @settings(deadline=None) - def test_premise_arg_conclusion_equals_premise(self, data): - """For every PremiseArg A, conc(A) == A.premise. - - Modgil & Prakken 2018, Def 5 clause 1 (p.9): if phi in K - then Conc(A) = phi. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - if isinstance(arg, PremiseArg): - assert conc(arg) == arg.premise, ( - f"PremiseArg conclusion {conc(arg)} != premise {arg.premise}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_sub_contains_self(self, data): - """For every argument A, A in sub(A). - - Modgil & Prakken 2018, Def 5 (p.9-10): Sub(A) always - includes A itself. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - assert arg in sub(arg), ( - f"Argument {arg} not in its own sub-arguments" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_sub_transitively_closed(self, data): - """For every A, for every B in sub(A), sub(B) ⊆ sub(A). - - Modgil & Prakken 2018, Def 5 (p.9-10): Sub is defined - recursively as Sub(A1) ∪ ... ∪ Sub(An) ∪ {A}, which - makes it transitively closed by construction. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - for b in sub(arg): - assert sub(b) <= sub(arg), ( - f"sub({b}) not subset of sub({arg})" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_prem_subset_of_kb(self, data): - """For every argument A, prem(A) ⊆ K_n ∪ K_p. - - Modgil & Prakken 2018, Def 5 (p.9): premises come from K. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - all_kb = kb.axioms | kb.premises - for arg in arguments: - assert prem(arg) <= all_kb, ( - f"prem({arg}) = {prem(arg)} not subset of K = {all_kb}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_every_built_argument_is_c_consistent(self, data): - """Public build_arguments() should emit only c-consistent arguments.""" - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - assert is_c_consistent(prem(arg), system.strict_rules, system.contrariness) - - @given(data=st.data()) - @settings(deadline=None) - def test_conc_in_language(self, data): - """For every argument A, conc(A) in L. - - Modgil & Prakken 2018, Def 5 (p.9-10): conclusions are - formulas in the logical language L. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - assert conc(arg) in L, ( - f"conc({arg}) = {conc(arg)} not in L" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_rule_args_antecedents_match(self, data): - """For every StrictArg/DefeasibleArg A, the conclusions of - A.sub_args match the antecedents of A.rule. - - Modgil & Prakken 2018, Def 5 clauses 2-3 (p.9-10): if - A1,...,An are arguments and their conclusions match a rule's - antecedents, then the compound is an argument. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - if isinstance(arg, (StrictArg, DefeasibleArg)): - sub_concs = tuple(conc(sa) for sa in arg.sub_args) - assert sub_concs == arg.rule.antecedents, ( - f"Sub-arg conclusions {sub_concs} != " - f"rule antecedents {arg.rule.antecedents}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_def_rules_empty_for_premise(self, data): - """For every PremiseArg A, def_rules(A) == frozenset(). - - Modgil & Prakken 2018, Def 5 clause 1 (p.9): - DefRules(A) = ∅ for premise arguments. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - if isinstance(arg, PremiseArg): - assert def_rules(arg) == frozenset(), ( - f"PremiseArg has non-empty def_rules: {def_rules(arg)}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_def_rules_includes_top_for_defeasible(self, data): - """For every DefeasibleArg A, A.rule in def_rules(A). - - Modgil & Prakken 2018, Def 5 clause 3 (p.9-10): - DefRules(A) = DefRules(A1) ∪ ... ∪ DefRules(An) ∪ {TopRule(A)}. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - if isinstance(arg, DefeasibleArg): - assert arg.rule in def_rules(arg), ( - f"DefeasibleArg top rule {arg.rule} not in " - f"def_rules: {def_rules(arg)}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_is_firm_iff_prem_subset_kn(self, data): - """is_firm(A) iff prem(A) ⊆ kb.axioms. - - Modgil & Prakken 2018, Def 5 / Prakken 2010 Def 3.8: - an argument is firm iff all its premises are axioms (K_n). - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - expected = prem(arg) <= kb.axioms - assert is_firm(arg) == expected, ( - f"is_firm({arg}) = {is_firm(arg)}, " - f"but prem(A) <= K_n is {expected}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_is_strict_iff_no_def_rules(self, data): - """is_strict(A) iff def_rules(A) == frozenset(). - - Modgil & Prakken 2018, Def 5 / Prakken 2010 Def 3.8: - an argument is strict iff it uses no defeasible rules. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - for arg in arguments: - expected = def_rules(arg) == frozenset() - assert is_strict(arg) == expected, ( - f"is_strict({arg}) = {is_strict(arg)}, " - f"but def_rules empty is {expected}" - ) - - @given(data=st.data()) - @settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much]) - def test_firm_strict_exist_when_kn_nonempty(self, data): - """When K_n is nonempty, at least one argument is both firm and strict. - - Modgil & Prakken 2018, Def 5 clause 1 (p.9): every phi in K - is a PremiseArg. If phi in K_n, the argument is firm (prem ⊆ K_n) - and strict (no defeasible rules). So K_n nonempty guarantees at - least one firm+strict argument. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - assume(len(kb.axioms) > 0) - # Prakken 2010, Thm. 6.10 (p. 145; local page image - # ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png``) - # requires consistent Cl_Rs(K_n) for the rationality guarantees. Once - # transposition_closure stops erasing inconsistent strict theories, this - # property must restrict itself to that well-defined fragment. - assume(is_c_consistent(kb.axioms, R_s, cfn)) - arguments = build_arguments(system, kb) - assert any( - is_firm(a) and is_strict(a) for a in arguments - ), "K_n nonempty but no firm+strict argument found" - - @given(data=st.data()) - @settings(deadline=None) - def test_nontriviality(self, data): - """When rules exist whose antecedents are in K, at least one - non-PremiseArg is constructed. - - This is the non-triviality guarantee from the knowledge_base - strategy: it forces at least one rule's antecedents into K_p, - ensuring build_arguments produces compound arguments when a - c-consistent rule antecedent set is available. - See reports/hypothesis-aspic-feasibility.md Section 3, Level 4. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - all_rules = R_s | R_d - assume(len(all_rules) > 0) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - assume(any( - frozenset(rule.antecedents) <= (kb.axioms | kb.premises) - and is_c_consistent( - frozenset(rule.antecedents), - system.strict_rules, - system.contrariness, - ) - for rule in all_rules - )) - non_premise = [ - a for a in arguments if not isinstance(a, PremiseArg) - ] - assert len(non_premise) > 0, ( - "Rules exist and antecedents are in K, " - "but no non-PremiseArg was constructed" - ) - - -class TestArgumentConstructionConcrete: - """Hand-constructed examples for argument construction.""" - - def test_simple_modus_ponens(self): - """K_p = {p, q}. Defeasible rule: p, q => r. - - Modgil & Prakken 2018, Def 5 (p.9-10): construct arguments - bottom-up from premises through rule application. - - Expected arguments: - - PremiseArg(p), PremiseArg(q) - - DefeasibleArg(sub_args=(PremiseArg(p), PremiseArg(q)), - rule=(p, q => r)) - The compound argument's conclusion is r. - """ - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - r = Literal(GroundAtom("r")) - not_p = p.contrary - not_q = q.contrary - not_r = r.contrary - - L = frozenset({p, q, r, not_p, not_q, not_r}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), (r, not_r), - })) - - rule_pq_r = Rule( - antecedents=(p, q), - consequent=r, - kind="defeasible", - name="d0", - ) - - system = ArgumentationSystem( - language=L, - contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_pq_r}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, q}), - ) - - arguments = build_arguments(system, kb) - - # Find premise arguments - prem_p = PremiseArg(premise=p, is_axiom=False) - prem_q = PremiseArg(premise=q, is_axiom=False) - assert prem_p in arguments, f"PremiseArg(p) not in arguments: {arguments}" - assert prem_q in arguments, f"PremiseArg(q) not in arguments: {arguments}" - - # Find the compound defeasible argument - compound = DefeasibleArg( - sub_args=(prem_p, prem_q), - rule=rule_pq_r, - ) - assert compound in arguments, ( - f"DefeasibleArg(p,q => r) not in arguments: {arguments}" - ) - - # Conclusion of the compound is r - assert conc(compound) == r, ( - f"Expected conc = r, got {conc(compound)}" - ) - - def test_c_inconsistent_argument_is_not_constructed(self): - """An argument with c-inconsistent premises must be excluded.""" - p = Literal(GroundAtom("p")) - not_p = p.contrary - q = Literal(GroundAtom("q")) - not_q = q.contrary - r = Literal(GroundAtom("r")) - not_r = r.contrary - - L = frozenset({p, not_p, q, not_q, r, not_r}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), (r, not_r), - })) - strict_rules, _post_language = transposition_closure( - frozenset({ - Rule((p,), q, "strict"), - Rule((not_q,), not_p, "strict"), - Rule((p, not_q), r, "strict"), - }), - L, - cfn, - ) - system = ArgumentationSystem( - language=L, - contrariness=cfn, - strict_rules=strict_rules, - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase( - axioms=frozenset({not_q}), - premises=frozenset({p}), - ) - - arguments = build_arguments(system, kb) - inconsistent_arg = StrictArg( - sub_args=( - PremiseArg(premise=p, is_axiom=False), - PremiseArg(premise=not_q, is_axiom=True), - ), - rule=Rule((p, not_q), r, "strict"), - ) - assert inconsistent_arg not in arguments - - -# ── Phase 4: Attack determination property tests ───────────────── - - -class TestAttackProperties: - """Property tests for three-type attack determination on sub-arguments. - - Modgil & Prakken 2018, Def 8 (p.11): undermining, rebutting, undercutting. - Pollock 1987, Defs 2.4-2.5 (p.485): rebutting vs undercutting defeaters. - - All tests use compute_attacks() which DOES NOT EXIST YET — tests - fail with ImportError. - """ - - @given(data=st.data()) - @settings(deadline=None) - def test_undermining_targets_ordinary_premises(self, data): - """Every undermining attack targets a PremiseArg with is_axiom == False. - - Modgil & Prakken 2018, Def 8a (p.11): A undermines B on B' iff - B' is a PremiseArg with ordinary premise phi (phi in K_p). - Axiom premises (K_n) cannot be undermined. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - if atk.kind == "undermining": - assert isinstance(atk.target_sub, PremiseArg), ( - f"Undermining target_sub is {type(atk.target_sub).__name__}, " - f"expected PremiseArg" - ) - assert not atk.target_sub.is_axiom, ( - f"Undermining attack targets axiom premise " - f"{atk.target_sub.premise} — axioms cannot be undermined" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_rebutting_targets_defeasible_conclusions(self, data): - """Every rebutting attack targets a sub-argument whose top_rule() is defeasible. - - Modgil & Prakken 2018, Def 8b (p.11): A rebuts B on B' iff - TopRule(B') is defeasible, and Conc(A) is in the contrariness of Conc(B'). - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - if atk.kind == "rebutting": - tr = top_rule(atk.target_sub) - assert tr is not None, ( - f"Rebutting target_sub has no top rule (PremiseArg)" - ) - assert tr.kind == "defeasible", ( - f"Rebutting target_sub top rule is {tr.kind}, " - f"expected 'defeasible'" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_undercutting_targets_defeasible_rules(self, data): - """Every undercutting attack targets a sub-argument whose top_rule() is defeasible. - - Modgil & Prakken 2018, Def 8c (p.11): A undercuts B on B' iff - TopRule(B') is a defeasible rule r, and Conc(A) is in the - contrariness of n(r). Strict rules cannot be undercut. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - if atk.kind == "undercutting": - tr = top_rule(atk.target_sub) - assert tr is not None, ( - f"Undercutting target_sub has no top rule (PremiseArg)" - ) - assert tr.kind == "defeasible", ( - f"Undercutting target_sub top rule is {tr.kind}, " - f"expected 'defeasible'" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_no_attack_on_firm_strict_subarg(self, data): - """No attack targets a sub-argument B' where is_firm(B') and is_strict(B'). - - Modgil & Prakken 2018, Def 18 (p.16): firm+strict sub-arguments - are unattackable — they use only axiom premises and strict rules. - Consequence of Def 8: undermining requires ordinary premises, - rebutting requires defeasible top rule, undercutting requires - defeasible top rule. Firm+strict satisfies none of these. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - assert not (is_firm(atk.target_sub) and is_strict(atk.target_sub)), ( - f"Attack {atk.kind} targets firm+strict sub-argument " - f"{atk.target_sub}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_attacker_and_target_in_arguments(self, data): - """Every attack's attacker and target are both in the argument set. - - compute_attacks operates over the argument set; it should not - introduce arguments from outside that set. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - assert atk.attacker in arguments, ( - f"Attacker {atk.attacker} not in argument set" - ) - assert atk.target in arguments, ( - f"Target {atk.target} not in argument set" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_target_sub_in_sub_of_target(self, data): - """Every attack's target_sub is in sub(attack.target). - - Modgil & Prakken 2018, Def 8 (p.11): all three attack types - require B' in Sub(B) — the attacked sub-argument must be a - sub-argument of the target argument. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - for atk in attacks: - assert atk.target_sub in sub(atk.target), ( - f"target_sub {atk.target_sub} not in sub({atk.target})" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_rebutting_symmetry_for_contradictories(self, data): - """Contradictory rebut is symmetric when both sides are defeasible. - - Modgil & Prakken 2018, Def 2 (p.8): contradictories are symmetric. - Def 8b (p.11): rebutting requires Conc(A) in contrariness of Conc(B'). - - The symmetry applies at the level of rebuttable defeasible conclusions. - If A rebuts B' and both A and B' have defeasible top rules with - contradictory conclusions, then B' must also rebut A. - - This does NOT hold when the attacker is only a premise or a strict - conclusion: Def 8b constrains the target structure, not the attacker. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - reverse_rebut_targets = { - (conc(atk2.attacker), id(atk2.target_sub)) - for atk2 in attacks - if atk2.kind == "rebutting" - } - - for atk in attacks: - if atk.kind != "rebutting": - continue - tr_a = top_rule(atk.attacker) - if tr_a is None or tr_a.kind != "defeasible": - continue - conc_a = conc(atk.attacker) - conc_b_prime = conc(atk.target_sub) - # Only check when they are contradictories (symmetric) - if not cfn.is_contradictory(conc_a, conc_b_prime): - continue - tr_b = top_rule(atk.target_sub) - if tr_b is None or tr_b.kind != "defeasible": - continue - # Index by target_sub identity to avoid quadratic deep-argument - # equality checks across large Hypothesis-generated attack sets. - reverse_exists = (conc_b_prime, id(atk.attacker)) in reverse_rebut_targets - assert reverse_exists, ( - f"Rebutting attack from defeasible {conc_a} on defeasible " - f"{conc_b_prime} is contradictory-symmetric, but no reverse " - f"rebutting attack onto the attacker was found" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_attack_kind_is_valid(self, data): - """Every attack has kind in {"undermining", "rebutting", "undercutting"}. - - Modgil & Prakken 2018, Def 8 (p.11): exactly three attack types. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - valid_kinds = {"undermining", "rebutting", "undercutting"} - for atk in attacks: - assert atk.kind in valid_kinds, ( - f"Attack kind '{atk.kind}' not in {valid_kinds}" - ) - - -class TestAttackConcrete: - """Hand-constructed examples for attack determination. - - Modgil & Prakken 2018, Def 8 (p.11). - Pollock 1987, Defs 2.4-2.5 (p.485). - """ - - def test_undermining_example(self): - """K_p = {p, ~p}. Defeasible rule: p => q. - The argument for ~p undermines the argument for q on sub-argument PremiseArg(p). - - Modgil & Prakken 2018, Def 8a (p.11): A undermines B on B' iff - B' in Sub(B), B' is a PremiseArg with ordinary premise phi (phi in K_p), - and Conc(A) is in the contrariness of phi. - - Here: Conc(A) = ~p, phi = p, ~p in bar(p) (contradictories). So A - undermines B on PremiseArg(p). - """ - p = Literal(GroundAtom("p")) - not_p = p.contrary - q = Literal(GroundAtom("q")) - not_q = q.contrary - - L = frozenset({p, not_p, q, not_q}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), - })) - - rule_p_q = Rule( - antecedents=(p,), consequent=q, - kind="defeasible", name="d0", - ) - - system = ArgumentationSystem( - language=L, contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_p_q}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, not_p}), - ) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - - prem_p_arg = PremiseArg(premise=p, is_axiom=False) - prem_not_p_arg = PremiseArg(premise=not_p, is_axiom=False) - compound_q = DefeasibleArg( - sub_args=(prem_p_arg,), rule=rule_p_q, - ) - - # The argument for ~p undermines the argument for q on PremiseArg(p) - expected = Attack( - attacker=prem_not_p_arg, - target=compound_q, - target_sub=prem_p_arg, - kind="undermining", - ) - assert expected in attacks, ( - f"Expected undermining attack {expected} not found in {attacks}" - ) - - def test_undercutting_example(self): - """K_p = {p, ~d0}. Defeasible rule d0: p => q (name="d0"). - The argument for ~d0 undercuts the argument for q. - - Modgil & Prakken 2018, Def 8c (p.11): A undercuts B on B' iff - B' in Sub(B), TopRule(B') is a defeasible rule r, and Conc(A) - is in the contrariness of n(r). - - Here: n(r) = d0 (as a Literal), Conc(A) = ~d0. ~d0 in bar(d0) - (contradictories). So A undercuts B on B'. - - Pollock 1987, Def 2.5 (p.485): undercutting defeats the connection - between premise and conclusion, not the conclusion itself. - """ - p = Literal(GroundAtom("p")) - not_p = p.contrary - q = Literal(GroundAtom("q")) - not_q = q.contrary - d0 = Literal(GroundAtom("d0")) - not_d0 = d0.contrary - - L = frozenset({p, not_p, q, not_q, d0, not_d0}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), (d0, not_d0), - })) - - rule_p_q = Rule( - antecedents=(p,), consequent=q, - kind="defeasible", name="d0", - ) - - system = ArgumentationSystem( - language=L, contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_p_q}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, not_d0}), - ) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - - prem_p_arg = PremiseArg(premise=p, is_axiom=False) - prem_not_d0_arg = PremiseArg(premise=not_d0, is_axiom=False) - compound_q = DefeasibleArg( - sub_args=(prem_p_arg,), rule=rule_p_q, - ) - - # The argument for ~d0 undercuts the argument for q - expected = Attack( - attacker=prem_not_d0_arg, - target=compound_q, - target_sub=compound_q, - kind="undercutting", - ) - assert expected in attacks, ( - f"Expected undercutting attack {expected} not found in {attacks}" - ) - - -# ── Phase 5: Preference ordering strategy ───────────────────────── - - -@st.composite -def preference_config(draw, defeasible_rules_set, premises): - """Generate a preference ordering configuration. - - Modgil & Prakken 2018, Defs 19-21 (p.21). - Def 22 (p.22): the inducing ordering must be a strict partial order - (irreflexive and transitive). - - Generates random partial orderings over rules and premises by: - 1. Drawing a random subset of pairs. - 2. Ensuring irreflexivity (no self-pairs). - 3. Computing transitive closure. - 4. Checking for cycles; if cyclic, falling back to empty ordering. - """ - # Build rule ordering: random subset of (weaker, stronger) pairs - rules_list = sorted(defeasible_rules_set, key=repr) - rule_pairs: set[tuple[Rule, Rule]] = set() - if len(rules_list) >= 2: - n_pairs = draw(st.integers(min_value=0, max_value=len(rules_list))) - for _ in range(n_pairs): - r1 = draw(st.sampled_from(rules_list)) - r2 = draw(st.sampled_from(rules_list)) - if r1 != r2: # irreflexivity - rule_pairs.add((r1, r2)) - # Transitive closure - rule_pairs = _transitive_closure_pairs(rule_pairs) - # Check for cycles (antisymmetry): if (a,b) and (b,a) both in set, discard all - if _has_cycle(rule_pairs): - rule_pairs = set() - - # Build premise ordering: random subset of (weaker, stronger) pairs - prem_list = sorted(premises, key=repr) - prem_pairs: set[tuple[Literal, Literal]] = set() - if len(prem_list) >= 2: - n_pairs = draw(st.integers(min_value=0, max_value=len(prem_list))) - for _ in range(n_pairs): - p1 = draw(st.sampled_from(prem_list)) - p2 = draw(st.sampled_from(prem_list)) - if p1 != p2: # irreflexivity - prem_pairs.add((p1, p2)) - # Transitive closure - prem_pairs = _transitive_closure_pairs(prem_pairs) - # Check for cycles - if _has_cycle(prem_pairs): - prem_pairs = set() - - comparison = draw(st.sampled_from(["elitist", "democratic"])) - link = draw(st.sampled_from(["last", "weakest"])) - - return PreferenceConfig( - rule_order=frozenset(rule_pairs), - premise_order=frozenset(prem_pairs), - comparison=comparison, - link=link, - ) - - -def _transitive_closure_pairs(pairs): - """Compute transitive closure of a set of (a, b) pairs.""" - closed = set(pairs) - changed = True - while changed: - changed = False - new = set() - for a, b in closed: - for c, d in closed: - if b == c and (a, d) not in closed and a != d: - new.add((a, d)) - if new: - closed.update(new) - changed = True - return closed - - -def _has_cycle(pairs): - """Check if a set of (a, b) pairs contains a cycle (a,b) and (b,a).""" - for a, b in pairs: - if (b, a) in pairs: - return True - return False - - -# ── Phase 5: Defeat property tests ──────────────────────────────── - - -class TestDefeatProperties: - """Property tests for defeat filtering via preference orderings. - - Modgil & Prakken 2018: - - Def 9 (p.12): when attacks succeed as defeats - - Def 19 (p.21): Elitist and Democratic set comparison - - Def 20 (p.21): Last-link principle - - Def 21 (p.21): Weakest-link principle - """ - - @given(data=st.data()) - @settings(deadline=None) - def test_undercutting_always_defeats(self, data): - """Every undercutting attack is a defeat regardless of preference ordering. - - Modgil & Prakken 2018, Def 9 (p.12): undercutting attacks are - preference-independent — they always succeed as defeats. - Pollock 1987, Def 2.5 (p.485): undercutting defeats the - connection between premise and conclusion. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - # Every undercutting attack must appear in defeats - undercutting_attacks = { - (atk.attacker, atk.target) - for atk in attacks if atk.kind == "undercutting" - } - defeat_pairs = {(d.attacker, d.target) for d in defeats} - for pair in undercutting_attacks: - assert pair in defeat_pairs, ( - f"Undercutting attack {pair} not in defeats — " - f"undercutting must always succeed (Def 9, p.12)" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_defeats_subset_of_attacks(self, data): - """Every defeat (a,b) corresponds to an attack from a on b. - - Modgil & Prakken 2018, Def 9 (p.12): defeats are a subset - of attacks — an attack must exist for a defeat to occur. - Defeats ⊆ Attacks. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - attack_pairs = { - (atk.attacker, atk.target, atk.target_sub) - for atk in attacks - } - for d in defeats: - assert (d.attacker, d.target, d.target_sub) in attack_pairs, ( - f"Defeat {d} has no corresponding attack" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_empty_ordering_still_respects_definition_19_edge_cases(self, data): - """Empty base orders do not erase Definition 19's empty-set lifting. - - Modgil & Prakken 2018, Def 19 (p.21) still makes a non-empty defeasible - set strictly less than an empty target set, even when the underlying - order relations themselves are empty. So defeats with empty preferences - are exactly the preference-independent attacks plus the attacks whose - attacker is not strictly weaker than the targeted sub-argument. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - empty_pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - defeats = compute_defeats(attacks, arguments, system, kb, empty_pref) - - expected_defeats = { - atk - for atk in attacks - if _is_preference_independent_attack(atk, system) - or not _strictly_weaker(atk.attacker, atk.target_sub, empty_pref, kb) - } - assert defeats == expected_defeats, ( - f"With empty preferences, defeats should still follow Def 9/19. " - f"Missing: {expected_defeats - defeats}; extra: {defeats - expected_defeats}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_defeat_is_directed(self, data): - """If (a,b) is a defeat, it means a defeats b — directionality is preserved. - - Modgil & Prakken 2018, Def 9 (p.12): defeat is a directed relation. - The attacker and target roles must be consistent with the attack. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - attack_pairs = { - (atk.attacker, atk.target, atk.target_sub) - for atk in attacks - } - for d in defeats: - # The defeat must correspond to an attack in the same direction. - assert (d.attacker, d.target, d.target_sub) in attack_pairs, ( - f"Defeat from {d.attacker} to {d.target} has no matching " - f"attack in the same direction" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_last_link_irreflexive(self, data): - """No argument is strictly weaker than itself under last-link. - - Modgil & Prakken 2018, Def 20 (p.21): the last-link principle - derives argument orderings from rule/premise orderings. Since the - inducing ordering is irreflexive (Def 22, p.22), no argument - can be strictly weaker than itself. - - Def 9 (p.12) compares the attacker against the targeted - sub-argument B', not automatically against itself. So even a - self-attack only succeeds when A is not strictly weaker than B'. - - Page-image grounding for last-link/lifting: - papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png - papers/Lehtonen_2024_PreferentialASPIC/pages/page_004.png - papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - # Force last-link - pref_last = PreferenceConfig( - rule_order=pref.rule_order, - premise_order=pref.premise_order, - comparison=pref.comparison, - link="last", - ) - defeats = compute_defeats(attacks, arguments, system, kb, pref_last) - - # Def 9 (p.12): self-attack does not bypass the A vs B' preference check. - for atk in attacks: - if atk.attacker == atk.target: - expected = _is_preference_independent_attack(atk, system) or ( - not _strictly_weaker(atk.attacker, atk.target_sub, pref_last, kb) - ) - actual = atk in defeats - assert actual == expected, ( - f"Self-attack {atk} should follow Def 9 against target_sub " - f"{atk.target_sub}: expected defeat={expected}, got {actual}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_weakest_link_irreflexive(self, data): - """No argument is strictly weaker than itself under weakest-link. - - Modgil & Prakken 2018, Def 21 (p.21): same irreflexivity - property as last-link, but under the weakest-link principle. - Def 22 (p.22): inducing ordering is irreflexive. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - # Force weakest-link - pref_weakest = PreferenceConfig( - rule_order=pref.rule_order, - premise_order=pref.premise_order, - comparison=pref.comparison, - link="weakest", - ) - defeats = compute_defeats(attacks, arguments, system, kb, pref_weakest) - - # Def 9 (p.12): self-attack does not bypass the A vs B' preference check. - for atk in attacks: - if atk.attacker == atk.target: - expected = _is_preference_independent_attack(atk, system) or ( - not _strictly_weaker( - atk.attacker, atk.target_sub, pref_weakest, kb - ) - ) - actual = atk in defeats - assert actual == expected, ( - f"Self-attack {atk} should follow Def 9 against target_sub " - f"{atk.target_sub}: expected defeat={expected}, got {actual}" - ) - - @given(data=st.data()) - @settings(deadline=None) - def test_firm_strict_never_defeated(self, data): - """If A is firm+strict, no argument B can defeat A. - - Modgil & Prakken 2018, Def 18 (p.16): firm+strict arguments - are never strictly weaker than any argument. Since they have - no ordinary premises and no defeasible rules, they cannot be - targets of undermining, rebutting, or undercutting attacks. - - Consequence of Def 18 conditions 1.i and 1.ii: strict+firm - arguments dominate all plausible/defeasible arguments, and - are never dominated. - """ - L, cfn = data.draw(logical_language()) - R_s = data.draw(strict_rules(L, cfn)) - R_d = data.draw(defeasible_rules(L)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = data.draw(knowledge_base(L, R_s, R_d)) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = data.draw(preference_config(R_d, kb.premises)) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - for d in defeats: - assert not (is_firm(d.target) and is_strict(d.target)), ( - f"Firm+strict argument {d.target} appears as defeat target — " - f"firm+strict arguments cannot be defeated (Def 18, p.16)" - ) - - -class TestSetComparisonProperties: - """Property tests for Def. 19 over finite induced orders. - - Prakken (2010), page 16, gives the elitist set order used by - last-link: - papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png - - Lehtonen (2024), pages 523-524, rephrases last-link defeat under - elitist and democratic lifting: - papers/Lehtonen_2024_PreferentialASPIC/pages/page_004.png - papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png - """ - - pytestmark = pytest.mark.property - - @given( - st.permutations((0, 1, 2, 3)), - st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), - st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), - ) - @settings(deadline=None) - def test_elitist_matches_definition_19(self, ranking, gamma, gamma_prime): - order_index = {item: idx for idx, item in enumerate(ranking)} - base_order = frozenset( - (x, y) - for x in ranking - for y in ranking - if order_index[x] < order_index[y] - ) - expected = any( - all((x, y) in base_order for y in gamma_prime) - for x in gamma - ) - actual = _set_strictly_less( - frozenset(gamma), - frozenset(gamma_prime), - base_order, - "elitist", - ) - assert actual == expected - - @given( - st.permutations((0, 1, 2, 3)), - st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), - st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), - ) - @settings(deadline=None) - def test_democratic_matches_definition_19(self, ranking, gamma, gamma_prime): - order_index = {item: idx for idx, item in enumerate(ranking)} - base_order = frozenset( - (x, y) - for x in ranking - for y in ranking - if order_index[x] < order_index[y] - ) - expected = all( - any((x, y) in base_order for y in gamma_prime) - for x in gamma - ) - actual = _set_strictly_less( - frozenset(gamma), - frozenset(gamma_prime), - base_order, - "democratic", - ) - assert actual == expected - - -class TestDefeatConcrete: - """Hand-constructed examples for defeat with preferences. - - Modgil & Prakken 2018, Def 9 (p.12), Defs 19-21 (p.21). - """ - - def test_elitist_set_comparison_matches_definition_19(self): - """Elitist comparison quantifies over Gamma, not Gamma'.""" - assert _set_strictly_less( - frozenset({1, 5}), - frozenset({3, 4}), - frozenset({(1, 3), (1, 4)}), - "elitist", - ) - - def test_definition_19_treats_nonempty_set_as_below_empty_set(self): - """Definition 19 (p.21) explicitly gives Gamma <_s empty when Gamma != empty.""" - base_order = frozenset({("weak", "strong")}) - - assert _set_strictly_less( - frozenset({"weak"}), - frozenset(), - base_order, - "elitist", - ) - assert _set_strictly_less( - frozenset({"weak"}), - frozenset(), - base_order, - "democratic", - ) - - def test_self_undermining_defeat_still_checks_target_sub_preference(self): - """Self-undermining does not automatically become a defeat. - - Modgil & Prakken 2018, Def 9 (p.12), compares attacker A against the - targeted sub-argument B'. Under Defs 19-20 (p.21), a defeasible - argument can be strictly weaker than its own premise sub-argument when - its last defeasible rule set is non-empty and the target sub-argument's - last defeasible rule set is empty. - """ - p = Literal(GroundAtom("p")) - not_p = p.contrary - - L = frozenset({p, not_p}) - cfn = ContrarinessFn(contradictories=frozenset({(p, not_p)})) - - rule_self_attack = Rule( - antecedents=(p,), - consequent=not_p, - kind="defeasible", - name="d_self_attack", - ) - - system = ArgumentationSystem( - language=L, - contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_self_attack}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p}), - ) - - premise_arg = PremiseArg(premise=p, is_axiom=False) - attacker = DefeasibleArg(sub_args=(premise_arg,), rule=rule_self_attack) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - self_undermining = Attack( - attacker=attacker, - target=attacker, - target_sub=premise_arg, - kind="undermining", - ) - assert self_undermining in attacks - - pref = PreferenceConfig( - rule_order=frozenset({(rule_self_attack, p)}), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - assert _strictly_weaker(attacker, premise_arg, pref, kb) - - defeats = compute_defeats(attacks, arguments, system, kb, pref) - assert self_undermining not in defeats - - def test_stronger_rebutter_defeats(self): - """Two defeasible arguments for contradictory conclusions. - Give one a stronger rule ordering. The stronger defeats the weaker, - not vice versa. - - Modgil & Prakken 2018, Def 9 (p.12): rebutting succeeds as defeat - iff the attacker is NOT strictly weaker than the targeted sub-argument. - If B prec A (B is weaker), then B's rebutting attack on A fails, - but A's rebutting attack on B succeeds. - """ - p = Literal(GroundAtom("p")) - not_p = p.contrary - q = Literal(GroundAtom("q")) - not_q = q.contrary - - L = frozenset({p, not_p, q, not_q}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), - })) - - # Two defeasible rules: both from p, one to q, one to ~q - rule_strong = Rule( - antecedents=(p,), consequent=q, - kind="defeasible", name="d_strong", - ) - rule_weak = Rule( - antecedents=(p,), consequent=not_q, - kind="defeasible", name="d_weak", - ) - - system = ArgumentationSystem( - language=L, contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_strong, rule_weak}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p}), - ) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - - # Preference: rule_weak < rule_strong (weak is strictly weaker) - pref = PreferenceConfig( - rule_order=frozenset({(rule_weak, rule_strong)}), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - prem_p_arg = PremiseArg(premise=p, is_axiom=False) - arg_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_strong) - arg_not_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_weak) - - # Strong defeats weak (A's attack on B succeeds: A not < B) - defeat_pairs = {(d.attacker, d.target) for d in defeats} - assert (arg_q, arg_not_q) in defeat_pairs, ( - f"Stronger argument should defeat weaker. " - f"Defeats: {defeat_pairs}" - ) - # Weak does NOT defeat strong (B's attack on A fails: B < A) - assert (arg_not_q, arg_q) not in defeat_pairs, ( - f"Weaker argument should not defeat stronger. " - f"Defeats: {defeat_pairs}" - ) - - def test_equal_strength_mutual_defeat(self): - """Two equally-ranked defeasible arguments with contradictory conclusions. - Both attacks succeed as defeats (neither is strictly weaker). - - Modgil & Prakken 2018, Def 9 (p.12): rebutting succeeds iff - A is NOT strictly weaker. If neither is weaker (equal or - incomparable), both attacks succeed as mutual defeats. - """ - p = Literal(GroundAtom("p")) - not_p = p.contrary - q = Literal(GroundAtom("q")) - not_q = q.contrary - - L = frozenset({p, not_p, q, not_q}) - cfn = ContrarinessFn(contradictories=frozenset({ - (p, not_p), (q, not_q), - })) - - rule_a = Rule( - antecedents=(p,), consequent=q, - kind="defeasible", name="d_a", - ) - rule_b = Rule( - antecedents=(p,), consequent=not_q, - kind="defeasible", name="d_b", - ) - - system = ArgumentationSystem( - language=L, contrariness=cfn, - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_a, rule_b}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p}), - ) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - - # Empty preference: neither rule is weaker - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - prem_p_arg = PremiseArg(premise=p, is_axiom=False) - arg_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_a) - arg_not_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_b) - - defeat_pairs = {(d.attacker, d.target) for d in defeats} - # Both directions should be defeats (mutual defeat) - assert (arg_q, arg_not_q) in defeat_pairs, ( - f"Equal-strength: arg_q should defeat arg_not_q. " - f"Defeats: {defeat_pairs}" - ) - assert (arg_not_q, arg_q) in defeat_pairs, ( - f"Equal-strength: arg_not_q should defeat arg_q. " - f"Defeats: {defeat_pairs}" - ) - - def test_elitist_last_link_blocks_rebut_when_attacker_is_strictly_weaker(self): - """A rebut must fail when one attacker rule is below every target rule. - - Last-link and elitist-lifting grounding: - papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png - papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png - """ - a = Literal(GroundAtom("a")) - b = Literal(GroundAtom("b")) - c = Literal(GroundAtom("c")) - x = Literal(GroundAtom("x")) - y = Literal(GroundAtom("y")) - q = Literal(GroundAtom("q")) - not_q = q.contrary - - d1 = Rule((a,), x, "defeasible", "d1") - d2 = Rule((b,), y, "defeasible", "d2") - t1 = Rule((c,), q, "defeasible", "t1") - s1 = Rule((x, y), not_q, "strict") - - system = ArgumentationSystem( - language=frozenset({a, b, c, x, y, q, not_q}), - contrariness=ContrarinessFn(frozenset({(q, not_q)})), - strict_rules=frozenset({s1}), - defeasible_rules=frozenset({d1, d2, t1}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({a, b, c}), - ) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - pref = PreferenceConfig( - rule_order=frozenset({(d1, t1)}), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - defeats = compute_defeats(attacks, arguments, system, kb, pref) - assert defeats == frozenset() - - def test_contrary_undermining_ignores_preference_ordering(self): - """Contrary undermining is preference-independent and always defeats.""" - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - system = ArgumentationSystem( - language=frozenset({p, q}), - contrariness=ContrarinessFn( - contradictories=frozenset(), - contraries=frozenset({(p, q)}), - ), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, q}), - ) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset({(p, q)}), - comparison="elitist", - link="last", - ) - - arg_p = PremiseArg(premise=p, is_axiom=False) - arg_q = PremiseArg(premise=q, is_axiom=False) - - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - defeat_pairs = {(defeat.attacker, defeat.target) for defeat in defeats} - - assert (arg_p, arg_q) in defeat_pairs - assert (arg_q, arg_p) not in defeat_pairs - - def test_last_and_weakest_link_can_diverge(self): - """A weak earlier rule can matter under weakest-link but not last-link.""" - a = Literal(GroundAtom("a")) - b = Literal(GroundAtom("b")) - x = Literal(GroundAtom("x")) - q = Literal(GroundAtom("q")) - not_q = q.contrary - - d_weak = Rule((a,), x, "defeasible", "d_weak") - d_strong = Rule((x,), not_q, "defeasible", "d_strong") - t_mid = Rule((b,), q, "defeasible", "t_mid") - - system = ArgumentationSystem( - language=frozenset({a, b, x, q, not_q}), - contrariness=ContrarinessFn( - contradictories=frozenset({(q, not_q)}), - ), - strict_rules=frozenset(), - defeasible_rules=frozenset({d_weak, d_strong, t_mid}), - ) - kb = KnowledgeBase( - axioms=frozenset({a, b}), - premises=frozenset(), - ) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - - pref_last = PreferenceConfig( - rule_order=frozenset({ - (d_weak, t_mid), - (t_mid, d_strong), - (d_weak, d_strong), - }), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - pref_weakest = PreferenceConfig( - rule_order=pref_last.rule_order, - premise_order=frozenset(), - comparison="elitist", - link="weakest", - ) - - arg_a = PremiseArg(premise=a, is_axiom=True) - arg_b = PremiseArg(premise=b, is_axiom=True) - arg_not_q = DefeasibleArg( - sub_args=(DefeasibleArg(sub_args=(arg_a,), rule=d_weak),), - rule=d_strong, - ) - arg_q = DefeasibleArg(sub_args=(arg_b,), rule=t_mid) - - last_pairs = { - (defeat.attacker, defeat.target) - for defeat in compute_defeats(attacks, arguments, system, kb, pref_last) - } - weakest_pairs = { - (defeat.attacker, defeat.target) - for defeat in compute_defeats(attacks, arguments, system, kb, pref_weakest) - } - - assert (arg_not_q, arg_q) in last_pairs - assert (arg_q, arg_not_q) not in last_pairs - assert (arg_not_q, arg_q) not in weakest_pairs - assert (arg_q, arg_not_q) in weakest_pairs - - -# ── Phase 6: Rationality postulate strategies and tests ────────── - - -ASPIC_RATIONALITY_MAX_ARGUMENTS = 10 -ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES = 1 << ASPIC_RATIONALITY_MAX_ARGUMENTS - - -@st.composite -def well_formed_csaf(draw, max_atoms=4, max_strict=3, max_defeasible=4): - """Generate a well-formed c-SAF per Modgil & Prakken 2018, Def 12. - - Chains: logical_language -> strict_rules -> defeasible_rules -> well_defined_knowledge_base - Then runs: build_arguments -> compute_attacks -> compute_defeats - Finally emits: dung.ArgumentationFramework for extension computation. - - Guarantees: - - Axiom consistency (Cl_Rs(K_n) is c-consistent) - - Well-formed contrariness (contradictories symmetric) - - Transposition closure (R_s = Cl(R_s)) - - At least one non-premise argument (non-triviality) - - Modgil & Prakken 2018, Def 12 (p.13): a c-SAF is well-defined iff - it is axiom consistent, well-formed, and closed under transposition. - """ - L, cfn = draw(logical_language(max_atoms=max_atoms)) - R_s = draw(strict_rules(L, cfn, max_rules=max_strict)) - R_d = draw(defeasible_rules(L, max_rules=max_defeasible)) - system = ArgumentationSystem(L, cfn, R_s, R_d) - kb = draw(well_defined_knowledge_base(L, R_s, R_d, cfn)) - pref = draw(preference_config(R_d, kb.premises)) - - # Computed (not drawn) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - defeat_attacks = compute_defeats(attacks, arguments, system, kb, pref) - - # Extract defeat pairs (Argument, Argument) from Attack objects - defeats = frozenset( - (atk.attacker, atk.target) for atk in defeat_attacks - ) - - # Build Dung AF - # Assign string IDs to arguments for the Dung layer - arg_list = sorted(arguments, key=lambda a: repr(a)) # deterministic ordering - arg_to_id = {a: f"arg_{i}" for i, a in enumerate(arg_list)} - id_to_arg = {v: k for k, v in arg_to_id.items()} - - af = ArgumentationFramework( - arguments=frozenset(arg_to_id.values()), - defeats=frozenset( - (arg_to_id[a], arg_to_id[b]) - for a, b in defeats - if a in arg_to_id and b in arg_to_id - ), - attacks=frozenset( - (arg_to_id[atk.attacker], arg_to_id[atk.target]) - for atk in attacks - if atk.attacker in arg_to_id and atk.target in arg_to_id - ), - ) - - return CSAF( - system=system, kb=kb, pref=pref, arguments=arguments, - attacks=attacks, defeats=defeats, framework=af, - arg_to_id=arg_to_id, id_to_arg=id_to_arg, - ) - - -@st.composite -def exact_complete_extension_csaf(draw): - """Generate c-SAFs small enough for exhaustive complete-extension checks.""" - csaf = draw(well_formed_csaf(max_atoms=3, max_strict=2, max_defeasible=2)) - assume(len(csaf.framework.arguments) <= ASPIC_RATIONALITY_MAX_ARGUMENTS) - return csaf - - -# ── Phase 6: The 8 Rationality Postulate Tests ────────────────── - - -class TestRationalityPostulates: - """Property tests for the 8 rationality postulates of ASPIC+. - - These are the crown jewel: if all 8 hold on 200 random well-formed - c-SAFs, the implementation is correct per Modgil & Prakken 2018. - - Complete-extension postulates use bounded generated c-SAFs because exact - Dung complete-extension enumeration is exponential in the projected AF size. - """ - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_sub_argument_closure(self, csaf): - """Postulate 1 — Sub-argument closure (Thm 12, p.18). - - If A is in a complete extension E and A' is in Sub(A), - then A' is also in E. Extensions are closed under sub-arguments. - - Modgil & Prakken 2018, Theorem 12 (p.18): for every attack- - conflict-free complete extension E of a well-defined c-SAF - with reasonable ordering, Sub(E) = E. - """ - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - for aid in ext_ids: - arg = csaf.id_to_arg[aid] - for sub_arg in sub(arg): - assert csaf.arg_to_id[sub_arg] in ext_ids, ( - f"Sub-argument {sub_arg} of {arg} not in extension" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_strict_closure(self, csaf): - """Postulate 2 — Closure under strict rules (Thm 13, p.18). - - Cl_Rs(Conc(E)) = Conc(E): the set of conclusions of arguments - in a complete extension is already closed under strict rules. - - Modgil & Prakken 2018, Theorem 13 (p.18): for every attack- - conflict-free complete extension E of a well-defined c-SAF - with reasonable ordering, Cl_Rs(Conc(E)) = Conc(E). - """ - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - conclusions = frozenset( - conc(csaf.id_to_arg[aid]) for aid in ext_ids - ) - closed = strict_closure(conclusions, csaf.system.strict_rules) - assert closed == conclusions, ( - f"Strict closure added: {closed - conclusions}" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_direct_consistency(self, csaf): - """Postulate 3 — Direct consistency (Thm 14, p.18). - - No two conclusions in a complete extension are contraries or - contradictories. The extension conclusions are directly consistent. - - Modgil & Prakken 2018, Theorem 14 (p.18): for every attack- - conflict-free complete extension E of a well-defined c-SAF - with reasonable ordering, Conc(E) is consistent. - """ - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - conclusions = [conc(csaf.id_to_arg[aid]) for aid in ext_ids] - for i, c1 in enumerate(conclusions): - for c2 in conclusions[i + 1:]: - assert not csaf.system.contrariness.is_contradictory(c1, c2), ( - f"Direct inconsistency: {c1} and {c2} are contradictories" - ) - assert not csaf.system.contrariness.is_contrary(c1, c2), ( - f"Direct inconsistency: {c1} is a contrary of {c2}" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_indirect_consistency(self, csaf): - """Postulate 4 — Indirect consistency (Thm 15, p.19). - - Cl_Rs(Conc(E)) is consistent: the strict closure of conclusions - contains no contradictory pair. - - Modgil & Prakken 2018, Theorem 15 (p.19): for every attack- - conflict-free complete extension E of a well-defined c-SAF - with reasonable ordering, Cl_Rs(Conc(E)) is consistent. - """ - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - conclusions = frozenset( - conc(csaf.id_to_arg[aid]) for aid in ext_ids - ) - closed = strict_closure(conclusions, csaf.system.strict_rules) - closed_list = list(closed) - for i, c1 in enumerate(closed_list): - for c2 in closed_list[i + 1:]: - assert not csaf.system.contrariness.is_contradictory(c1, c2), ( - f"Indirect inconsistency: {c1} and {c2} are " - f"contradictories in Cl_Rs(Conc(E))" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_firm_strict_in_every_complete(self, csaf): - """Postulate 5 — Firm+strict in every complete extension (Def 18). - - Every argument that is both firm (all premises are axioms) and - strict (no defeasible rules) must be in every complete extension. - - Modgil & Prakken 2018, Def 18 (p.16): reasonable orderings - require that firm+strict arguments are never strictly weaker - than any argument. Combined with the fundamental lemma (Props - 9-11, p.17), this means they are in every complete extension. - """ - firm_strict_ids = { - csaf.arg_to_id[a] for a in csaf.arguments - if is_firm(a) and is_strict(a) - } - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - assert firm_strict_ids <= ext_ids, ( - f"Firm+strict args {firm_strict_ids - ext_ids} " - f"not in complete extension" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_undercutting_always_defeats(self, csaf): - """Postulate 6 — Undercutting always defeats (Def 9). - - Every undercutting attack succeeds as a defeat regardless of - the preference ordering. Undercutting is preference-independent. - - Modgil & Prakken 2018, Def 9 (p.12): undercutting attacks - always succeed as defeats. - Pollock 1987, Def 2.5 (p.485): undercutting defeaters. - """ - for atk in csaf.attacks: - if atk.kind == "undercutting": - pair = (csaf.arg_to_id[atk.attacker], csaf.arg_to_id[atk.target]) - assert pair in csaf.framework.defeats, ( - f"Undercutting attack {atk} not in framework defeats" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_attack_based_conflict_free(self, csaf): - """Postulate 7 — Attack-based conflict-free (Def 14). - - Every complete extension is conflict-free with respect to the - attack relation (not just the defeat relation). - - Modgil & Prakken 2018, Def 14 (p.14): a set S is attack-based - conflict-free iff no argument in S attacks another argument in S. - This is strictly stronger than defeat-based conflict-free. - """ - for ext_ids in complete_extensions( - csaf.framework, - max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, - ): - assert conflict_free(ext_ids, csaf.framework.attacks), ( - f"Complete extension {ext_ids} is not attack-based " - f"conflict-free" - ) - - @given(exact_complete_extension_csaf()) - @settings(deadline=None) - def test_transposition_closure_maintained(self, csaf): - """Postulate 8 — Transposition closure (Def 12). - - The strict rules in the argumentation system are already closed - under transposition. Applying transposition_closure again - produces the same set. - - Modgil & Prakken 2018, Def 12 (p.13): well-definedness requires - closure under transposition. - Prakken 2010, Theorem 6.10: transposition closure is REQUIRED - for the rationality postulates to hold. - """ - closed, _post_language = transposition_closure( - csaf.system.strict_rules, - csaf.system.language, - csaf.system.contrariness, - ) - assert closed == csaf.system.strict_rules, ( - f"Strict rules not closed under transposition: " - f"{len(closed)} rules after closure vs {len(csaf.system.strict_rules)}" - ) - - -# ── Phase 6: Concrete regression test ─────────────────────────── - - -class TestRationalityPostulatesConcrete: - """Hand-crafted regression tests for rationality postulates. - - These complement the property tests with known examples from the - literature, providing deterministic coverage of specific scenarios. - """ - - def test_married_bachelor_consistency(self): - """The married/bachelor example from Modgil 2014, Example 4.4. - - L = {married, ~married, bachelor, ~bachelor} - Strict rule: married -> ~bachelor (with transposition: bachelor -> ~married) - K_p = {married, bachelor} - - Build CSAF. Compute grounded extension. - Assert: the extension does NOT contain both bachelor and ~bachelor - (direct consistency holds). - - This is a classic example of how transposition closure ensures - consistency: the strict rule generates a counter-argument that - prevents contradictory conclusions from coexisting in any extension. - """ - married = Literal(GroundAtom("married")) - not_married = Literal(GroundAtom("married"), negated=True) - bachelor = Literal(GroundAtom("bachelor")) - not_bachelor = Literal(GroundAtom("bachelor"), negated=True) - - L = frozenset({married, not_married, bachelor, not_bachelor}) - cfn = ContrarinessFn(contradictories=frozenset({ - (married, not_married), - (bachelor, not_bachelor), - })) - - # Strict rule: married -> ~bachelor - rule_m_nb = Rule( - antecedents=(married,), - consequent=not_bachelor, - kind="strict", - name=None, - ) - - # Compute transposition closure: adds bachelor -> ~married - R_s, _post_language = transposition_closure(frozenset({rule_m_nb}), L, cfn) - - system = ArgumentationSystem( - language=L, contrariness=cfn, - strict_rules=R_s, - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({married, bachelor}), - ) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - # Build CSAF manually - arguments = build_arguments(system, kb) - attacks_set = compute_attacks(arguments, system) - defeat_attacks = compute_defeats(attacks_set, arguments, system, kb, pref) - defeats = frozenset( - (atk.attacker, atk.target) for atk in defeat_attacks - ) - - arg_list = sorted(arguments, key=lambda a: repr(a)) - arg_to_id = {a: f"arg_{i}" for i, a in enumerate(arg_list)} - id_to_arg = {v: k for k, v in arg_to_id.items()} - - af = ArgumentationFramework( - arguments=frozenset(arg_to_id.values()), - defeats=frozenset( - (arg_to_id[a], arg_to_id[b]) - for a, b in defeats - if a in arg_to_id and b in arg_to_id - ), - attacks=frozenset( - (arg_to_id[atk.attacker], arg_to_id[atk.target]) - for atk in attacks_set - if atk.attacker in arg_to_id and atk.target in arg_to_id - ), - ) - - csaf = CSAF( - system=system, kb=kb, pref=pref, arguments=arguments, - attacks=attacks_set, defeats=defeats, framework=af, - arg_to_id=arg_to_id, id_to_arg=id_to_arg, - ) - - # Compute grounded extension - ext = grounded_extension(csaf.framework) - - # Direct consistency: extension should NOT contain both - # bachelor and ~bachelor conclusions - ext_conclusions = {conc(csaf.id_to_arg[aid]) for aid in ext} - assert not (bachelor in ext_conclusions and not_bachelor in ext_conclusions), ( - f"Grounded extension contains both bachelor and ~bachelor — " - f"direct consistency violated. Conclusions: {ext_conclusions}" - ) +"""Tests for ASPIC+ logical language, contrariness, rules, and transposition. + +Property-based tests verify formal definitions from: + Modgil, S. & Prakken, H. (2018). A general account of argumentation + with preferences. Artificial Intelligence, 248, 51-104. + - Def 1 (p.8): Logical language L + - Def 2 (p.8): Contrariness function, contradictories vs contraries + - Def 2 (p.8): Strict rules R_s, defeasible rules R_d, naming function n + - Def 12 (p.13): Transposition closure for strict rules + + Prakken, H. (2010). An abstract framework for argumentation with + structured arguments. Argument & Computation, 1(2), 93-124. + - Def 3.1: Argumentation system tuple + - Def 3.2: Contrariness — symmetric (contradictory) vs asymmetric (contrary) + - Def 3.4 (p.47-48): Strict vs defeasible rules + - Def 5.1 (p.141-142): Transposition of strict rules + - Def 5.2: Closure under transposition + +Concrete regression tests verify hand-constructed examples. +""" + +from __future__ import annotations + +import pytest +from hypothesis import HealthCheck, given, settings, assume +from hypothesis import strategies as st + +from argumentation.structured.aspic.aspic import ( + Literal, GroundAtom, ContrarinessFn, Rule, transposition_closure, + PremiseArg, StrictArg, DefeasibleArg, Argument, Attack, + KnowledgeBase, ArgumentationSystem, PreferenceConfig, + build_arguments, compute_attacks, compute_defeats, + _set_strictly_less, _strictly_weaker, _is_preference_independent_attack, + conc, prem, sub, top_rule, + def_rules, last_def_rules, prem_p, is_firm, is_strict, + CSAF, is_c_consistent, strict_closure, +) +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + grounded_extension, + conflict_free, +) + + +# ── Hypothesis strategies ─────────────────────────────────────────── + + +@st.composite +def logical_language(draw, max_atoms=4): + """Generate a logical language L with contrariness function. + + Modgil & Prakken 2018, Defs 1-2 (p.8). + L consists of atoms and their negations. + Contradictory pairs: (p, ~p) are symmetric — if φ ∈ ¯ψ then ψ ∈ ¯φ. + """ + pool = ["p", "q", "r", "s", "t"] + # Draw 2-4 distinct atoms + atoms = draw( + st.lists( + st.sampled_from(pool), + min_size=2, + max_size=max_atoms, + unique=True, + ) + ) + # Build literals: each atom and its negation + literals = frozenset( + Literal(atom=GroundAtom(a), negated=n) for a in atoms for n in (False, True) + ) + # Build contrariness function: each atom and its negation are contradictories + contradictory_pairs = frozenset( + (Literal(atom=GroundAtom(a), negated=False), Literal(atom=GroundAtom(a), negated=True)) + for a in atoms + ) + cfn = ContrarinessFn(contradictories=contradictory_pairs) + return literals, cfn + + +# ── Property tests ───────────────────────────────────────────────── + + +class TestLanguageProperties: + """Property tests for logical language L. + + Every property cites the formal definition it verifies. + """ + + @given(logical_language()) + @settings(deadline=None) + def test_every_literal_has_negation_in_L(self, lang_cfn): + """For every literal in L, its .contrary is also in L. + + Modgil & Prakken 2018, Def 1 (p.8): L is closed — + every formula's contraries/contradictories are in L. + """ + L, _cfn = lang_cfn + for lit in L: + assert lit.contrary in L, ( + f"{lit}.contrary = {lit.contrary} not in L" + ) + + @given(logical_language()) + @settings(deadline=None) + def test_contrary_is_involutory(self, lang_cfn): + """For every literal a in L, a.contrary.contrary == a. + + Negation is an involution: applying it twice returns + the original formula. Follows from the symmetric structure + of contradictories (Modgil & Prakken 2018, Def 2, p.8). + """ + L, _cfn = lang_cfn + for lit in L: + assert lit.contrary.contrary == lit, ( + f"{lit}.contrary.contrary = {lit.contrary.contrary} != {lit}" + ) + + @given(logical_language()) + @settings(deadline=None) + def test_contradictories_are_symmetric(self, lang_cfn): + """If (a, b) is a contradictory pair, then (b, a) is also. + + Modgil & Prakken 2018, Def 2 (p.8): φ and ψ are contradictories + iff φ ∈ ¯ψ AND ψ ∈ ¯φ (symmetric relation). + Prakken 2010, Def 3.2: same symmetry condition. + """ + L, cfn = lang_cfn + for a in L: + for b in L: + if cfn.is_contradictory(a, b): + assert cfn.is_contradictory(b, a), ( + f"({a}, {b}) contradictory but ({b}, {a}) is not" + ) + + @given(logical_language()) + @settings(deadline=None) + def test_no_self_contrary(self, lang_cfn): + """No literal is contrary or contradictory to itself. + + A formula cannot be its own contrary or contradictory — + self-conflict is not permitted in a well-formed language. + Modgil & Prakken 2018, Def 2 (p.8): contrariness maps + formulas to *other* formulas. + """ + L, cfn = lang_cfn + for lit in L: + assert not cfn.is_contrary(lit, lit), ( + f"{lit} is contrary to itself" + ) + assert not cfn.is_contradictory(lit, lit), ( + f"{lit} is contradictory to itself" + ) + + @given(logical_language()) + @settings(deadline=None) + def test_language_nonempty(self, lang_cfn): + """L has at least 2 literals (an atom and its negation). + + A language must contain at least one atom and its negation + to support any argumentation. Follows from the strategy + drawing min_size=2 atoms, each producing 2 literals. + """ + L, _cfn = lang_cfn + assert len(L) >= 2 + + @given(logical_language()) + @settings(deadline=None) + def test_language_even_size(self, lang_cfn): + """|L| is even: every atom has exactly one negation. + + Since L is built from atoms and their negations, each atom + contributes exactly 2 literals. |L| = 2 * |atoms|. + """ + L, _cfn = lang_cfn + assert len(L) % 2 == 0, f"|L| = {len(L)} is odd" + + +# ── Concrete regression tests ────────────────────────────────────── + + +class TestLanguageConcrete: + """Hand-constructed examples for verifying language properties.""" + + def test_simple_two_atom_language(self): + """Manually construct L = {p, ~p, q, ~q}. + + Contradictories: {(p, ~p), (~p, p), (q, ~q), (~q, q)}. + Verifies all properties from TestLanguageProperties hold + on a concrete, known-good instance. + """ + p = Literal(atom=GroundAtom("p"), negated=False) + not_p = Literal(atom=GroundAtom("p"), negated=True) + q = Literal(atom=GroundAtom("q"), negated=False) + not_q = Literal(atom=GroundAtom("q"), negated=True) + + L = frozenset({p, not_p, q, not_q}) + + contradictory_pairs = frozenset({(p, not_p), (q, not_q)}) + cfn = ContrarinessFn(contradictories=contradictory_pairs) + + # Every literal's negation is in L + for lit in L: + assert lit.contrary in L + + # Involution + for lit in L: + assert lit.contrary.contrary == lit + + # Contradictories are symmetric + assert cfn.is_contradictory(p, not_p) + assert cfn.is_contradictory(not_p, p) + assert cfn.is_contradictory(q, not_q) + assert cfn.is_contradictory(not_q, q) + + # No self-contrary + for lit in L: + assert not cfn.is_contrary(lit, lit) + assert not cfn.is_contradictory(lit, lit) + + # Non-empty and even + assert len(L) == 4 + assert len(L) % 2 == 0 + + # Cross-atom pairs are not contradictory + assert not cfn.is_contradictory(p, q) + assert not cfn.is_contradictory(p, not_q) + assert not cfn.is_contradictory(not_p, q) + assert not cfn.is_contradictory(not_p, not_q) + + def test_asymmetric_contrary_is_one_way(self): + """A contrary is directional, while contradictories remain symmetric.""" + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + cfn = ContrarinessFn( + contradictories=frozenset(), + contraries=frozenset({(p, q)}), + ) + + assert cfn.is_contrary(p, q) is True + assert cfn.is_contrary(q, p) is False + assert cfn.is_contradictory(p, q) is False + + def test_asymmetric_contrary_generates_one_way_attack(self): + """Only the attacking direction licensed by the contrary should appear.""" + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + system = ArgumentationSystem( + language=frozenset({p, q}), + contrariness=ContrarinessFn( + contradictories=frozenset(), + contraries=frozenset({(p, q)}), + ), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, q}), + ) + + arg_p = PremiseArg(premise=p, is_axiom=False) + arg_q = PremiseArg(premise=q, is_axiom=False) + + attacks = compute_attacks(build_arguments(system, kb), system) + attack_pairs = {(attack.attacker, attack.target) for attack in attacks} + + assert (arg_p, arg_q) in attack_pairs + assert (arg_q, arg_p) not in attack_pairs + + +# ── Phase 2: Rule strategies ───────────────────────────────────── + + +@st.composite +def strict_rules(draw, language, contrariness, max_rules=4): + """Generate strict rules over L with transposition closure. + + Modgil & Prakken 2018, Defs 2, 12 (pp.8, 13). + Prakken 2010, Defs 3.4, 5.1-5.2 (pp.47-48, 141-142). + + 1. Draw 0-max_rules seed rules with 1-2 antecedents from L, consequent from L. + 2. Filter: consequent not in antecedents. + 3. Compute transposition closure (calls transposition_closure which does not + exist yet -- tests will fail with ImportError in red phase). + 4. Return the closed rule set. + """ + L_list = sorted(language, key=repr) + n_rules = draw(st.integers(min_value=0, max_value=max_rules)) + seed_rules: list[Rule] = [] + for _ in range(n_rules): + n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) + antecedents = tuple( + draw(st.sampled_from(L_list)) for _ in range(n_ante) + ) + consequent = draw(st.sampled_from(L_list)) + # Filter: consequent must not appear in antecedents + if consequent in antecedents: + continue + seed_rules.append( + Rule( + antecedents=antecedents, + consequent=consequent, + kind="strict", + name=None, + ) + ) + closed, _post_language = transposition_closure( + frozenset(seed_rules), + language, + contrariness, + ) + return closed + + +@st.composite +def strict_seed_rules(draw, language, contrariness, max_rules=4): + """Generate well-formed strict seed rules before transposition closure.""" + L_list = sorted(language, key=repr) + n_rules = draw(st.integers(min_value=0, max_value=max_rules)) + seed_rules: list[Rule] = [] + for _ in range(n_rules): + n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) + antecedents = tuple( + draw(st.sampled_from(L_list)) for _ in range(n_ante) + ) + consequent = draw(st.sampled_from(L_list)) + if consequent in antecedents: + continue + if any( + contrariness.is_contradictory(left, right) + for index, left in enumerate(antecedents) + for right in antecedents[index + 1:] + ): + continue + seed_rules.append( + Rule( + antecedents=antecedents, + consequent=consequent, + kind="strict", + name=None, + ) + ) + return frozenset(seed_rules) + + +def _contradictories_in_language( + literal: Literal, + language: frozenset[Literal], + contrariness: ContrarinessFn, +) -> tuple[Literal, ...]: + return tuple( + sorted( + ( + other + for other in language + if other != literal and contrariness.is_contradictory(literal, other) + ), + key=repr, + ) + ) + + +def _schema_transpositions( + rule: Rule, + language: frozenset[Literal], + contrariness: ContrarinessFn, +) -> frozenset[Rule]: + transpositions: set[Rule] = set() + if rule.kind != "strict": + return frozenset() + for index, antecedent in enumerate(rule.antecedents): + for contrary_consequent in _contradictories_in_language( + rule.consequent, + language, + contrariness, + ): + for contrary_antecedent in _contradictories_in_language( + antecedent, + language, + contrariness, + ): + transposed_antecedents = list(rule.antecedents) + transposed_antecedents[index] = contrary_consequent + if contrary_antecedent in transposed_antecedents: + continue + candidate = Rule( + antecedents=tuple(transposed_antecedents), + consequent=contrary_antecedent, + kind="strict", + name=None, + ) + if any( + contrariness.is_contradictory(left, right) + for left_index, left in enumerate(candidate.antecedents) + for right in candidate.antecedents[left_index + 1:] + ): + continue + transpositions.add(candidate) + return frozenset(transpositions) + + +@st.composite +def defeasible_rules(draw, language, max_rules=4): + """Generate defeasible rules with naming function. + + Modgil & Prakken 2018, Def 2 (p.8): each defeasible rule r has a + name n(r) in L, enabling undercutting attacks on the inference step. + + Prakken 2010, Def 3.4 (p.47-48): defeasible rules use => (presumptive). + + Names are strings "d0", "d1", etc. — these are rule identifiers, not + Literals. The name-Literals are created during argument construction + in Phase 3. + """ + L_list = sorted(language, key=repr) + n_rules = draw(st.integers(min_value=0, max_value=max_rules)) + rules: list[Rule] = [] + for i in range(n_rules): + n_ante = draw(st.integers(min_value=1, max_value=min(2, len(L_list)))) + antecedents = tuple( + draw(st.sampled_from(L_list)) for _ in range(n_ante) + ) + consequent = draw(st.sampled_from(L_list)) + # Filter: consequent must not appear in antecedents + if consequent in antecedents: + continue + rules.append( + Rule( + antecedents=antecedents, + consequent=consequent, + kind="defeasible", + name=f"d{i}", + ) + ) + return frozenset(rules) + + +# ── Phase 2: Rule property tests ───────────────────────────────── + + +class TestRuleProperties: + """Property tests for strict and defeasible rules. + + Verifies structural invariants of rules generated by the strategies. + Modgil & Prakken 2018, Def 2 (p.8); Prakken 2010, Def 3.4 (p.47-48). + """ + + pytestmark = pytest.mark.property + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_strict_rules_all_strict(self, lc_rules): + """Every rule from strict_rules() has kind == 'strict'. + + Modgil & Prakken 2018, Def 2 (p.8): R_s contains only strict rules. + """ + (_L, _cfn), rules = lc_rules + for r in rules: + assert r.kind == "strict", f"Rule {r} has kind={r.kind}, expected 'strict'" + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + defeasible_rules(language=lc[0]), + ) + )) + @settings(deadline=None) + def test_defeasible_rules_all_defeasible(self, lc_rules): + """Every rule from defeasible_rules() has kind == 'defeasible'. + + Modgil & Prakken 2018, Def 2 (p.8): R_d contains only defeasible rules. + """ + (_L, _cfn), rules = lc_rules + for r in rules: + assert r.kind == "defeasible", ( + f"Rule {r} has kind={r.kind}, expected 'defeasible'" + ) + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + defeasible_rules(language=lc[0]), + ) + )) + @settings(deadline=None) + def test_defeasible_rules_all_named(self, lc_rules): + """Every defeasible rule has name is not None. + + Modgil & Prakken 2018, Def 2 (p.8): the naming function n maps + each defeasible rule to a name n(r), enabling undercutting attacks. + """ + (_L, _cfn), rules = lc_rules + for r in rules: + assert r.name is not None, f"Defeasible rule {r} has no name" + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + defeasible_rules(language=lc[0]), + ) + )) + @settings(deadline=None) + def test_rule_antecedents_in_language(self, lc_sr_dr): + """All antecedents and consequents are in L. + + Modgil & Prakken 2018, Def 2 (p.8): rules are over the language L. + Prakken 2010, Def 3.4 (p.47-48): antecedents and consequent in L. + """ + (L, _cfn), s_rules, d_rules = lc_sr_dr + for r in s_rules | d_rules: + for ante in r.antecedents: + assert ante in L, f"Antecedent {ante} of rule {r} not in L" + assert r.consequent in L, f"Consequent {r.consequent} of rule {r} not in L" + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + defeasible_rules(language=lc[0]), + ) + )) + @settings(deadline=None) + def test_consequent_not_in_antecedents(self, lc_sr_dr): + """No rule has its consequent appearing in its antecedents. + + A rule phi_1, ..., phi_n -> psi must have psi not in {phi_1, ..., phi_n}. + This is a well-formedness constraint: a rule cannot trivially conclude + one of its own premises. + """ + (L, _cfn), s_rules, d_rules = lc_sr_dr + for r in s_rules | d_rules: + assert r.consequent not in r.antecedents, ( + f"Rule {r} has consequent {r.consequent} in antecedents" + ) + + +class TestTranspositionClosure: + """Property tests for transposition closure of strict rules. + + Modgil & Prakken 2018, Def 12 (p.13): if A1,...,An -> C is a strict + rule, then for each i, A1,...,~C,...,An -> ~Ai must also be in R_s. + + Prakken 2010, Defs 5.1-5.2 (pp.141-142): transposition and closure. + + Theorem 6.10 (Prakken 2010): closure under transposition is REQUIRED + for the rationality postulates to hold. + """ + + pytestmark = pytest.mark.property + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_seed_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_transposition_closure_is_extensive_for_well_formed_seed_rules(self, lc_rules): + """Seed strict rules are retained by closure. + + Grounded in + `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`: + Prakken 2010 Defs. 5.1-5.2 define closure by adding transpositions. + """ + (language, cfn), seed_rules = lc_rules + + closed, _post_language = transposition_closure(seed_rules, language, cfn) + assert seed_rules <= closed + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_seed_rules(language=lc[0], contrariness=lc[1]), + strict_seed_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_transposition_closure_is_monotone_over_added_seed_rules(self, lc_rules): + """Adding strict rules cannot shrink the transposition closure. + + Grounded in + `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`. + """ + (language, cfn), seed_rules, added_rules = lc_rules + + closed_seed, _seed_language = transposition_closure(seed_rules, language, cfn) + closed_union, _union_language = transposition_closure( + seed_rules | added_rules, + language, + cfn, + ) + + assert closed_seed <= closed_union + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_seed_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_every_added_rule_matches_prakken_2010_page_12_transposition_schema(self, lc_rules): + """Every non-seed closure member is generated by the transposition schema. + + Grounded in + `papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png`: + replacing one antecedent with a contradictory of the consequent yields + a strict rule whose consequent is a contradictory of that antecedent. + """ + (language, cfn), seed_rules = lc_rules + closed, _post_language = transposition_closure(seed_rules, language, cfn) + schema_rules = frozenset( + transposed + for rule in closed + for transposed in _schema_transpositions(rule, language, cfn) + ) + + assert closed - seed_rules <= schema_rules + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_transposition_closure_complete(self, lc_rules): + """For every strict rule A1,...,An -> C, for every i, the transposed + rule A1,...,~C,...,An -> ~Ai exists in R_s. + + Modgil & Prakken 2018, Def 12 (p.13). + Prakken 2010, Def 5.1 (p.141-142): a transposition of + phi_1,...,phi_n -> psi is phi_1,...,-psi,...,phi_n -> -phi_i. + """ + (L, _cfn), rules = lc_rules + for r in rules: + for i, ante_i in enumerate(r.antecedents): + # Build the transposed rule: replace antecedent i with ~C, + # consequent becomes ~ante_i + transposed_antes = list(r.antecedents) + transposed_antes[i] = r.consequent.contrary + transposed = Rule( + antecedents=tuple(transposed_antes), + consequent=ante_i.contrary, + kind="strict", + name=None, + ) + assert transposed in rules, ( + f"Transposition of {r} at position {i} missing: {transposed}" + ) + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_transposition_closure_idempotent(self, lc_rules): + """Applying transposition_closure again produces no new rules. + + Prakken 2010, Def 5.2: Cl_{R_s}(R_s) is the smallest set closed + under transposition. Applying closure to an already-closed set + must be a fixed point. + """ + (L, cfn), rules = lc_rules + closed_again, _post_language = transposition_closure(rules, L, cfn) + assert closed_again == rules, ( + f"Closure not idempotent: {len(closed_again)} rules vs {len(rules)}" + ) + + @given(logical_language().flatmap( + lambda lc: st.tuples( + st.just(lc), + strict_rules(language=lc[0], contrariness=lc[1]), + ) + )) + @settings(deadline=None) + def test_transposition_closure_preserves_kind(self, lc_rules): + """All transposed rules have kind == 'strict'. + + Transposition applies only to strict rules and produces strict rules. + Modgil & Prakken 2018, Def 12 (p.13): transposition is defined + only for strict rules in R_s. + """ + (_L, _cfn), rules = lc_rules + for r in rules: + assert r.kind == "strict", ( + f"Transposed rule {r} has kind={r.kind}, expected 'strict'" + ) + + def test_empty_rules_closure_is_empty(self): + """Transposition closure of the empty set is the empty set. + + Trivial base case: no rules means no transpositions to generate. + """ + L = frozenset({Literal(GroundAtom("p")), Literal(GroundAtom("p"), negated=True)}) + cfn = ContrarinessFn( + contradictories=frozenset({(Literal(GroundAtom("p")), Literal(GroundAtom("p"), negated=True))}) + ) + result, _post_language = transposition_closure(frozenset(), L, cfn) + assert result == frozenset(), f"Expected empty set, got {result}" + + def test_transposition_closure_does_not_erase_unrelated_rules_on_singleton_inconsistency(self): + """Transposition closure must not wipe out unrelated strict rules. + + Prakken 2010, Defs 5.1-5.3 (pp. 141-142; local page image + ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png``) + defines closure under transposition purely as the least fixpoint under + adding transpositions. It does not say to delete the whole strict theory + when one singleton closure is inconsistent. + """ + + p = Literal(GroundAtom("p")) + not_p = Literal(GroundAtom("p"), negated=True) + q = Literal(GroundAtom("q")) + not_q = Literal(GroundAtom("q"), negated=True) + r = Literal(GroundAtom("r")) + not_r = Literal(GroundAtom("r"), negated=True) + s = Literal(GroundAtom("s")) + not_s = Literal(GroundAtom("s"), negated=True) + language = frozenset({p, not_p, q, not_q, r, not_r, s, not_s}) + cfn = ContrarinessFn( + contradictories=frozenset({ + (p, not_p), + (q, not_q), + (r, not_r), + (s, not_s), + }) + ) + unrelated = Rule(antecedents=(r,), consequent=s, kind="strict") + rules = frozenset( + { + Rule(antecedents=(not_p,), consequent=q, kind="strict"), + Rule(antecedents=(q,), consequent=p, kind="strict"), + unrelated, + } + ) + + closed, _post_language = transposition_closure(rules, language, cfn) + + assert unrelated in closed + assert closed != frozenset() + + def test_transposition_closure_uses_explicit_contradictories_from_contrariness(self): + """Transposition must follow the supplied contradictory relation. + + Prakken 2010, Def. 5.1 (p. 141; local page image + ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-012.png``) + defines transposition with the argumentation system's ``-`` operator. + If ``-`` is represented by ``ContrarinessFn``, the implementation must + use that relation instead of hard-coding ``Literal.contrary``. + """ + + p = Literal(GroundAtom("p")) + not_p = Literal(GroundAtom("p"), negated=True) + q = Literal(GroundAtom("q")) + not_q = Literal(GroundAtom("q"), negated=True) + r = Literal(GroundAtom("r")) + not_r = Literal(GroundAtom("r"), negated=True) + s = Literal(GroundAtom("s")) + not_s = Literal(GroundAtom("s"), negated=True) + language = frozenset({p, not_p, q, not_q, r, not_r, s, not_s}) + cfn = ContrarinessFn( + contradictories=frozenset({ + (p, q), + (not_p, not_q), + (r, s), + (not_r, not_s), + }) + ) + original = Rule(antecedents=(p,), consequent=r, kind="strict") + + closed, _post_language = transposition_closure(frozenset({original}), language, cfn) + + assert Rule(antecedents=(s,), consequent=q, kind="strict") in closed + + +class TestRuleConcrete: + """Hand-constructed examples for verifying rule and transposition properties.""" + + def test_married_bachelor_transposition(self): + """Classic example: married -> ~bachelor transposes to bachelor -> ~married. + + Modgil & Prakken 2014, Example 4.4. + Prakken 2010, Def 5.1 (p.141-142). + + Given strict rule: married -> ~bachelor + Transposition must produce: bachelor -> ~married + (Replace the single antecedent 'married' with ~(~bachelor) = bachelor, + and the consequent becomes ~married.) + """ + married = Literal(GroundAtom("married")) + not_married = Literal(GroundAtom("married"), negated=True) + bachelor = Literal(GroundAtom("bachelor")) + not_bachelor = Literal(GroundAtom("bachelor"), negated=True) + + L = frozenset({married, not_married, bachelor, not_bachelor}) + cfn = ContrarinessFn( + contradictories=frozenset({ + (married, not_married), + (bachelor, not_bachelor), + }) + ) + + # Original rule: married -> ~bachelor + original = Rule( + antecedents=(married,), + consequent=not_bachelor, + kind="strict", + name=None, + ) + + # Expected transposition: bachelor -> ~married + # (ante[0]=married replaced by ~(~bachelor)=bachelor, consequent=~married) + expected_transposition = Rule( + antecedents=(bachelor,), + consequent=not_married, + kind="strict", + name=None, + ) + + # Compute closure + closed, _post_language = transposition_closure(frozenset({original}), L, cfn) + + # Original must be preserved + assert original in closed, "Original rule missing from closure" + + # Transposition must be present + assert expected_transposition in closed, ( + f"Expected transposition {expected_transposition} not in closure: {closed}" + ) + + +# ── Phase 3: Knowledge base strategy ───────────────────────────── + + +@st.composite +def knowledge_base(draw, language, strict_rules, defeasible_rules): + """Generate a knowledge base with non-triviality guarantee. + + Modgil & Prakken 2018, Def 4 (p.9): K = K_n ∪ K_p where + K_n (axioms) are not attackable, K_p (ordinary premises) are attackable. + + Non-triviality: forces at least one rule's antecedents into K_p, + ensuring at least one non-premise argument can be constructed. + See reports/hypothesis-aspic-feasibility.md Section 3, Level 4. + """ + L_list = sorted(language, key=repr) + all_rules = list(strict_rules) + list(defeasible_rules) + + # Force at least one rule's antecedents into K_p for non-triviality + forced_premises: frozenset[Literal] = frozenset() + if all_rules: + target = draw(st.sampled_from(all_rules)) + forced_premises = frozenset(target.antecedents) + + # Draw additional K_p members from L (up to 4 total) + extra_kp = draw( + st.frozensets(st.sampled_from(L_list), max_size=4) + ) + K_p = forced_premises | extra_kp + + # Draw K_n members from L (up to 2), ensuring consistency + # (no literal and its contrary both in K_n) + K_n_candidates = draw( + st.frozensets(st.sampled_from(L_list), max_size=2) + ) + # Filter: no contradictory pair in K_n + K_n = frozenset() + for lit in K_n_candidates: + if lit.contrary not in K_n: + K_n = K_n | {lit} + + # Ensure K_n and K_p are disjoint + K_n = K_n - K_p + + return KnowledgeBase(axioms=K_n, premises=K_p) + + +@st.composite +def well_defined_knowledge_base(draw, language, strict_rules, defeasible_rules, contrariness): + """Generate a KB whose axioms and full base are c-consistent.""" + L_list = sorted(language, key=repr) + all_rules = list(strict_rules) + list(defeasible_rules) + + forced_premises: frozenset[Literal] = frozenset() + candidate_rules = [ + rule for rule in all_rules + if is_c_consistent(frozenset(rule.antecedents), strict_rules, contrariness) + ] + if candidate_rules: + target = draw(st.sampled_from(candidate_rules)) + forced_premises = frozenset(target.antecedents) + + extra_kp = draw( + st.frozensets(st.sampled_from(L_list), max_size=4) + ) + K_p = frozenset(forced_premises) + for lit in extra_kp: + tentative = K_p | {lit} + if is_c_consistent(tentative, strict_rules, contrariness): + K_p = tentative + + K_n_candidates = draw( + st.frozensets(st.sampled_from(L_list), max_size=2) + ) + K_n = frozenset() + for lit in K_n_candidates: + if lit in K_p: + continue + tentative = K_n | {lit} + if ( + is_c_consistent(tentative, strict_rules, contrariness) + and is_c_consistent(tentative | K_p, strict_rules, contrariness) + ): + K_n = tentative + + # Guard the full knowledge base, not just the incremental construction path. + # This keeps the rationality-postulate generators on genuinely well-defined + # c-SAF inputs even when Hypothesis shrinks toward edge cases. + assume(is_c_consistent(K_n | K_p, strict_rules, contrariness)) + return KnowledgeBase(axioms=K_n, premises=K_p) + + +# ── Phase 3: Argument construction property tests ───────────────── + + +class TestArgumentConstructionProperties: + """Property tests for recursive argument construction. + + Modgil & Prakken 2018, Def 5 (pp.9-10): arguments are constructed + recursively from premises and rules, with computed properties + Prem(A), Conc(A), Sub(A), DefRules(A), TopRule(A), LastDefRules(A). + + Prakken 2010, Def 3.6 (p.36): argument construction. + """ + + @given(data=st.data()) + @settings(deadline=None) + def test_premise_arg_conclusion_equals_premise(self, data): + """For every PremiseArg A, conc(A) == A.premise. + + Modgil & Prakken 2018, Def 5 clause 1 (p.9): if phi in K + then Conc(A) = phi. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + if isinstance(arg, PremiseArg): + assert conc(arg) == arg.premise, ( + f"PremiseArg conclusion {conc(arg)} != premise {arg.premise}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_sub_contains_self(self, data): + """For every argument A, A in sub(A). + + Modgil & Prakken 2018, Def 5 (p.9-10): Sub(A) always + includes A itself. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + assert arg in sub(arg), ( + f"Argument {arg} not in its own sub-arguments" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_sub_transitively_closed(self, data): + """For every A, for every B in sub(A), sub(B) ⊆ sub(A). + + Modgil & Prakken 2018, Def 5 (p.9-10): Sub is defined + recursively as Sub(A1) ∪ ... ∪ Sub(An) ∪ {A}, which + makes it transitively closed by construction. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + for b in sub(arg): + assert sub(b) <= sub(arg), ( + f"sub({b}) not subset of sub({arg})" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_prem_subset_of_kb(self, data): + """For every argument A, prem(A) ⊆ K_n ∪ K_p. + + Modgil & Prakken 2018, Def 5 (p.9): premises come from K. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + all_kb = kb.axioms | kb.premises + for arg in arguments: + assert prem(arg) <= all_kb, ( + f"prem({arg}) = {prem(arg)} not subset of K = {all_kb}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_every_built_argument_is_c_consistent(self, data): + """Public build_arguments() should emit only c-consistent arguments.""" + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + assert is_c_consistent(prem(arg), system.strict_rules, system.contrariness) + + @given(data=st.data()) + @settings(deadline=None) + def test_conc_in_language(self, data): + """For every argument A, conc(A) in L. + + Modgil & Prakken 2018, Def 5 (p.9-10): conclusions are + formulas in the logical language L. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + assert conc(arg) in L, ( + f"conc({arg}) = {conc(arg)} not in L" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_rule_args_antecedents_match(self, data): + """For every StrictArg/DefeasibleArg A, the conclusions of + A.sub_args match the antecedents of A.rule. + + Modgil & Prakken 2018, Def 5 clauses 2-3 (p.9-10): if + A1,...,An are arguments and their conclusions match a rule's + antecedents, then the compound is an argument. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + if isinstance(arg, (StrictArg, DefeasibleArg)): + sub_concs = tuple(conc(sa) for sa in arg.sub_args) + assert sub_concs == arg.rule.antecedents, ( + f"Sub-arg conclusions {sub_concs} != " + f"rule antecedents {arg.rule.antecedents}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_def_rules_empty_for_premise(self, data): + """For every PremiseArg A, def_rules(A) == frozenset(). + + Modgil & Prakken 2018, Def 5 clause 1 (p.9): + DefRules(A) = ∅ for premise arguments. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + if isinstance(arg, PremiseArg): + assert def_rules(arg) == frozenset(), ( + f"PremiseArg has non-empty def_rules: {def_rules(arg)}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_def_rules_includes_top_for_defeasible(self, data): + """For every DefeasibleArg A, A.rule in def_rules(A). + + Modgil & Prakken 2018, Def 5 clause 3 (p.9-10): + DefRules(A) = DefRules(A1) ∪ ... ∪ DefRules(An) ∪ {TopRule(A)}. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + if isinstance(arg, DefeasibleArg): + assert arg.rule in def_rules(arg), ( + f"DefeasibleArg top rule {arg.rule} not in " + f"def_rules: {def_rules(arg)}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_is_firm_iff_prem_subset_kn(self, data): + """is_firm(A) iff prem(A) ⊆ kb.axioms. + + Modgil & Prakken 2018, Def 5 / Prakken 2010 Def 3.8: + an argument is firm iff all its premises are axioms (K_n). + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + expected = prem(arg) <= kb.axioms + assert is_firm(arg) == expected, ( + f"is_firm({arg}) = {is_firm(arg)}, " + f"but prem(A) <= K_n is {expected}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_is_strict_iff_no_def_rules(self, data): + """is_strict(A) iff def_rules(A) == frozenset(). + + Modgil & Prakken 2018, Def 5 / Prakken 2010 Def 3.8: + an argument is strict iff it uses no defeasible rules. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + for arg in arguments: + expected = def_rules(arg) == frozenset() + assert is_strict(arg) == expected, ( + f"is_strict({arg}) = {is_strict(arg)}, " + f"but def_rules empty is {expected}" + ) + + @given(data=st.data()) + @settings(deadline=None, suppress_health_check=[HealthCheck.filter_too_much]) + def test_firm_strict_exist_when_kn_nonempty(self, data): + """When K_n is nonempty, at least one argument is both firm and strict. + + Modgil & Prakken 2018, Def 5 clause 1 (p.9): every phi in K + is a PremiseArg. If phi in K_n, the argument is firm (prem ⊆ K_n) + and strict (no defeasible rules). So K_n nonempty guarantees at + least one firm+strict argument. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + assume(len(kb.axioms) > 0) + # Prakken 2010, Thm. 6.10 (p. 145; local page image + # ``papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png``) + # requires consistent Cl_Rs(K_n) for the rationality guarantees. Once + # transposition_closure stops erasing inconsistent strict theories, this + # property must restrict itself to that well-defined fragment. + assume(is_c_consistent(kb.axioms, R_s, cfn)) + arguments = build_arguments(system, kb) + assert any( + is_firm(a) and is_strict(a) for a in arguments + ), "K_n nonempty but no firm+strict argument found" + + @given(data=st.data()) + @settings(deadline=None) + def test_nontriviality(self, data): + """When rules exist whose antecedents are in K, at least one + non-PremiseArg is constructed. + + This is the non-triviality guarantee from the knowledge_base + strategy: it forces at least one rule's antecedents into K_p, + ensuring build_arguments produces compound arguments when a + c-consistent rule antecedent set is available. + See reports/hypothesis-aspic-feasibility.md Section 3, Level 4. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + all_rules = R_s | R_d + assume(len(all_rules) > 0) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + assume(any( + frozenset(rule.antecedents) <= (kb.axioms | kb.premises) + and is_c_consistent( + frozenset(rule.antecedents), + system.strict_rules, + system.contrariness, + ) + for rule in all_rules + )) + non_premise = [ + a for a in arguments if not isinstance(a, PremiseArg) + ] + assert len(non_premise) > 0, ( + "Rules exist and antecedents are in K, " + "but no non-PremiseArg was constructed" + ) + + +class TestArgumentConstructionConcrete: + """Hand-constructed examples for argument construction.""" + + def test_simple_modus_ponens(self): + """K_p = {p, q}. Defeasible rule: p, q => r. + + Modgil & Prakken 2018, Def 5 (p.9-10): construct arguments + bottom-up from premises through rule application. + + Expected arguments: + - PremiseArg(p), PremiseArg(q) + - DefeasibleArg(sub_args=(PremiseArg(p), PremiseArg(q)), + rule=(p, q => r)) + The compound argument's conclusion is r. + """ + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + r = Literal(GroundAtom("r")) + not_p = p.contrary + not_q = q.contrary + not_r = r.contrary + + L = frozenset({p, q, r, not_p, not_q, not_r}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), (r, not_r), + })) + + rule_pq_r = Rule( + antecedents=(p, q), + consequent=r, + kind="defeasible", + name="d0", + ) + + system = ArgumentationSystem( + language=L, + contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_pq_r}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, q}), + ) + + arguments = build_arguments(system, kb) + + # Find premise arguments + prem_p = PremiseArg(premise=p, is_axiom=False) + prem_q = PremiseArg(premise=q, is_axiom=False) + assert prem_p in arguments, f"PremiseArg(p) not in arguments: {arguments}" + assert prem_q in arguments, f"PremiseArg(q) not in arguments: {arguments}" + + # Find the compound defeasible argument + compound = DefeasibleArg( + sub_args=(prem_p, prem_q), + rule=rule_pq_r, + ) + assert compound in arguments, ( + f"DefeasibleArg(p,q => r) not in arguments: {arguments}" + ) + + # Conclusion of the compound is r + assert conc(compound) == r, ( + f"Expected conc = r, got {conc(compound)}" + ) + + def test_c_inconsistent_argument_is_not_constructed(self): + """An argument with c-inconsistent premises must be excluded.""" + p = Literal(GroundAtom("p")) + not_p = p.contrary + q = Literal(GroundAtom("q")) + not_q = q.contrary + r = Literal(GroundAtom("r")) + not_r = r.contrary + + L = frozenset({p, not_p, q, not_q, r, not_r}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), (r, not_r), + })) + strict_rules, _post_language = transposition_closure( + frozenset({ + Rule((p,), q, "strict"), + Rule((not_q,), not_p, "strict"), + Rule((p, not_q), r, "strict"), + }), + L, + cfn, + ) + system = ArgumentationSystem( + language=L, + contrariness=cfn, + strict_rules=strict_rules, + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase( + axioms=frozenset({not_q}), + premises=frozenset({p}), + ) + + arguments = build_arguments(system, kb) + inconsistent_arg = StrictArg( + sub_args=( + PremiseArg(premise=p, is_axiom=False), + PremiseArg(premise=not_q, is_axiom=True), + ), + rule=Rule((p, not_q), r, "strict"), + ) + assert inconsistent_arg not in arguments + + +# ── Phase 4: Attack determination property tests ───────────────── + + +class TestAttackProperties: + """Property tests for three-type attack determination on sub-arguments. + + Modgil & Prakken 2018, Def 8 (p.11): undermining, rebutting, undercutting. + Pollock 1987, Defs 2.4-2.5 (p.485): rebutting vs undercutting defeaters. + + All tests use compute_attacks() which DOES NOT EXIST YET — tests + fail with ImportError. + """ + + @given(data=st.data()) + @settings(deadline=None) + def test_undermining_targets_ordinary_premises(self, data): + """Every undermining attack targets a PremiseArg with is_axiom == False. + + Modgil & Prakken 2018, Def 8a (p.11): A undermines B on B' iff + B' is a PremiseArg with ordinary premise phi (phi in K_p). + Axiom premises (K_n) cannot be undermined. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + if atk.kind == "undermining": + assert isinstance(atk.target_sub, PremiseArg), ( + f"Undermining target_sub is {type(atk.target_sub).__name__}, " + f"expected PremiseArg" + ) + assert not atk.target_sub.is_axiom, ( + f"Undermining attack targets axiom premise " + f"{atk.target_sub.premise} — axioms cannot be undermined" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_rebutting_targets_defeasible_conclusions(self, data): + """Every rebutting attack targets a sub-argument whose top_rule() is defeasible. + + Modgil & Prakken 2018, Def 8b (p.11): A rebuts B on B' iff + TopRule(B') is defeasible, and Conc(A) is in the contrariness of Conc(B'). + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + if atk.kind == "rebutting": + tr = top_rule(atk.target_sub) + assert tr is not None, ( + f"Rebutting target_sub has no top rule (PremiseArg)" + ) + assert tr.kind == "defeasible", ( + f"Rebutting target_sub top rule is {tr.kind}, " + f"expected 'defeasible'" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_undercutting_targets_defeasible_rules(self, data): + """Every undercutting attack targets a sub-argument whose top_rule() is defeasible. + + Modgil & Prakken 2018, Def 8c (p.11): A undercuts B on B' iff + TopRule(B') is a defeasible rule r, and Conc(A) is in the + contrariness of n(r). Strict rules cannot be undercut. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + if atk.kind == "undercutting": + tr = top_rule(atk.target_sub) + assert tr is not None, ( + f"Undercutting target_sub has no top rule (PremiseArg)" + ) + assert tr.kind == "defeasible", ( + f"Undercutting target_sub top rule is {tr.kind}, " + f"expected 'defeasible'" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_no_attack_on_firm_strict_subarg(self, data): + """No attack targets a sub-argument B' where is_firm(B') and is_strict(B'). + + Modgil & Prakken 2018, Def 18 (p.16): firm+strict sub-arguments + are unattackable — they use only axiom premises and strict rules. + Consequence of Def 8: undermining requires ordinary premises, + rebutting requires defeasible top rule, undercutting requires + defeasible top rule. Firm+strict satisfies none of these. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + assert not (is_firm(atk.target_sub) and is_strict(atk.target_sub)), ( + f"Attack {atk.kind} targets firm+strict sub-argument " + f"{atk.target_sub}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_attacker_and_target_in_arguments(self, data): + """Every attack's attacker and target are both in the argument set. + + compute_attacks operates over the argument set; it should not + introduce arguments from outside that set. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + assert atk.attacker in arguments, ( + f"Attacker {atk.attacker} not in argument set" + ) + assert atk.target in arguments, ( + f"Target {atk.target} not in argument set" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_target_sub_in_sub_of_target(self, data): + """Every attack's target_sub is in sub(attack.target). + + Modgil & Prakken 2018, Def 8 (p.11): all three attack types + require B' in Sub(B) — the attacked sub-argument must be a + sub-argument of the target argument. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + for atk in attacks: + assert atk.target_sub in sub(atk.target), ( + f"target_sub {atk.target_sub} not in sub({atk.target})" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_rebutting_symmetry_for_contradictories(self, data): + """Contradictory rebut is symmetric when both sides are defeasible. + + Modgil & Prakken 2018, Def 2 (p.8): contradictories are symmetric. + Def 8b (p.11): rebutting requires Conc(A) in contrariness of Conc(B'). + + The symmetry applies at the level of rebuttable defeasible conclusions. + If A rebuts B' and both A and B' have defeasible top rules with + contradictory conclusions, then B' must also rebut A. + + This does NOT hold when the attacker is only a premise or a strict + conclusion: Def 8b constrains the target structure, not the attacker. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + reverse_rebut_targets = { + (conc(atk2.attacker), id(atk2.target_sub)) + for atk2 in attacks + if atk2.kind == "rebutting" + } + + for atk in attacks: + if atk.kind != "rebutting": + continue + tr_a = top_rule(atk.attacker) + if tr_a is None or tr_a.kind != "defeasible": + continue + conc_a = conc(atk.attacker) + conc_b_prime = conc(atk.target_sub) + # Only check when they are contradictories (symmetric) + if not cfn.is_contradictory(conc_a, conc_b_prime): + continue + tr_b = top_rule(atk.target_sub) + if tr_b is None or tr_b.kind != "defeasible": + continue + # Index by target_sub identity to avoid quadratic deep-argument + # equality checks across large Hypothesis-generated attack sets. + reverse_exists = (conc_b_prime, id(atk.attacker)) in reverse_rebut_targets + assert reverse_exists, ( + f"Rebutting attack from defeasible {conc_a} on defeasible " + f"{conc_b_prime} is contradictory-symmetric, but no reverse " + f"rebutting attack onto the attacker was found" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_attack_kind_is_valid(self, data): + """Every attack has kind in {"undermining", "rebutting", "undercutting"}. + + Modgil & Prakken 2018, Def 8 (p.11): exactly three attack types. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + valid_kinds = {"undermining", "rebutting", "undercutting"} + for atk in attacks: + assert atk.kind in valid_kinds, ( + f"Attack kind '{atk.kind}' not in {valid_kinds}" + ) + + +class TestAttackConcrete: + """Hand-constructed examples for attack determination. + + Modgil & Prakken 2018, Def 8 (p.11). + Pollock 1987, Defs 2.4-2.5 (p.485). + """ + + def test_undermining_example(self): + """K_p = {p, ~p}. Defeasible rule: p => q. + The argument for ~p undermines the argument for q on sub-argument PremiseArg(p). + + Modgil & Prakken 2018, Def 8a (p.11): A undermines B on B' iff + B' in Sub(B), B' is a PremiseArg with ordinary premise phi (phi in K_p), + and Conc(A) is in the contrariness of phi. + + Here: Conc(A) = ~p, phi = p, ~p in bar(p) (contradictories). So A + undermines B on PremiseArg(p). + """ + p = Literal(GroundAtom("p")) + not_p = p.contrary + q = Literal(GroundAtom("q")) + not_q = q.contrary + + L = frozenset({p, not_p, q, not_q}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), + })) + + rule_p_q = Rule( + antecedents=(p,), consequent=q, + kind="defeasible", name="d0", + ) + + system = ArgumentationSystem( + language=L, contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_p_q}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, not_p}), + ) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + + prem_p_arg = PremiseArg(premise=p, is_axiom=False) + prem_not_p_arg = PremiseArg(premise=not_p, is_axiom=False) + compound_q = DefeasibleArg( + sub_args=(prem_p_arg,), rule=rule_p_q, + ) + + # The argument for ~p undermines the argument for q on PremiseArg(p) + expected = Attack( + attacker=prem_not_p_arg, + target=compound_q, + target_sub=prem_p_arg, + kind="undermining", + ) + assert expected in attacks, ( + f"Expected undermining attack {expected} not found in {attacks}" + ) + + def test_undercutting_example(self): + """K_p = {p, ~d0}. Defeasible rule d0: p => q (name="d0"). + The argument for ~d0 undercuts the argument for q. + + Modgil & Prakken 2018, Def 8c (p.11): A undercuts B on B' iff + B' in Sub(B), TopRule(B') is a defeasible rule r, and Conc(A) + is in the contrariness of n(r). + + Here: n(r) = d0 (as a Literal), Conc(A) = ~d0. ~d0 in bar(d0) + (contradictories). So A undercuts B on B'. + + Pollock 1987, Def 2.5 (p.485): undercutting defeats the connection + between premise and conclusion, not the conclusion itself. + """ + p = Literal(GroundAtom("p")) + not_p = p.contrary + q = Literal(GroundAtom("q")) + not_q = q.contrary + d0 = Literal(GroundAtom("d0")) + not_d0 = d0.contrary + + L = frozenset({p, not_p, q, not_q, d0, not_d0}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), (d0, not_d0), + })) + + rule_p_q = Rule( + antecedents=(p,), consequent=q, + kind="defeasible", name="d0", + ) + + system = ArgumentationSystem( + language=L, contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_p_q}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, not_d0}), + ) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + + prem_p_arg = PremiseArg(premise=p, is_axiom=False) + prem_not_d0_arg = PremiseArg(premise=not_d0, is_axiom=False) + compound_q = DefeasibleArg( + sub_args=(prem_p_arg,), rule=rule_p_q, + ) + + # The argument for ~d0 undercuts the argument for q + expected = Attack( + attacker=prem_not_d0_arg, + target=compound_q, + target_sub=compound_q, + kind="undercutting", + ) + assert expected in attacks, ( + f"Expected undercutting attack {expected} not found in {attacks}" + ) + + +# ── Phase 5: Preference ordering strategy ───────────────────────── + + +@st.composite +def preference_config(draw, defeasible_rules_set, premises): + """Generate a preference ordering configuration. + + Modgil & Prakken 2018, Defs 19-21 (p.21). + Def 22 (p.22): the inducing ordering must be a strict partial order + (irreflexive and transitive). + + Generates random partial orderings over rules and premises by: + 1. Drawing a random subset of pairs. + 2. Ensuring irreflexivity (no self-pairs). + 3. Computing transitive closure. + 4. Checking for cycles; if cyclic, falling back to empty ordering. + """ + # Build rule ordering: random subset of (weaker, stronger) pairs + rules_list = sorted(defeasible_rules_set, key=repr) + rule_pairs: set[tuple[Rule, Rule]] = set() + if len(rules_list) >= 2: + n_pairs = draw(st.integers(min_value=0, max_value=len(rules_list))) + for _ in range(n_pairs): + r1 = draw(st.sampled_from(rules_list)) + r2 = draw(st.sampled_from(rules_list)) + if r1 != r2: # irreflexivity + rule_pairs.add((r1, r2)) + # Transitive closure + rule_pairs = _transitive_closure_pairs(rule_pairs) + # Check for cycles (antisymmetry): if (a,b) and (b,a) both in set, discard all + if _has_cycle(rule_pairs): + rule_pairs = set() + + # Build premise ordering: random subset of (weaker, stronger) pairs + prem_list = sorted(premises, key=repr) + prem_pairs: set[tuple[Literal, Literal]] = set() + if len(prem_list) >= 2: + n_pairs = draw(st.integers(min_value=0, max_value=len(prem_list))) + for _ in range(n_pairs): + p1 = draw(st.sampled_from(prem_list)) + p2 = draw(st.sampled_from(prem_list)) + if p1 != p2: # irreflexivity + prem_pairs.add((p1, p2)) + # Transitive closure + prem_pairs = _transitive_closure_pairs(prem_pairs) + # Check for cycles + if _has_cycle(prem_pairs): + prem_pairs = set() + + comparison = draw(st.sampled_from(["elitist", "democratic"])) + link = draw(st.sampled_from(["last", "weakest"])) + + return PreferenceConfig( + rule_order=frozenset(rule_pairs), + premise_order=frozenset(prem_pairs), + comparison=comparison, + link=link, + ) + + +def _transitive_closure_pairs(pairs): + """Compute transitive closure of a set of (a, b) pairs.""" + closed = set(pairs) + changed = True + while changed: + changed = False + new = set() + for a, b in closed: + for c, d in closed: + if b == c and (a, d) not in closed and a != d: + new.add((a, d)) + if new: + closed.update(new) + changed = True + return closed + + +def _has_cycle(pairs): + """Check if a set of (a, b) pairs contains a cycle (a,b) and (b,a).""" + for a, b in pairs: + if (b, a) in pairs: + return True + return False + + +# ── Phase 5: Defeat property tests ──────────────────────────────── + + +class TestDefeatProperties: + """Property tests for defeat filtering via preference orderings. + + Modgil & Prakken 2018: + - Def 9 (p.12): when attacks succeed as defeats + - Def 19 (p.21): Elitist and Democratic set comparison + - Def 20 (p.21): Last-link principle + - Def 21 (p.21): Weakest-link principle + """ + + @given(data=st.data()) + @settings(deadline=None) + def test_undercutting_always_defeats(self, data): + """Every undercutting attack is a defeat regardless of preference ordering. + + Modgil & Prakken 2018, Def 9 (p.12): undercutting attacks are + preference-independent — they always succeed as defeats. + Pollock 1987, Def 2.5 (p.485): undercutting defeats the + connection between premise and conclusion. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + # Every undercutting attack must appear in defeats + undercutting_attacks = { + (atk.attacker, atk.target) + for atk in attacks if atk.kind == "undercutting" + } + defeat_pairs = {(d.attacker, d.target) for d in defeats} + for pair in undercutting_attacks: + assert pair in defeat_pairs, ( + f"Undercutting attack {pair} not in defeats — " + f"undercutting must always succeed (Def 9, p.12)" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_defeats_subset_of_attacks(self, data): + """Every defeat (a,b) corresponds to an attack from a on b. + + Modgil & Prakken 2018, Def 9 (p.12): defeats are a subset + of attacks — an attack must exist for a defeat to occur. + Defeats ⊆ Attacks. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + attack_pairs = { + (atk.attacker, atk.target, atk.target_sub) + for atk in attacks + } + for d in defeats: + assert (d.attacker, d.target, d.target_sub) in attack_pairs, ( + f"Defeat {d} has no corresponding attack" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_empty_ordering_still_respects_definition_19_edge_cases(self, data): + """Empty base orders do not erase Definition 19's empty-set lifting. + + Modgil & Prakken 2018, Def 19 (p.21) still makes a non-empty defeasible + set strictly less than an empty target set, even when the underlying + order relations themselves are empty. So defeats with empty preferences + are exactly the preference-independent attacks plus the attacks whose + attacker is not strictly weaker than the targeted sub-argument. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + empty_pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + defeats = compute_defeats(attacks, arguments, system, kb, empty_pref) + + expected_defeats = { + atk + for atk in attacks + if _is_preference_independent_attack(atk, system) + or not _strictly_weaker(atk.attacker, atk.target_sub, empty_pref, kb) + } + assert defeats == expected_defeats, ( + f"With empty preferences, defeats should still follow Def 9/19. " + f"Missing: {expected_defeats - defeats}; extra: {defeats - expected_defeats}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_defeat_is_directed(self, data): + """If (a,b) is a defeat, it means a defeats b — directionality is preserved. + + Modgil & Prakken 2018, Def 9 (p.12): defeat is a directed relation. + The attacker and target roles must be consistent with the attack. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + attack_pairs = { + (atk.attacker, atk.target, atk.target_sub) + for atk in attacks + } + for d in defeats: + # The defeat must correspond to an attack in the same direction. + assert (d.attacker, d.target, d.target_sub) in attack_pairs, ( + f"Defeat from {d.attacker} to {d.target} has no matching " + f"attack in the same direction" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_last_link_irreflexive(self, data): + """No argument is strictly weaker than itself under last-link. + + Modgil & Prakken 2018, Def 20 (p.21): the last-link principle + derives argument orderings from rule/premise orderings. Since the + inducing ordering is irreflexive (Def 22, p.22), no argument + can be strictly weaker than itself. + + Def 9 (p.12) compares the attacker against the targeted + sub-argument B', not automatically against itself. So even a + self-attack only succeeds when A is not strictly weaker than B'. + + Page-image grounding for last-link/lifting: + papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png + papers/Lehtonen_2024_PreferentialASPIC/pages/page_004.png + papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + # Force last-link + pref_last = PreferenceConfig( + rule_order=pref.rule_order, + premise_order=pref.premise_order, + comparison=pref.comparison, + link="last", + ) + defeats = compute_defeats(attacks, arguments, system, kb, pref_last) + + # Def 9 (p.12): self-attack does not bypass the A vs B' preference check. + for atk in attacks: + if atk.attacker == atk.target: + expected = _is_preference_independent_attack(atk, system) or ( + not _strictly_weaker(atk.attacker, atk.target_sub, pref_last, kb) + ) + actual = atk in defeats + assert actual == expected, ( + f"Self-attack {atk} should follow Def 9 against target_sub " + f"{atk.target_sub}: expected defeat={expected}, got {actual}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_weakest_link_irreflexive(self, data): + """No argument is strictly weaker than itself under weakest-link. + + Modgil & Prakken 2018, Def 21 (p.21): same irreflexivity + property as last-link, but under the weakest-link principle. + Def 22 (p.22): inducing ordering is irreflexive. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + # Force weakest-link + pref_weakest = PreferenceConfig( + rule_order=pref.rule_order, + premise_order=pref.premise_order, + comparison=pref.comparison, + link="weakest", + ) + defeats = compute_defeats(attacks, arguments, system, kb, pref_weakest) + + # Def 9 (p.12): self-attack does not bypass the A vs B' preference check. + for atk in attacks: + if atk.attacker == atk.target: + expected = _is_preference_independent_attack(atk, system) or ( + not _strictly_weaker( + atk.attacker, atk.target_sub, pref_weakest, kb + ) + ) + actual = atk in defeats + assert actual == expected, ( + f"Self-attack {atk} should follow Def 9 against target_sub " + f"{atk.target_sub}: expected defeat={expected}, got {actual}" + ) + + @given(data=st.data()) + @settings(deadline=None) + def test_firm_strict_never_defeated(self, data): + """If A is firm+strict, no argument B can defeat A. + + Modgil & Prakken 2018, Def 18 (p.16): firm+strict arguments + are never strictly weaker than any argument. Since they have + no ordinary premises and no defeasible rules, they cannot be + targets of undermining, rebutting, or undercutting attacks. + + Consequence of Def 18 conditions 1.i and 1.ii: strict+firm + arguments dominate all plausible/defeasible arguments, and + are never dominated. + """ + L, cfn = data.draw(logical_language()) + R_s = data.draw(strict_rules(L, cfn)) + R_d = data.draw(defeasible_rules(L)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = data.draw(knowledge_base(L, R_s, R_d)) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = data.draw(preference_config(R_d, kb.premises)) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + for d in defeats: + assert not (is_firm(d.target) and is_strict(d.target)), ( + f"Firm+strict argument {d.target} appears as defeat target — " + f"firm+strict arguments cannot be defeated (Def 18, p.16)" + ) + + +class TestSetComparisonProperties: + """Property tests for Def. 19 over finite induced orders. + + Prakken (2010), page 16, gives the elitist set order used by + last-link: + papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png + + Lehtonen (2024), pages 523-524, rephrases last-link defeat under + elitist and democratic lifting: + papers/Lehtonen_2024_PreferentialASPIC/pages/page_004.png + papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png + """ + + pytestmark = pytest.mark.property + + @given( + st.permutations((0, 1, 2, 3)), + st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), + st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), + ) + @settings(deadline=None) + def test_elitist_matches_definition_19(self, ranking, gamma, gamma_prime): + order_index = {item: idx for idx, item in enumerate(ranking)} + base_order = frozenset( + (x, y) + for x in ranking + for y in ranking + if order_index[x] < order_index[y] + ) + expected = any( + all((x, y) in base_order for y in gamma_prime) + for x in gamma + ) + actual = _set_strictly_less( + frozenset(gamma), + frozenset(gamma_prime), + base_order, + "elitist", + ) + assert actual == expected + + @given( + st.permutations((0, 1, 2, 3)), + st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), + st.sets(st.integers(min_value=0, max_value=3), min_size=1, max_size=4), + ) + @settings(deadline=None) + def test_democratic_matches_definition_19(self, ranking, gamma, gamma_prime): + order_index = {item: idx for idx, item in enumerate(ranking)} + base_order = frozenset( + (x, y) + for x in ranking + for y in ranking + if order_index[x] < order_index[y] + ) + expected = all( + any((x, y) in base_order for y in gamma_prime) + for x in gamma + ) + actual = _set_strictly_less( + frozenset(gamma), + frozenset(gamma_prime), + base_order, + "democratic", + ) + assert actual == expected + + +class TestDefeatConcrete: + """Hand-constructed examples for defeat with preferences. + + Modgil & Prakken 2018, Def 9 (p.12), Defs 19-21 (p.21). + """ + + def test_elitist_set_comparison_matches_definition_19(self): + """Elitist comparison quantifies over Gamma, not Gamma'.""" + assert _set_strictly_less( + frozenset({1, 5}), + frozenset({3, 4}), + frozenset({(1, 3), (1, 4)}), + "elitist", + ) + + def test_definition_19_treats_nonempty_set_as_below_empty_set(self): + """Definition 19 (p.21) explicitly gives Gamma <_s empty when Gamma != empty.""" + base_order = frozenset({("weak", "strong")}) + + assert _set_strictly_less( + frozenset({"weak"}), + frozenset(), + base_order, + "elitist", + ) + assert _set_strictly_less( + frozenset({"weak"}), + frozenset(), + base_order, + "democratic", + ) + + def test_self_undermining_defeat_still_checks_target_sub_preference(self): + """Self-undermining does not automatically become a defeat. + + Modgil & Prakken 2018, Def 9 (p.12), compares attacker A against the + targeted sub-argument B'. Under Defs 19-20 (p.21), a defeasible + argument can be strictly weaker than its own premise sub-argument when + its last defeasible rule set is non-empty and the target sub-argument's + last defeasible rule set is empty. + """ + p = Literal(GroundAtom("p")) + not_p = p.contrary + + L = frozenset({p, not_p}) + cfn = ContrarinessFn(contradictories=frozenset({(p, not_p)})) + + rule_self_attack = Rule( + antecedents=(p,), + consequent=not_p, + kind="defeasible", + name="d_self_attack", + ) + + system = ArgumentationSystem( + language=L, + contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_self_attack}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p}), + ) + + premise_arg = PremiseArg(premise=p, is_axiom=False) + attacker = DefeasibleArg(sub_args=(premise_arg,), rule=rule_self_attack) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + self_undermining = Attack( + attacker=attacker, + target=attacker, + target_sub=premise_arg, + kind="undermining", + ) + assert self_undermining in attacks + + pref = PreferenceConfig( + rule_order=frozenset({(rule_self_attack, p)}), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + assert _strictly_weaker(attacker, premise_arg, pref, kb) + + defeats = compute_defeats(attacks, arguments, system, kb, pref) + assert self_undermining not in defeats + + def test_stronger_rebutter_defeats(self): + """Two defeasible arguments for contradictory conclusions. + Give one a stronger rule ordering. The stronger defeats the weaker, + not vice versa. + + Modgil & Prakken 2018, Def 9 (p.12): rebutting succeeds as defeat + iff the attacker is NOT strictly weaker than the targeted sub-argument. + If B prec A (B is weaker), then B's rebutting attack on A fails, + but A's rebutting attack on B succeeds. + """ + p = Literal(GroundAtom("p")) + not_p = p.contrary + q = Literal(GroundAtom("q")) + not_q = q.contrary + + L = frozenset({p, not_p, q, not_q}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), + })) + + # Two defeasible rules: both from p, one to q, one to ~q + rule_strong = Rule( + antecedents=(p,), consequent=q, + kind="defeasible", name="d_strong", + ) + rule_weak = Rule( + antecedents=(p,), consequent=not_q, + kind="defeasible", name="d_weak", + ) + + system = ArgumentationSystem( + language=L, contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_strong, rule_weak}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p}), + ) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + + # Preference: rule_weak < rule_strong (weak is strictly weaker) + pref = PreferenceConfig( + rule_order=frozenset({(rule_weak, rule_strong)}), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + prem_p_arg = PremiseArg(premise=p, is_axiom=False) + arg_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_strong) + arg_not_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_weak) + + # Strong defeats weak (A's attack on B succeeds: A not < B) + defeat_pairs = {(d.attacker, d.target) for d in defeats} + assert (arg_q, arg_not_q) in defeat_pairs, ( + f"Stronger argument should defeat weaker. " + f"Defeats: {defeat_pairs}" + ) + # Weak does NOT defeat strong (B's attack on A fails: B < A) + assert (arg_not_q, arg_q) not in defeat_pairs, ( + f"Weaker argument should not defeat stronger. " + f"Defeats: {defeat_pairs}" + ) + + def test_equal_strength_mutual_defeat(self): + """Two equally-ranked defeasible arguments with contradictory conclusions. + Both attacks succeed as defeats (neither is strictly weaker). + + Modgil & Prakken 2018, Def 9 (p.12): rebutting succeeds iff + A is NOT strictly weaker. If neither is weaker (equal or + incomparable), both attacks succeed as mutual defeats. + """ + p = Literal(GroundAtom("p")) + not_p = p.contrary + q = Literal(GroundAtom("q")) + not_q = q.contrary + + L = frozenset({p, not_p, q, not_q}) + cfn = ContrarinessFn(contradictories=frozenset({ + (p, not_p), (q, not_q), + })) + + rule_a = Rule( + antecedents=(p,), consequent=q, + kind="defeasible", name="d_a", + ) + rule_b = Rule( + antecedents=(p,), consequent=not_q, + kind="defeasible", name="d_b", + ) + + system = ArgumentationSystem( + language=L, contrariness=cfn, + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_a, rule_b}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p}), + ) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + + # Empty preference: neither rule is weaker + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + prem_p_arg = PremiseArg(premise=p, is_axiom=False) + arg_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_a) + arg_not_q = DefeasibleArg(sub_args=(prem_p_arg,), rule=rule_b) + + defeat_pairs = {(d.attacker, d.target) for d in defeats} + # Both directions should be defeats (mutual defeat) + assert (arg_q, arg_not_q) in defeat_pairs, ( + f"Equal-strength: arg_q should defeat arg_not_q. " + f"Defeats: {defeat_pairs}" + ) + assert (arg_not_q, arg_q) in defeat_pairs, ( + f"Equal-strength: arg_not_q should defeat arg_q. " + f"Defeats: {defeat_pairs}" + ) + + def test_elitist_last_link_blocks_rebut_when_attacker_is_strictly_weaker(self): + """A rebut must fail when one attacker rule is below every target rule. + + Last-link and elitist-lifting grounding: + papers/Prakken_2010_AbstractFrameworkArgumentationStructured/pngs/page-015.png + papers/Lehtonen_2024_PreferentialASPIC/pages/page_005.png + """ + a = Literal(GroundAtom("a")) + b = Literal(GroundAtom("b")) + c = Literal(GroundAtom("c")) + x = Literal(GroundAtom("x")) + y = Literal(GroundAtom("y")) + q = Literal(GroundAtom("q")) + not_q = q.contrary + + d1 = Rule((a,), x, "defeasible", "d1") + d2 = Rule((b,), y, "defeasible", "d2") + t1 = Rule((c,), q, "defeasible", "t1") + s1 = Rule((x, y), not_q, "strict") + + system = ArgumentationSystem( + language=frozenset({a, b, c, x, y, q, not_q}), + contrariness=ContrarinessFn(frozenset({(q, not_q)})), + strict_rules=frozenset({s1}), + defeasible_rules=frozenset({d1, d2, t1}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({a, b, c}), + ) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + pref = PreferenceConfig( + rule_order=frozenset({(d1, t1)}), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + defeats = compute_defeats(attacks, arguments, system, kb, pref) + assert defeats == frozenset() + + def test_contrary_undermining_ignores_preference_ordering(self): + """Contrary undermining is preference-independent and always defeats.""" + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + system = ArgumentationSystem( + language=frozenset({p, q}), + contrariness=ContrarinessFn( + contradictories=frozenset(), + contraries=frozenset({(p, q)}), + ), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, q}), + ) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset({(p, q)}), + comparison="elitist", + link="last", + ) + + arg_p = PremiseArg(premise=p, is_axiom=False) + arg_q = PremiseArg(premise=q, is_axiom=False) + + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + defeat_pairs = {(defeat.attacker, defeat.target) for defeat in defeats} + + assert (arg_p, arg_q) in defeat_pairs + assert (arg_q, arg_p) not in defeat_pairs + + def test_last_and_weakest_link_can_diverge(self): + """A weak earlier rule can matter under weakest-link but not last-link.""" + a = Literal(GroundAtom("a")) + b = Literal(GroundAtom("b")) + x = Literal(GroundAtom("x")) + q = Literal(GroundAtom("q")) + not_q = q.contrary + + d_weak = Rule((a,), x, "defeasible", "d_weak") + d_strong = Rule((x,), not_q, "defeasible", "d_strong") + t_mid = Rule((b,), q, "defeasible", "t_mid") + + system = ArgumentationSystem( + language=frozenset({a, b, x, q, not_q}), + contrariness=ContrarinessFn( + contradictories=frozenset({(q, not_q)}), + ), + strict_rules=frozenset(), + defeasible_rules=frozenset({d_weak, d_strong, t_mid}), + ) + kb = KnowledgeBase( + axioms=frozenset({a, b}), + premises=frozenset(), + ) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + + pref_last = PreferenceConfig( + rule_order=frozenset({ + (d_weak, t_mid), + (t_mid, d_strong), + (d_weak, d_strong), + }), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + pref_weakest = PreferenceConfig( + rule_order=pref_last.rule_order, + premise_order=frozenset(), + comparison="elitist", + link="weakest", + ) + + arg_a = PremiseArg(premise=a, is_axiom=True) + arg_b = PremiseArg(premise=b, is_axiom=True) + arg_not_q = DefeasibleArg( + sub_args=(DefeasibleArg(sub_args=(arg_a,), rule=d_weak),), + rule=d_strong, + ) + arg_q = DefeasibleArg(sub_args=(arg_b,), rule=t_mid) + + last_pairs = { + (defeat.attacker, defeat.target) + for defeat in compute_defeats(attacks, arguments, system, kb, pref_last) + } + weakest_pairs = { + (defeat.attacker, defeat.target) + for defeat in compute_defeats(attacks, arguments, system, kb, pref_weakest) + } + + assert (arg_not_q, arg_q) in last_pairs + assert (arg_q, arg_not_q) not in last_pairs + assert (arg_not_q, arg_q) not in weakest_pairs + assert (arg_q, arg_not_q) in weakest_pairs + + +# ── Phase 6: Rationality postulate strategies and tests ────────── + + +ASPIC_RATIONALITY_MAX_ARGUMENTS = 10 +ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES = 1 << ASPIC_RATIONALITY_MAX_ARGUMENTS + + +@st.composite +def well_formed_csaf(draw, max_atoms=4, max_strict=3, max_defeasible=4): + """Generate a well-formed c-SAF per Modgil & Prakken 2018, Def 12. + + Chains: logical_language -> strict_rules -> defeasible_rules -> well_defined_knowledge_base + Then runs: build_arguments -> compute_attacks -> compute_defeats + Finally emits: dung.ArgumentationFramework for extension computation. + + Guarantees: + - Axiom consistency (Cl_Rs(K_n) is c-consistent) + - Well-formed contrariness (contradictories symmetric) + - Transposition closure (R_s = Cl(R_s)) + - At least one non-premise argument (non-triviality) + + Modgil & Prakken 2018, Def 12 (p.13): a c-SAF is well-defined iff + it is axiom consistent, well-formed, and closed under transposition. + """ + L, cfn = draw(logical_language(max_atoms=max_atoms)) + R_s = draw(strict_rules(L, cfn, max_rules=max_strict)) + R_d = draw(defeasible_rules(L, max_rules=max_defeasible)) + system = ArgumentationSystem(L, cfn, R_s, R_d) + kb = draw(well_defined_knowledge_base(L, R_s, R_d, cfn)) + pref = draw(preference_config(R_d, kb.premises)) + + # Computed (not drawn) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + defeat_attacks = compute_defeats(attacks, arguments, system, kb, pref) + + # Extract defeat pairs (Argument, Argument) from Attack objects + defeats = frozenset( + (atk.attacker, atk.target) for atk in defeat_attacks + ) + + # Build Dung AF + # Assign string IDs to arguments for the Dung layer + arg_list = sorted(arguments, key=lambda a: repr(a)) # deterministic ordering + arg_to_id = {a: f"arg_{i}" for i, a in enumerate(arg_list)} + id_to_arg = {v: k for k, v in arg_to_id.items()} + + af = ArgumentationFramework( + arguments=frozenset(arg_to_id.values()), + defeats=frozenset( + (arg_to_id[a], arg_to_id[b]) + for a, b in defeats + if a in arg_to_id and b in arg_to_id + ), + attacks=frozenset( + (arg_to_id[atk.attacker], arg_to_id[atk.target]) + for atk in attacks + if atk.attacker in arg_to_id and atk.target in arg_to_id + ), + ) + + return CSAF( + system=system, kb=kb, pref=pref, arguments=arguments, + attacks=attacks, defeats=defeats, framework=af, + arg_to_id=arg_to_id, id_to_arg=id_to_arg, + ) + + +@st.composite +def exact_complete_extension_csaf(draw): + """Generate c-SAFs small enough for exhaustive complete-extension checks.""" + csaf = draw(well_formed_csaf(max_atoms=3, max_strict=2, max_defeasible=2)) + assume(len(csaf.framework.arguments) <= ASPIC_RATIONALITY_MAX_ARGUMENTS) + return csaf + + +# ── Phase 6: The 8 Rationality Postulate Tests ────────────────── + + +class TestRationalityPostulates: + """Property tests for the 8 rationality postulates of ASPIC+. + + These are the crown jewel: if all 8 hold on 200 random well-formed + c-SAFs, the implementation is correct per Modgil & Prakken 2018. + + Complete-extension postulates use bounded generated c-SAFs because exact + Dung complete-extension enumeration is exponential in the projected AF size. + """ + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_sub_argument_closure(self, csaf): + """Postulate 1 — Sub-argument closure (Thm 12, p.18). + + If A is in a complete extension E and A' is in Sub(A), + then A' is also in E. Extensions are closed under sub-arguments. + + Modgil & Prakken 2018, Theorem 12 (p.18): for every attack- + conflict-free complete extension E of a well-defined c-SAF + with reasonable ordering, Sub(E) = E. + """ + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + for aid in ext_ids: + arg = csaf.id_to_arg[aid] + for sub_arg in sub(arg): + assert csaf.arg_to_id[sub_arg] in ext_ids, ( + f"Sub-argument {sub_arg} of {arg} not in extension" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_strict_closure(self, csaf): + """Postulate 2 — Closure under strict rules (Thm 13, p.18). + + Cl_Rs(Conc(E)) = Conc(E): the set of conclusions of arguments + in a complete extension is already closed under strict rules. + + Modgil & Prakken 2018, Theorem 13 (p.18): for every attack- + conflict-free complete extension E of a well-defined c-SAF + with reasonable ordering, Cl_Rs(Conc(E)) = Conc(E). + """ + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + conclusions = frozenset( + conc(csaf.id_to_arg[aid]) for aid in ext_ids + ) + closed = strict_closure(conclusions, csaf.system.strict_rules) + assert closed == conclusions, ( + f"Strict closure added: {closed - conclusions}" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_direct_consistency(self, csaf): + """Postulate 3 — Direct consistency (Thm 14, p.18). + + No two conclusions in a complete extension are contraries or + contradictories. The extension conclusions are directly consistent. + + Modgil & Prakken 2018, Theorem 14 (p.18): for every attack- + conflict-free complete extension E of a well-defined c-SAF + with reasonable ordering, Conc(E) is consistent. + """ + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + conclusions = [conc(csaf.id_to_arg[aid]) for aid in ext_ids] + for i, c1 in enumerate(conclusions): + for c2 in conclusions[i + 1:]: + assert not csaf.system.contrariness.is_contradictory(c1, c2), ( + f"Direct inconsistency: {c1} and {c2} are contradictories" + ) + assert not csaf.system.contrariness.is_contrary(c1, c2), ( + f"Direct inconsistency: {c1} is a contrary of {c2}" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_indirect_consistency(self, csaf): + """Postulate 4 — Indirect consistency (Thm 15, p.19). + + Cl_Rs(Conc(E)) is consistent: the strict closure of conclusions + contains no contradictory pair. + + Modgil & Prakken 2018, Theorem 15 (p.19): for every attack- + conflict-free complete extension E of a well-defined c-SAF + with reasonable ordering, Cl_Rs(Conc(E)) is consistent. + """ + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + conclusions = frozenset( + conc(csaf.id_to_arg[aid]) for aid in ext_ids + ) + closed = strict_closure(conclusions, csaf.system.strict_rules) + closed_list = list(closed) + for i, c1 in enumerate(closed_list): + for c2 in closed_list[i + 1:]: + assert not csaf.system.contrariness.is_contradictory(c1, c2), ( + f"Indirect inconsistency: {c1} and {c2} are " + f"contradictories in Cl_Rs(Conc(E))" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_firm_strict_in_every_complete(self, csaf): + """Postulate 5 — Firm+strict in every complete extension (Def 18). + + Every argument that is both firm (all premises are axioms) and + strict (no defeasible rules) must be in every complete extension. + + Modgil & Prakken 2018, Def 18 (p.16): reasonable orderings + require that firm+strict arguments are never strictly weaker + than any argument. Combined with the fundamental lemma (Props + 9-11, p.17), this means they are in every complete extension. + """ + firm_strict_ids = { + csaf.arg_to_id[a] for a in csaf.arguments + if is_firm(a) and is_strict(a) + } + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + assert firm_strict_ids <= ext_ids, ( + f"Firm+strict args {firm_strict_ids - ext_ids} " + f"not in complete extension" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_undercutting_always_defeats(self, csaf): + """Postulate 6 — Undercutting always defeats (Def 9). + + Every undercutting attack succeeds as a defeat regardless of + the preference ordering. Undercutting is preference-independent. + + Modgil & Prakken 2018, Def 9 (p.12): undercutting attacks + always succeed as defeats. + Pollock 1987, Def 2.5 (p.485): undercutting defeaters. + """ + for atk in csaf.attacks: + if atk.kind == "undercutting": + pair = (csaf.arg_to_id[atk.attacker], csaf.arg_to_id[atk.target]) + assert pair in csaf.framework.defeats, ( + f"Undercutting attack {atk} not in framework defeats" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_attack_based_conflict_free(self, csaf): + """Postulate 7 — Attack-based conflict-free (Def 14). + + Every complete extension is conflict-free with respect to the + attack relation (not just the defeat relation). + + Modgil & Prakken 2018, Def 14 (p.14): a set S is attack-based + conflict-free iff no argument in S attacks another argument in S. + This is strictly stronger than defeat-based conflict-free. + """ + for ext_ids in complete_extensions( + csaf.framework, + max_candidates=ASPIC_RATIONALITY_MAX_COMPLETE_CANDIDATES, + ): + assert conflict_free(ext_ids, csaf.framework.attacks), ( + f"Complete extension {ext_ids} is not attack-based " + f"conflict-free" + ) + + @given(exact_complete_extension_csaf()) + @settings(deadline=None) + def test_transposition_closure_maintained(self, csaf): + """Postulate 8 — Transposition closure (Def 12). + + The strict rules in the argumentation system are already closed + under transposition. Applying transposition_closure again + produces the same set. + + Modgil & Prakken 2018, Def 12 (p.13): well-definedness requires + closure under transposition. + Prakken 2010, Theorem 6.10: transposition closure is REQUIRED + for the rationality postulates to hold. + """ + closed, _post_language = transposition_closure( + csaf.system.strict_rules, + csaf.system.language, + csaf.system.contrariness, + ) + assert closed == csaf.system.strict_rules, ( + f"Strict rules not closed under transposition: " + f"{len(closed)} rules after closure vs {len(csaf.system.strict_rules)}" + ) + + +# ── Phase 6: Concrete regression test ─────────────────────────── + + +class TestRationalityPostulatesConcrete: + """Hand-crafted regression tests for rationality postulates. + + These complement the property tests with known examples from the + literature, providing deterministic coverage of specific scenarios. + """ + + def test_married_bachelor_consistency(self): + """The married/bachelor example from Modgil 2014, Example 4.4. + + L = {married, ~married, bachelor, ~bachelor} + Strict rule: married -> ~bachelor (with transposition: bachelor -> ~married) + K_p = {married, bachelor} + + Build CSAF. Compute grounded extension. + Assert: the extension does NOT contain both bachelor and ~bachelor + (direct consistency holds). + + This is a classic example of how transposition closure ensures + consistency: the strict rule generates a counter-argument that + prevents contradictory conclusions from coexisting in any extension. + """ + married = Literal(GroundAtom("married")) + not_married = Literal(GroundAtom("married"), negated=True) + bachelor = Literal(GroundAtom("bachelor")) + not_bachelor = Literal(GroundAtom("bachelor"), negated=True) + + L = frozenset({married, not_married, bachelor, not_bachelor}) + cfn = ContrarinessFn(contradictories=frozenset({ + (married, not_married), + (bachelor, not_bachelor), + })) + + # Strict rule: married -> ~bachelor + rule_m_nb = Rule( + antecedents=(married,), + consequent=not_bachelor, + kind="strict", + name=None, + ) + + # Compute transposition closure: adds bachelor -> ~married + R_s, _post_language = transposition_closure(frozenset({rule_m_nb}), L, cfn) + + system = ArgumentationSystem( + language=L, contrariness=cfn, + strict_rules=R_s, + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({married, bachelor}), + ) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + # Build CSAF manually + arguments = build_arguments(system, kb) + attacks_set = compute_attacks(arguments, system) + defeat_attacks = compute_defeats(attacks_set, arguments, system, kb, pref) + defeats = frozenset( + (atk.attacker, atk.target) for atk in defeat_attacks + ) + + arg_list = sorted(arguments, key=lambda a: repr(a)) + arg_to_id = {a: f"arg_{i}" for i, a in enumerate(arg_list)} + id_to_arg = {v: k for k, v in arg_to_id.items()} + + af = ArgumentationFramework( + arguments=frozenset(arg_to_id.values()), + defeats=frozenset( + (arg_to_id[a], arg_to_id[b]) + for a, b in defeats + if a in arg_to_id and b in arg_to_id + ), + attacks=frozenset( + (arg_to_id[atk.attacker], arg_to_id[atk.target]) + for atk in attacks_set + if atk.attacker in arg_to_id and atk.target in arg_to_id + ), + ) + + csaf = CSAF( + system=system, kb=kb, pref=pref, arguments=arguments, + attacks=attacks_set, defeats=defeats, framework=af, + arg_to_id=arg_to_id, id_to_arg=id_to_arg, + ) + + # Compute grounded extension + ext = grounded_extension(csaf.framework) + + # Direct consistency: extension should NOT contain both + # bachelor and ~bachelor conclusions + ext_conclusions = {conc(csaf.id_to_arg[aid]) for aid in ext} + assert not (bachelor in ext_conclusions and not_bachelor in ext_conclusions), ( + f"Grounded extension contains both bachelor and ~bachelor — " + f"direct consistency violated. Conclusions: {ext_conclusions}" + ) diff --git a/tests/test_aspic_asp_differential.py b/tests/structured/aspic/test_aspic_asp_differential.py similarity index 97% rename from tests/test_aspic_asp_differential.py rename to tests/structured/aspic/test_aspic_asp_differential.py index 767b8e9..9ea5d4e 100644 --- a/tests/test_aspic_asp_differential.py +++ b/tests/structured/aspic/test_aspic_asp_differential.py @@ -4,7 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, ContrarinessFn, GroundAtom, @@ -13,7 +13,7 @@ PreferenceConfig, Rule, ) -from argumentation.aspic_encoding import solve_aspic_with_backend +from argumentation.structured.aspic.aspic_encoding import solve_aspic_with_backend def mutually_attacking_premises(): diff --git a/tests/test_aspic_encodings.py b/tests/structured/aspic/test_aspic_encodings.py similarity index 94% rename from tests/test_aspic_encodings.py rename to tests/structured/aspic/test_aspic_encodings.py index 40f05a8..e0b1261 100644 --- a/tests/test_aspic_encodings.py +++ b/tests/structured/aspic/test_aspic_encodings.py @@ -1,474 +1,466 @@ -from __future__ import annotations - -import importlib -import re -from types import SimpleNamespace - -import argumentation -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from argumentation.aspic import ( - ArgumentationSystem, - ContrarinessFn, - GroundAtom, - KnowledgeBase, - Literal, - PreferenceConfig, - Rule, - build_abstract_framework, - conc, -) -from argumentation.aspic_encoding import encode_aspic_theory -from argumentation.aspic_encoding import solve_aspic_grounded -from argumentation.aspic_encoding import solve_aspic_with_backend -from argumentation.dung import grounded_extension - - -ASP_CONSTANT_RE = re.compile(r"^[a-z][A-Za-z0-9_]*$") - - -def test_aspic_encoding_module_is_exported_from_package() -> None: - package = importlib.reload(argumentation) - - assert "aspic_encoding" in package.__all__ - - -def test_aspic_encoding_assigns_deterministic_facts_and_signature() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - not_q = q.contrary - strict = Rule((p,), q, "strict") - defeasible = Rule((q,), not_q, "defeasible", "d_not_q") - system = ArgumentationSystem( - language=frozenset({not_q, q, p}), - contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), - strict_rules=frozenset({strict}), - defeasible_rules=frozenset({defeasible}), - ) - kb = KnowledgeBase(axioms=frozenset({p}), premises=frozenset({q})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - first = encode_aspic_theory(system, kb, pref) - second = encode_aspic_theory(system, kb, pref) - - assert first.facts == second.facts - assert first.signature == second.signature - assert first.facts == tuple(sorted(first.facts)) - assert "axiom(p)." in first.facts - assert "premise(q)." in first.facts - assert "s_head(s_0,q)." in first.facts - assert "s_body(s_0,p)." in first.facts - assert "d_head(d_not_q,n_q)." in first.facts - assert "d_body(d_not_q,q)." in first.facts - assert "contrary(q,n_q)." in first.facts - - -def test_aspic_encoding_signature_is_stable_under_input_set_ordering() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - r = Literal(GroundAtom("r")) - d1 = Rule((p,), q, "defeasible", "d1") - d2 = Rule((q,), r, "defeasible", "d2") - first_system = ArgumentationSystem( - language=frozenset({p, q, r}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset({d1, d2}), - ) - second_system = ArgumentationSystem( - language=frozenset({r, q, p}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset({d2, d1}), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({q, p})) - pref = PreferenceConfig( - rule_order=frozenset({(d1, d2)}), - premise_order=frozenset(), - comparison="democratic", - link="last", - ) - - first = encode_aspic_theory(first_system, kb, pref) - second = encode_aspic_theory(second_system, kb, pref) - - assert first.signature == second.signature - assert first.facts == second.facts - assert "preferred(d2,d1)." in first.facts - assert first.metadata["comparison"] == "democratic" - assert first.metadata["link"] == "last" - - -def test_solve_aspic_grounded_returns_accepted_conclusions() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - rule = Rule((p,), q, "defeasible", "d_q") - system = ArgumentationSystem( - language=frozenset({p, q}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset({rule}), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - result = solve_aspic_grounded(system, kb, pref) - - assert result.status == "success" - assert result.semantics == "grounded" - assert result.accepted_conclusions == frozenset({p, q}) - assert result.backend == "materialized_reference" - - -def test_solve_aspic_grounded_matches_materialized_pipeline() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - not_q = q.contrary - rule_q = Rule((p,), q, "defeasible", "d_q") - system = ArgumentationSystem( - language=frozenset({p, q, not_q}), - contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_q}), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p, not_q})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - projection = build_abstract_framework(system, kb, pref) - grounded_ids = grounded_extension(projection.framework) - expected = frozenset(conc(projection.id_to_argument[arg_id]) for arg_id in grounded_ids) - - result = solve_aspic_grounded(system, kb, pref) - - assert result.accepted_conclusions == expected - assert result.accepted_argument_ids == grounded_ids - - -def test_optional_aspic_backend_absence_is_typed() -> None: - p = Literal(GroundAtom("p")) - system = ArgumentationSystem( - language=frozenset({p}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - result = solve_aspic_with_backend(system, kb, pref, backend="missing-test-backend") - - assert result.status == "unavailable_backend" - assert result.backend == "missing-test-backend" - assert result.accepted_argument_ids == frozenset() - assert result.metadata["reason"] == "backend is not installed or registered" - - -def test_clingo_backend_invokes_solver_and_parses_grounded_answer_set(monkeypatch) -> None: - system, kb, pref = simple_aspic_theory() - expected = solve_aspic_with_backend( - system, - kb, - pref, - backend="asp", - semantics="grounded", - ) - accepted_ids = " ".join(f"accepted_arg({arg_id})" for arg_id in expected.extensions[0]) - accepted_lits = " ".join( - f"accepted_lit({literal_id})" - for literal_id, literal in expected.encoding.literal_by_id.items() - if literal in expected.accepted_conclusions - ) - calls: list[list[str]] = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.shutil.which", - lambda binary: binary, - ) - - def fake_run(command, *, capture_output, text, timeout, check): - calls.append(command) - assert command[0] == "fake-clingo" - assert capture_output is True - assert text is True - assert timeout == 5.0 - assert check is False - return SimpleNamespace( - returncode=0, - stdout=f"Answer: 1\n{accepted_ids} {accepted_lits}\nSATISFIABLE\n", - stderr="", - ) - - monkeypatch.setattr("argumentation.solver_adapters.clingo.subprocess.run", fake_run) - - result = solve_aspic_with_backend( - system, - kb, - pref, - backend="clingo", - semantics="grounded", - binary="fake-clingo", - timeout_seconds=5.0, - ) - - assert result.status == "success" - assert result.backend == "clingo" - assert result.accepted_conclusions == frozenset({Literal(GroundAtom("p")), Literal(GroundAtom("q"))}) - assert result.accepted_argument_ids == expected.extensions[0] - assert calls - - -def test_clingo_backend_missing_binary_is_typed(monkeypatch) -> None: - system, kb, pref = simple_aspic_theory() - calls = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.shutil.which", - lambda binary: None, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.subprocess.run", - lambda *args, **kwargs: calls.append((args, kwargs)), - ) - - result = solve_aspic_with_backend( - system, - kb, - pref, - backend="clingo", - semantics="grounded", - binary="missing-clingo", - ) - - assert result.status == "unavailable_backend" - assert result.backend == "clingo" - assert result.metadata["reason"] == "binary not found on PATH" - assert calls == [] - - -def test_clingo_backend_malformed_answer_set_is_protocol_error(monkeypatch) -> None: - system, kb, pref = simple_aspic_theory() - - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout="Answer: 1\naccepted_arg(unknown)\nSATISFIABLE\n", - stderr="protocol stderr", - ), - ) - - result = solve_aspic_with_backend( - system, - kb, - pref, - backend="clingo", - semantics="grounded", - binary="fake-clingo", - ) - - assert result.status == "protocol_error" - assert result.metadata["reason"] == "accepted argument id is not in the encoding" - assert result.metadata["stdout"] == "Answer: 1\naccepted_arg(unknown)\nSATISFIABLE\n" - assert result.metadata["stderr"] == "protocol stderr" - - -def test_clingo_backend_missing_binary_for_stable_is_typed( - monkeypatch, -) -> None: - system, kb, pref = simple_aspic_theory() - calls = [] - - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.subprocess.run", - lambda *args, **kwargs: calls.append((args, kwargs)), - ) - - result = solve_aspic_with_backend( - system, - kb, - pref, - backend="clingo", - semantics="stable", - binary="fake-clingo", - ) - - assert result.status == "unavailable_backend" - assert result.metadata["reason"] == "binary not found on PATH" - assert calls == [] - - -@st.composite -def simple_aspic_theories(draw): - size = draw(st.integers(min_value=1, max_value=4)) - literals = [Literal(GroundAtom(f"p{index}")) for index in range(size)] - rule_count = draw(st.integers(min_value=0, max_value=max(0, size - 1))) - rules = frozenset( - Rule((literals[index],), literals[index + 1], "defeasible", f"d_{index}") - for index in range(rule_count) - ) - system = ArgumentationSystem( - language=frozenset(literals), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=rules, - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({literals[0]})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - return system, kb, pref - -@given(simple_aspic_theories()) -@settings(deadline=10000, max_examples=25) -def test_clingo_grounded_success_matches_reference_on_generated_simple_theories( - theory, -) -> None: - system, kb, pref = theory - expected = solve_aspic_with_backend( - system, - kb, - pref, - backend="asp", - semantics="grounded", - ) - accepted_ids = " ".join( - f"accepted_arg({arg_id})" - for arg_id in expected.extensions[0] - ) - accepted_lits = " ".join( - f"accepted_lit({literal_id})" - for literal_id, literal in expected.encoding.literal_by_id.items() - if literal in expected.accepted_conclusions - ) - - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.shutil.which", - lambda binary: binary, - ) - monkeypatch.setattr( - "argumentation.solver_adapters.clingo.subprocess.run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout=f"Answer: 1\n{accepted_ids} {accepted_lits}\nSATISFIABLE\n", - stderr="", - ), - ) - - result = solve_aspic_with_backend( - system, - kb, - pref, - backend="clingo", - semantics="grounded", - binary="fake-clingo", - ) - - assert result.status == "success" - assert result.accepted_conclusions == expected.accepted_conclusions - - -def test_ws_o_arg_aspic_encoding_sanitises_literal_ids_for_asp() -> None: - """Bug 2: encoded literal identifiers must be valid ASP constants.""" - p = Literal(GroundAtom("P", (1, 2))) - not_p = p.contrary - system = ArgumentationSystem( - language=frozenset({p, not_p}), - contrariness=ContrarinessFn(contradictories=frozenset({(p, not_p)})), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase(axioms=frozenset({not_p}), premises=frozenset({p})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - encoding = encode_aspic_theory(system, kb, pref) - ids = { - fact.removesuffix(").").split("(", 1)[1] - for fact in encoding.facts - if fact.startswith(("axiom(", "premise(")) - } - - assert ids - assert all(ASP_CONSTANT_RE.fullmatch(identifier) for identifier in ids) - assert encoding.literal_by_id - assert set(encoding.literal_by_id) >= ids - assert not any("~" in fact or "(" in fact.split("(", 1)[1] for fact in encoding.facts) - - -def test_ws_o_arg_aspic_encoding_rejects_duplicate_defeasible_rule_names() -> None: - """Bug 3: duplicate defeasible rule names must fail at encode time.""" - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - r = Literal(GroundAtom("r")) - first = Rule((p,), q, "defeasible", "dup") - second = Rule((p,), r, "defeasible", "dup") - system = ArgumentationSystem( - language=frozenset({p, q, r}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset({first, second}), - ) - kb = KnowledgeBase(axioms=frozenset({p}), premises=frozenset()) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - with pytest.raises(ValueError, match="duplicate defeasible rule name: 'dup'"): - encode_aspic_theory(system, kb, pref) - - -def simple_aspic_theory(): - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - rule = Rule((p,), q, "defeasible", "d_q") - system = ArgumentationSystem( - language=frozenset({p, q}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset({rule}), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - return system, kb, pref +from __future__ import annotations + +import re +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.structured.aspic.aspic import ( + ArgumentationSystem, + ContrarinessFn, + GroundAtom, + KnowledgeBase, + Literal, + PreferenceConfig, + Rule, + build_abstract_framework, + conc, +) +from argumentation.structured.aspic.aspic_encoding import encode_aspic_theory +from argumentation.structured.aspic.aspic_encoding import solve_aspic_grounded +from argumentation.structured.aspic.aspic_encoding import solve_aspic_with_backend +from argumentation.core.dung import grounded_extension + + +ASP_CONSTANT_RE = re.compile(r"^[a-z][A-Za-z0-9_]*$") + + +def test_aspic_encoding_assigns_deterministic_facts_and_signature() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + not_q = q.contrary + strict = Rule((p,), q, "strict") + defeasible = Rule((q,), not_q, "defeasible", "d_not_q") + system = ArgumentationSystem( + language=frozenset({not_q, q, p}), + contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), + strict_rules=frozenset({strict}), + defeasible_rules=frozenset({defeasible}), + ) + kb = KnowledgeBase(axioms=frozenset({p}), premises=frozenset({q})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + first = encode_aspic_theory(system, kb, pref) + second = encode_aspic_theory(system, kb, pref) + + assert first.facts == second.facts + assert first.signature == second.signature + assert first.facts == tuple(sorted(first.facts)) + assert "axiom(p)." in first.facts + assert "premise(q)." in first.facts + assert "s_head(s_0,q)." in first.facts + assert "s_body(s_0,p)." in first.facts + assert "d_head(d_not_q,n_q)." in first.facts + assert "d_body(d_not_q,q)." in first.facts + assert "contrary(q,n_q)." in first.facts + + +def test_aspic_encoding_signature_is_stable_under_input_set_ordering() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + r = Literal(GroundAtom("r")) + d1 = Rule((p,), q, "defeasible", "d1") + d2 = Rule((q,), r, "defeasible", "d2") + first_system = ArgumentationSystem( + language=frozenset({p, q, r}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset({d1, d2}), + ) + second_system = ArgumentationSystem( + language=frozenset({r, q, p}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset({d2, d1}), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({q, p})) + pref = PreferenceConfig( + rule_order=frozenset({(d1, d2)}), + premise_order=frozenset(), + comparison="democratic", + link="last", + ) + + first = encode_aspic_theory(first_system, kb, pref) + second = encode_aspic_theory(second_system, kb, pref) + + assert first.signature == second.signature + assert first.facts == second.facts + assert "preferred(d2,d1)." in first.facts + assert first.metadata["comparison"] == "democratic" + assert first.metadata["link"] == "last" + + +def test_solve_aspic_grounded_returns_accepted_conclusions() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + rule = Rule((p,), q, "defeasible", "d_q") + system = ArgumentationSystem( + language=frozenset({p, q}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset({rule}), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + result = solve_aspic_grounded(system, kb, pref) + + assert result.status == "success" + assert result.semantics == "grounded" + assert result.accepted_conclusions == frozenset({p, q}) + assert result.backend == "materialized_reference" + + +def test_solve_aspic_grounded_matches_materialized_pipeline() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + not_q = q.contrary + rule_q = Rule((p,), q, "defeasible", "d_q") + system = ArgumentationSystem( + language=frozenset({p, q, not_q}), + contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_q}), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p, not_q})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + projection = build_abstract_framework(system, kb, pref) + grounded_ids = grounded_extension(projection.framework) + expected = frozenset(conc(projection.id_to_argument[arg_id]) for arg_id in grounded_ids) + + result = solve_aspic_grounded(system, kb, pref) + + assert result.accepted_conclusions == expected + assert result.accepted_argument_ids == grounded_ids + + +def test_optional_aspic_backend_absence_is_typed() -> None: + p = Literal(GroundAtom("p")) + system = ArgumentationSystem( + language=frozenset({p}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + result = solve_aspic_with_backend(system, kb, pref, backend="missing-test-backend") + + assert result.status == "unavailable_backend" + assert result.backend == "missing-test-backend" + assert result.accepted_argument_ids == frozenset() + assert result.metadata["reason"] == "backend is not installed or registered" + + +def test_clingo_backend_invokes_solver_and_parses_grounded_answer_set(monkeypatch) -> None: + system, kb, pref = simple_aspic_theory() + expected = solve_aspic_with_backend( + system, + kb, + pref, + backend="asp", + semantics="grounded", + ) + accepted_ids = " ".join(f"accepted_arg({arg_id})" for arg_id in expected.extensions[0]) + accepted_lits = " ".join( + f"accepted_lit({literal_id})" + for literal_id, literal in expected.encoding.literal_by_id.items() + if literal in expected.accepted_conclusions + ) + calls: list[list[str]] = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.shutil.which", + lambda binary: binary, + ) + + def fake_run(command, *, capture_output, text, timeout, check): + calls.append(command) + assert command[0] == "fake-clingo" + assert capture_output is True + assert text is True + assert timeout == 5.0 + assert check is False + return SimpleNamespace( + returncode=0, + stdout=f"Answer: 1\n{accepted_ids} {accepted_lits}\nSATISFIABLE\n", + stderr="", + ) + + monkeypatch.setattr("argumentation.solver_adapters.clingo.subprocess.run", fake_run) + + result = solve_aspic_with_backend( + system, + kb, + pref, + backend="clingo", + semantics="grounded", + binary="fake-clingo", + timeout_seconds=5.0, + ) + + assert result.status == "success" + assert result.backend == "clingo" + assert result.accepted_conclusions == frozenset({Literal(GroundAtom("p")), Literal(GroundAtom("q"))}) + assert result.accepted_argument_ids == expected.extensions[0] + assert calls + + +def test_clingo_backend_missing_binary_is_typed(monkeypatch) -> None: + system, kb, pref = simple_aspic_theory() + calls = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.shutil.which", + lambda binary: None, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.subprocess.run", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + result = solve_aspic_with_backend( + system, + kb, + pref, + backend="clingo", + semantics="grounded", + binary="missing-clingo", + ) + + assert result.status == "unavailable_backend" + assert result.backend == "clingo" + assert result.metadata["reason"] == "binary not found on PATH" + assert calls == [] + + +def test_clingo_backend_malformed_answer_set_is_protocol_error(monkeypatch) -> None: + system, kb, pref = simple_aspic_theory() + + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout="Answer: 1\naccepted_arg(unknown)\nSATISFIABLE\n", + stderr="protocol stderr", + ), + ) + + result = solve_aspic_with_backend( + system, + kb, + pref, + backend="clingo", + semantics="grounded", + binary="fake-clingo", + ) + + assert result.status == "protocol_error" + assert result.metadata["reason"] == "accepted argument id is not in the encoding" + assert result.metadata["stdout"] == "Answer: 1\naccepted_arg(unknown)\nSATISFIABLE\n" + assert result.metadata["stderr"] == "protocol stderr" + + +def test_clingo_backend_missing_binary_for_stable_is_typed( + monkeypatch, +) -> None: + system, kb, pref = simple_aspic_theory() + calls = [] + + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.subprocess.run", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + result = solve_aspic_with_backend( + system, + kb, + pref, + backend="clingo", + semantics="stable", + binary="fake-clingo", + ) + + assert result.status == "unavailable_backend" + assert result.metadata["reason"] == "binary not found on PATH" + assert calls == [] + + +@st.composite +def simple_aspic_theories(draw): + size = draw(st.integers(min_value=1, max_value=4)) + literals = [Literal(GroundAtom(f"p{index}")) for index in range(size)] + rule_count = draw(st.integers(min_value=0, max_value=max(0, size - 1))) + rules = frozenset( + Rule((literals[index],), literals[index + 1], "defeasible", f"d_{index}") + for index in range(rule_count) + ) + system = ArgumentationSystem( + language=frozenset(literals), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=rules, + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({literals[0]})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + return system, kb, pref + +@given(simple_aspic_theories()) +@settings(deadline=10000, max_examples=25) +def test_clingo_grounded_success_matches_reference_on_generated_simple_theories( + theory, +) -> None: + system, kb, pref = theory + expected = solve_aspic_with_backend( + system, + kb, + pref, + backend="asp", + semantics="grounded", + ) + accepted_ids = " ".join( + f"accepted_arg({arg_id})" + for arg_id in expected.extensions[0] + ) + accepted_lits = " ".join( + f"accepted_lit({literal_id})" + for literal_id, literal in expected.encoding.literal_by_id.items() + if literal in expected.accepted_conclusions + ) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.shutil.which", + lambda binary: binary, + ) + monkeypatch.setattr( + "argumentation.solver_adapters.clingo.subprocess.run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=f"Answer: 1\n{accepted_ids} {accepted_lits}\nSATISFIABLE\n", + stderr="", + ), + ) + + result = solve_aspic_with_backend( + system, + kb, + pref, + backend="clingo", + semantics="grounded", + binary="fake-clingo", + ) + + assert result.status == "success" + assert result.accepted_conclusions == expected.accepted_conclusions + + +def test_ws_o_arg_aspic_encoding_sanitises_literal_ids_for_asp() -> None: + """Bug 2: encoded literal identifiers must be valid ASP constants.""" + p = Literal(GroundAtom("P", (1, 2))) + not_p = p.contrary + system = ArgumentationSystem( + language=frozenset({p, not_p}), + contrariness=ContrarinessFn(contradictories=frozenset({(p, not_p)})), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase(axioms=frozenset({not_p}), premises=frozenset({p})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + encoding = encode_aspic_theory(system, kb, pref) + ids = { + fact.removesuffix(").").split("(", 1)[1] + for fact in encoding.facts + if fact.startswith(("axiom(", "premise(")) + } + + assert ids + assert all(ASP_CONSTANT_RE.fullmatch(identifier) for identifier in ids) + assert encoding.literal_by_id + assert set(encoding.literal_by_id) >= ids + assert not any("~" in fact or "(" in fact.split("(", 1)[1] for fact in encoding.facts) + + +def test_ws_o_arg_aspic_encoding_rejects_duplicate_defeasible_rule_names() -> None: + """Bug 3: duplicate defeasible rule names must fail at encode time.""" + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + r = Literal(GroundAtom("r")) + first = Rule((p,), q, "defeasible", "dup") + second = Rule((p,), r, "defeasible", "dup") + system = ArgumentationSystem( + language=frozenset({p, q, r}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset({first, second}), + ) + kb = KnowledgeBase(axioms=frozenset({p}), premises=frozenset()) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + with pytest.raises(ValueError, match="duplicate defeasible rule name: 'dup'"): + encode_aspic_theory(system, kb, pref) + + +def simple_aspic_theory(): + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + rule = Rule((p,), q, "defeasible", "d_q") + system = ArgumentationSystem( + language=frozenset({p, q}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset({rule}), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({p})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + return system, kb, pref diff --git a/tests/test_aspic_incomplete.py b/tests/structured/aspic/test_aspic_incomplete.py similarity index 92% rename from tests/test_aspic_incomplete.py rename to tests/structured/aspic/test_aspic_incomplete.py index c726e77..6d6613d 100644 --- a/tests/test_aspic_incomplete.py +++ b/tests/structured/aspic/test_aspic_incomplete.py @@ -1,10 +1,6 @@ from __future__ import annotations -import importlib - -import argumentation - -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, ContrarinessFn, GroundAtom, @@ -13,7 +9,7 @@ PreferenceConfig, Rule, ) -from argumentation.aspic_incomplete import ( +from argumentation.structured.aspic.aspic_incomplete import ( PartialASPICTheory, evaluate_incomplete_grounded, ) @@ -28,12 +24,6 @@ def _empty_pref() -> PreferenceConfig: ) -def test_aspic_incomplete_module_is_exported_from_package() -> None: - package = importlib.reload(argumentation) - - assert "aspic_incomplete" in package.__all__ - - def test_unknown_premise_makes_conclusion_relevant_across_completions() -> None: p = Literal(GroundAtom("p")) q = Literal(GroundAtom("q")) diff --git a/tests/test_aspic_projection.py b/tests/structured/aspic/test_aspic_projection.py similarity index 93% rename from tests/test_aspic_projection.py rename to tests/structured/aspic/test_aspic_projection.py index c712221..9e5c52f 100644 --- a/tests/test_aspic_projection.py +++ b/tests/structured/aspic/test_aspic_projection.py @@ -1,84 +1,84 @@ -from __future__ import annotations - -from argumentation.aspic import ( - ArgumentationSystem, - ContrarinessFn, - GroundAtom, - KnowledgeBase, - Literal, - PreferenceConfig, - Rule, - build_abstract_framework, - build_arguments, - compute_attacks, - compute_defeats, -) -from argumentation.dung import ArgumentationFramework - - -def test_build_abstract_framework_matches_manual_aspic_pipeline() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - not_q = q.contrary - rule_q = Rule((p,), q, "defeasible", "d_q") - - system = ArgumentationSystem( - language=frozenset({p, q, not_q}), - contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), - strict_rules=frozenset(), - defeasible_rules=frozenset({rule_q}), - ) - kb = KnowledgeBase( - axioms=frozenset(), - premises=frozenset({p, not_q}), - ) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - projection = build_abstract_framework(system, kb, pref) - arguments = build_arguments(system, kb) - attacks = compute_attacks(arguments, system) - defeats = compute_defeats(attacks, arguments, system, kb, pref) - - assert projection.arguments == arguments - assert projection.attacks == attacks - assert projection.defeats == defeats - assert projection.framework == ArgumentationFramework( - arguments=frozenset(projection.argument_to_id.values()), - attacks=frozenset( - (projection.argument_to_id[attack.attacker], projection.argument_to_id[attack.target]) - for attack in attacks - ), - defeats=frozenset( - (projection.argument_to_id[attack.attacker], projection.argument_to_id[attack.target]) - for attack in defeats - ), - ) - - -def test_build_abstract_framework_assigns_deterministic_external_ids() -> None: - p = Literal(GroundAtom("p")) - q = Literal(GroundAtom("q")) - system = ArgumentationSystem( - language=frozenset({p, q}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({q, p})) - pref = PreferenceConfig( - rule_order=frozenset(), - premise_order=frozenset(), - comparison="elitist", - link="last", - ) - - first = build_abstract_framework(system, kb, pref) - second = build_abstract_framework(system, kb, pref) - - assert first.argument_to_id == second.argument_to_id - assert set(first.id_to_argument) == set(first.argument_to_id.values()) +from __future__ import annotations + +from argumentation.structured.aspic.aspic import ( + ArgumentationSystem, + ContrarinessFn, + GroundAtom, + KnowledgeBase, + Literal, + PreferenceConfig, + Rule, + build_abstract_framework, + build_arguments, + compute_attacks, + compute_defeats, +) +from argumentation.core.dung import ArgumentationFramework + + +def test_build_abstract_framework_matches_manual_aspic_pipeline() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + not_q = q.contrary + rule_q = Rule((p,), q, "defeasible", "d_q") + + system = ArgumentationSystem( + language=frozenset({p, q, not_q}), + contrariness=ContrarinessFn(contradictories=frozenset({(q, not_q)})), + strict_rules=frozenset(), + defeasible_rules=frozenset({rule_q}), + ) + kb = KnowledgeBase( + axioms=frozenset(), + premises=frozenset({p, not_q}), + ) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + projection = build_abstract_framework(system, kb, pref) + arguments = build_arguments(system, kb) + attacks = compute_attacks(arguments, system) + defeats = compute_defeats(attacks, arguments, system, kb, pref) + + assert projection.arguments == arguments + assert projection.attacks == attacks + assert projection.defeats == defeats + assert projection.framework == ArgumentationFramework( + arguments=frozenset(projection.argument_to_id.values()), + attacks=frozenset( + (projection.argument_to_id[attack.attacker], projection.argument_to_id[attack.target]) + for attack in attacks + ), + defeats=frozenset( + (projection.argument_to_id[attack.attacker], projection.argument_to_id[attack.target]) + for attack in defeats + ), + ) + + +def test_build_abstract_framework_assigns_deterministic_external_ids() -> None: + p = Literal(GroundAtom("p")) + q = Literal(GroundAtom("q")) + system = ArgumentationSystem( + language=frozenset({p, q}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + kb = KnowledgeBase(axioms=frozenset(), premises=frozenset({q, p})) + pref = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset(), + comparison="elitist", + link="last", + ) + + first = build_abstract_framework(system, kb, pref) + second = build_abstract_framework(system, kb, pref) + + assert first.argument_to_id == second.argument_to_id + assert set(first.id_to_argument) == set(first.argument_to_id.values()) diff --git a/tests/test_aspic_ws_f_public_api.py b/tests/structured/aspic/test_aspic_ws_f_public_api.py similarity index 96% rename from tests/test_aspic_ws_f_public_api.py rename to tests/structured/aspic/test_aspic_ws_f_public_api.py index ee5fd93..efec0f1 100644 --- a/tests/test_aspic_ws_f_public_api.py +++ b/tests/structured/aspic/test_aspic_ws_f_public_api.py @@ -5,7 +5,7 @@ imports at the package boundary. """ -from argumentation import aspic +from argumentation.structured.aspic import aspic def test_transposition_closure_returns_closed_rules_and_post_closure_language(): diff --git a/tests/test_backward_chaining.py b/tests/structured/aspic/test_backward_chaining.py similarity index 99% rename from tests/test_backward_chaining.py rename to tests/structured/aspic/test_backward_chaining.py index 67cfea4..82beaf5 100644 --- a/tests/test_backward_chaining.py +++ b/tests/structured/aspic/test_backward_chaining.py @@ -15,8 +15,8 @@ from hypothesis import HealthCheck, given, settings, assume from hypothesis import strategies as st -import argumentation.aspic as aspic -from argumentation.aspic import ( +import argumentation.structured.aspic.aspic as aspic +from argumentation.structured.aspic.aspic import ( Literal, GroundAtom, ContrarinessFn, diff --git a/tests/test_datalog_grounding.py b/tests/structured/aspic/test_datalog_grounding.py similarity index 97% rename from tests/test_datalog_grounding.py rename to tests/structured/aspic/test_datalog_grounding.py index 27717aa..46be18b 100644 --- a/tests/test_datalog_grounding.py +++ b/tests/structured/aspic/test_datalog_grounding.py @@ -2,8 +2,8 @@ from gunray import DefeasibleTheory, Rule as GunrayRule -from argumentation.aspic import GroundAtom, Literal, build_arguments -from argumentation.datalog_grounding import ( +from argumentation.structured.aspic.aspic import GroundAtom, Literal, build_arguments +from argumentation.structured.aspic.datalog_grounding import ( ground_defeasible_theory, grounding_inspection_to_aspic, ) diff --git a/tests/test_subjective_aspic.py b/tests/structured/aspic/test_subjective_aspic.py similarity index 91% rename from tests/test_subjective_aspic.py rename to tests/structured/aspic/test_subjective_aspic.py index 32472a5..79e8964 100644 --- a/tests/test_subjective_aspic.py +++ b/tests/structured/aspic/test_subjective_aspic.py @@ -2,8 +2,7 @@ import pytest -import argumentation -from argumentation.aspic import ( +from argumentation.structured.aspic.aspic import ( ArgumentationSystem, ContrarinessFn, GroundAtom, @@ -12,7 +11,7 @@ PreferenceConfig, Rule, ) -from argumentation.subjective_aspic import ( +from argumentation.structured.aspic.subjective_aspic import ( complementary_literals, subjective_argumentation_theory, subjective_defeasible_rules, @@ -20,12 +19,6 @@ ) -def test_subjective_aspic_module_is_exported() -> None: - assert argumentation.subjective_aspic.subjective_knowledge_base is subjective_knowledge_base - assert "subjective_aspic" in argumentation.__all__ - assert "value_based" not in argumentation.__all__ - - def lit(name: str) -> Literal: return Literal(GroundAtom(name)) diff --git a/tests/test_docs_surface.py b/tests/test_docs_surface.py index 571b79b..acd5353 100644 --- a/tests/test_docs_surface.py +++ b/tests/test_docs_surface.py @@ -10,21 +10,21 @@ def test_readme_documents_new_package_surfaces() -> None: readme = (ROOT / "README.md").read_text(encoding="utf-8") for expected in ( - "argumentation.ranking", - "argumentation.weighted", - "argumentation.gradual", - "argumentation.subjective_aspic", - "argumentation.vaf", - "argumentation.practical_reasoning", - "argumentation.ranking_axioms", - "argumentation.accrual", - "argumentation.setaf", - "argumentation.enforcement", - "argumentation.caf", - "argumentation.dynamic", - "argumentation.approximate", - "argumentation.epistemic", - "argumentation.llm_surface", + "argumentation.ranking.ranking", + "argumentation.ranking.weighted", + "argumentation.gradual.gradual", + "argumentation.structured.aspic.subjective_aspic", + "argumentation.frameworks.vaf", + "argumentation.frameworks.practical_reasoning", + "argumentation.ranking.ranking_axioms", + "argumentation.core.accrual", + "argumentation.frameworks.setaf", + "argumentation.dynamics.enforcement", + "argumentation.frameworks.caf", + "argumentation.dynamics.dynamic", + "argumentation.dynamics.approximate", + "argumentation.probabilistic.epistemic", + "argumentation.gradual.llm_surface", ): assert expected in readme @@ -40,21 +40,21 @@ def test_architecture_documents_new_package_surfaces() -> None: architecture = (ROOT / "docs" / "architecture.md").read_text(encoding="utf-8") for expected in ( - "argumentation.ranking", - "argumentation.weighted", - "argumentation.gradual", - "argumentation.subjective_aspic", - "argumentation.vaf", - "argumentation.practical_reasoning", - "argumentation.ranking_axioms", - "argumentation.accrual", - "argumentation.setaf", - "argumentation.enforcement", - "argumentation.caf", - "argumentation.dynamic", - "argumentation.approximate", - "argumentation.epistemic", - "argumentation.llm_surface", + "argumentation.ranking.ranking", + "argumentation.ranking.weighted", + "argumentation.gradual.gradual", + "argumentation.structured.aspic.subjective_aspic", + "argumentation.frameworks.vaf", + "argumentation.frameworks.practical_reasoning", + "argumentation.ranking.ranking_axioms", + "argumentation.core.accrual", + "argumentation.frameworks.setaf", + "argumentation.dynamics.enforcement", + "argumentation.frameworks.caf", + "argumentation.dynamics.dynamic", + "argumentation.dynamics.approximate", + "argumentation.probabilistic.epistemic", + "argumentation.gradual.llm_surface", ): assert expected in architecture diff --git a/tests/test_performance_contracts.py b/tests/test_performance_contracts.py index f131d98..07a6c24 100644 --- a/tests/test_performance_contracts.py +++ b/tests/test_performance_contracts.py @@ -5,11 +5,11 @@ import pytest -from argumentation.aba import ABAFramework -from argumentation.aba_incremental import AbaIncrementalSolver, IncrementalTelemetry -from argumentation.aspic import GroundAtom, Literal, Rule -import argumentation.solver as solver_module -from argumentation.solver import SingleExtensionSolverSuccess, solve_aba_single_extension +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aba.aba_incremental import AbaIncrementalSolver, IncrementalTelemetry +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +import argumentation.solving.solver as solver_module +from argumentation.solving.solver import SingleExtensionSolverSuccess, solve_aba_single_extension from tests import performance_contracts from tests.performance_contracts import ( CALIBRATION_ENV, diff --git a/tests/test_semantics.py b/tests/test_semantics.py index 8cdb730..c9fe177 100644 --- a/tests/test_semantics.py +++ b/tests/test_semantics.py @@ -1,111 +1,111 @@ -from __future__ import annotations - -import pytest - -from argumentation.bipolar import BipolarArgumentationFramework -from argumentation.dung import ArgumentationFramework -from argumentation.partial_af import PartialArgumentationFramework -from argumentation.semantics import SemanticsUndefined, accepted_arguments, extensions - - -def test_dung_extensions_dispatches_standard_semantics() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c")}), - ) - - assert extensions(framework, semantics="grounded") == (frozenset({"a", "c"}),) - assert extensions(framework, semantics="complete") == (frozenset({"a", "c"}),) - assert extensions(framework, semantics="preferred") == (frozenset({"a", "c"}),) - assert extensions(framework, semantics="stable") == (frozenset({"a", "c"}),) - - -def test_accepted_arguments_supports_credulous_and_skeptical_modes() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - assert accepted_arguments( - framework, - semantics="preferred", - mode="credulous", - ) == frozenset({"a", "b"}) - assert accepted_arguments( - framework, - semantics="preferred", - mode="skeptical", - ) == frozenset() - - -def test_empty_stable_extension_family_returns_sentinel() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("a", "b"), ("b", "c"), ("c", "a")}), - ) - - assert extensions(framework, semantics="stable") == () - assert accepted_arguments(framework, semantics="stable") is SemanticsUndefined - assert ( - accepted_arguments(framework, semantics="stable", mode="skeptical") - is SemanticsUndefined - ) - - -def test_bipolar_extensions_dispatch_preferred_and_stable_variants() -> None: - framework = BipolarArgumentationFramework( - arguments=frozenset({"a", "b", "c"}), - defeats=frozenset({("b", "c")}), - supports=frozenset({("a", "b")}), - ) - - assert extensions(framework, semantics="d-preferred") - assert extensions(framework, semantics="s-preferred") - assert extensions(framework, semantics="c-preferred") - assert extensions(framework, semantics="bipolar-stable") - - -def test_partial_af_extensions_are_completion_based() -> None: - framework = PartialArgumentationFramework( - arguments=frozenset({"a", "b"}), - attacks=frozenset({("a", "b")}), - ignorance=frozenset({("b", "a")}), - non_attacks=frozenset({("a", "a"), ("b", "b")}), - ) - - assert extensions(framework, semantics="grounded") == ( - frozenset(), - frozenset({"a"}), - ) - assert accepted_arguments( - framework, - semantics="grounded", - mode="credulous", - ) == frozenset({"a"}) - assert accepted_arguments( - framework, - semantics="grounded", - mode="necessary_skeptical", - ) == frozenset() - assert accepted_arguments( - framework, - semantics="grounded", - mode="possible_skeptical", - ) == frozenset({"a"}) - with pytest.raises(ValueError, match="necessary_skeptical"): - accepted_arguments( - framework, - semantics="grounded", - mode="skeptical", - ) - - -def test_semantics_rejects_unknown_framework_and_mode() -> None: - with pytest.raises(TypeError): - extensions(object(), semantics="grounded") - with pytest.raises(ValueError, match="mode"): - accepted_arguments( - ArgumentationFramework(arguments=frozenset(), defeats=frozenset()), - semantics="grounded", - mode="both", - ) +from __future__ import annotations + +import pytest + +from argumentation.core.bipolar import BipolarArgumentationFramework +from argumentation.core.dung import ArgumentationFramework +from argumentation.frameworks.partial_af import PartialArgumentationFramework +from argumentation.semantics import SemanticsUndefined, accepted_arguments, extensions + + +def test_dung_extensions_dispatches_standard_semantics() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c")}), + ) + + assert extensions(framework, semantics="grounded") == (frozenset({"a", "c"}),) + assert extensions(framework, semantics="complete") == (frozenset({"a", "c"}),) + assert extensions(framework, semantics="preferred") == (frozenset({"a", "c"}),) + assert extensions(framework, semantics="stable") == (frozenset({"a", "c"}),) + + +def test_accepted_arguments_supports_credulous_and_skeptical_modes() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + assert accepted_arguments( + framework, + semantics="preferred", + mode="credulous", + ) == frozenset({"a", "b"}) + assert accepted_arguments( + framework, + semantics="preferred", + mode="skeptical", + ) == frozenset() + + +def test_empty_stable_extension_family_returns_sentinel() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("a", "b"), ("b", "c"), ("c", "a")}), + ) + + assert extensions(framework, semantics="stable") == () + assert accepted_arguments(framework, semantics="stable") is SemanticsUndefined + assert ( + accepted_arguments(framework, semantics="stable", mode="skeptical") + is SemanticsUndefined + ) + + +def test_bipolar_extensions_dispatch_preferred_and_stable_variants() -> None: + framework = BipolarArgumentationFramework( + arguments=frozenset({"a", "b", "c"}), + defeats=frozenset({("b", "c")}), + supports=frozenset({("a", "b")}), + ) + + assert extensions(framework, semantics="d-preferred") + assert extensions(framework, semantics="s-preferred") + assert extensions(framework, semantics="c-preferred") + assert extensions(framework, semantics="bipolar-stable") + + +def test_partial_af_extensions_are_completion_based() -> None: + framework = PartialArgumentationFramework( + arguments=frozenset({"a", "b"}), + attacks=frozenset({("a", "b")}), + ignorance=frozenset({("b", "a")}), + non_attacks=frozenset({("a", "a"), ("b", "b")}), + ) + + assert extensions(framework, semantics="grounded") == ( + frozenset(), + frozenset({"a"}), + ) + assert accepted_arguments( + framework, + semantics="grounded", + mode="credulous", + ) == frozenset({"a"}) + assert accepted_arguments( + framework, + semantics="grounded", + mode="necessary_skeptical", + ) == frozenset() + assert accepted_arguments( + framework, + semantics="grounded", + mode="possible_skeptical", + ) == frozenset({"a"}) + with pytest.raises(ValueError, match="necessary_skeptical"): + accepted_arguments( + framework, + semantics="grounded", + mode="skeptical", + ) + + +def test_semantics_rejects_unknown_framework_and_mode() -> None: + with pytest.raises(TypeError): + extensions(object(), semantics="grounded") + with pytest.raises(ValueError, match="mode"): + accepted_arguments( + ArgumentationFramework(arguments=frozenset(), defeats=frozenset()), + semantics="grounded", + mode="both", + ) diff --git a/tests/test_workstream_o_arg_aba_adf_done.py b/tests/test_workstream_o_arg_aba_adf_done.py index 5382028..3e05d3a 100644 --- a/tests/test_workstream_o_arg_aba_adf_done.py +++ b/tests/test_workstream_o_arg_aba_adf_done.py @@ -1,8 +1,9 @@ from __future__ import annotations -from argumentation import aba, adf -from argumentation.aba import ABAFramework, ABAPlusFramework, NotFlatABAError -from argumentation.adf import ( +from argumentation.structured.aba import aba +from argumentation.frameworks import adf +from argumentation.structured.aba.aba import ABAFramework, ABAPlusFramework, NotFlatABAError +from argumentation.frameworks.adf import ( AbstractDialecticalFramework, And, Atom, @@ -14,8 +15,8 @@ grounded_interpretation, interpretation_from_mapping, ) -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.iccma import parse_aba, write_aba +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.interop.iccma import parse_aba, write_aba def lit(name: str) -> Literal: diff --git a/tests/test_workstream_o_arg_done.py b/tests/test_workstream_o_arg_done.py index 9671e04..965c58a 100644 --- a/tests/test_workstream_o_arg_done.py +++ b/tests/test_workstream_o_arg_done.py @@ -1,107 +1,107 @@ -from __future__ import annotations - -import re - -import pytest - -from argumentation.af_revision import AFChangeKind, ExtensionRevisionState, _classify_extension_change -from argumentation.aspic import ( - ArgumentationSystem, - ContrarinessFn, - GroundAtom, - KnowledgeBase, - Literal, - PreferenceConfig, - Rule, -) -from argumentation.aspic_encoding import encode_aspic_theory -from argumentation.dung import ArgumentationFramework, admissible, ideal_extension -from argumentation.partial_af import PartialArgumentationFramework -from argumentation.preference import strictly_weaker -from argumentation.probabilistic import _z_for_confidence -from argumentation.semantics import accepted_arguments - - -def test_workstream_o_arg_done() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b", "x", "y"}), - defeats=frozenset( - { - ("x", "a"), - ("x", "x"), - ("b", "x"), - ("y", "b"), - ("y", "y"), - ("a", "y"), - } - ), - ) - ideal = ideal_extension(framework) - assert ideal == frozenset({"a", "b"}) - assert admissible(ideal, framework.arguments, framework.defeats) - - p = Literal(GroundAtom("P", (1, 2))) - not_p = p.contrary - pref = PreferenceConfig(frozenset(), frozenset(), comparison="elitist", link="last") - system = ArgumentationSystem( - language=frozenset({p, not_p}), - contrariness=ContrarinessFn(frozenset({(p, not_p)})), - strict_rules=frozenset(), - defeasible_rules=frozenset(), - ) - encoding = encode_aspic_theory(system, KnowledgeBase(frozenset({not_p}), frozenset({p})), pref) - assert all( - re.fullmatch(r"[a-z][A-Za-z0-9_]*", fact.removesuffix(").").split("(", 1)[1]) - for fact in encoding.facts - if fact.startswith(("axiom(", "premise(")) - ) - - q = Literal(GroundAtom("q")) - r = Literal(GroundAtom("r")) - duplicate_system = ArgumentationSystem( - language=frozenset({p, q, r}), - contrariness=ContrarinessFn(frozenset()), - strict_rules=frozenset(), - defeasible_rules=frozenset( - { - Rule((p,), q, "defeasible", "dup"), - Rule((p,), r, "defeasible", "dup"), - } - ), - ) - with pytest.raises(ValueError, match="duplicate defeasible rule name"): - encode_aspic_theory(duplicate_system, KnowledgeBase(frozenset({p}), frozenset()), pref) - - assert _classify_extension_change( - (frozenset({"a"}), frozenset({"b"})), - (frozenset({"a"}),), - ) is AFChangeKind.DECISIVE - - calls: list[frozenset[str]] = [] - - def ranking(extension: frozenset[str]) -> int: - calls.append(extension) - return 0 - - state = ExtensionRevisionState.from_extensions( - frozenset(f"a{i}" for i in range(20)), - (frozenset({"a0"}),), - ranking=ranking, - ) - assert calls == [] - assert state.minimal_extensions((frozenset({"a0"}),)) == (frozenset({"a0"}),) - - assert strictly_weaker([1.0], [], "elitist") is True - - partial = PartialArgumentationFramework( - arguments=frozenset({"a", "b"}), - attacks=frozenset({("a", "b")}), - ignorance=frozenset({("b", "a")}), - non_attacks=frozenset({("a", "a"), ("b", "b")}), - ) - assert accepted_arguments(partial, semantics="grounded", mode="necessary_skeptical") == frozenset() - assert accepted_arguments(partial, semantics="grounded", mode="possible_skeptical") == frozenset({"a"}) - with pytest.raises(ValueError, match="necessary_skeptical"): - accepted_arguments(partial, semantics="grounded", mode="skeptical") - - assert _z_for_confidence(0.975) == pytest.approx(2.2414027276) +from __future__ import annotations + +import re + +import pytest + +from argumentation.dynamics.af_revision import AFChangeKind, ExtensionRevisionState, _classify_extension_change +from argumentation.structured.aspic.aspic import ( + ArgumentationSystem, + ContrarinessFn, + GroundAtom, + KnowledgeBase, + Literal, + PreferenceConfig, + Rule, +) +from argumentation.structured.aspic.aspic_encoding import encode_aspic_theory +from argumentation.core.dung import ArgumentationFramework, admissible, ideal_extension +from argumentation.frameworks.partial_af import PartialArgumentationFramework +from argumentation.core.preference import strictly_weaker +from argumentation.probabilistic.probabilistic import _z_for_confidence +from argumentation.semantics import accepted_arguments + + +def test_workstream_o_arg_done() -> None: + framework = ArgumentationFramework( + arguments=frozenset({"a", "b", "x", "y"}), + defeats=frozenset( + { + ("x", "a"), + ("x", "x"), + ("b", "x"), + ("y", "b"), + ("y", "y"), + ("a", "y"), + } + ), + ) + ideal = ideal_extension(framework) + assert ideal == frozenset({"a", "b"}) + assert admissible(ideal, framework.arguments, framework.defeats) + + p = Literal(GroundAtom("P", (1, 2))) + not_p = p.contrary + pref = PreferenceConfig(frozenset(), frozenset(), comparison="elitist", link="last") + system = ArgumentationSystem( + language=frozenset({p, not_p}), + contrariness=ContrarinessFn(frozenset({(p, not_p)})), + strict_rules=frozenset(), + defeasible_rules=frozenset(), + ) + encoding = encode_aspic_theory(system, KnowledgeBase(frozenset({not_p}), frozenset({p})), pref) + assert all( + re.fullmatch(r"[a-z][A-Za-z0-9_]*", fact.removesuffix(").").split("(", 1)[1]) + for fact in encoding.facts + if fact.startswith(("axiom(", "premise(")) + ) + + q = Literal(GroundAtom("q")) + r = Literal(GroundAtom("r")) + duplicate_system = ArgumentationSystem( + language=frozenset({p, q, r}), + contrariness=ContrarinessFn(frozenset()), + strict_rules=frozenset(), + defeasible_rules=frozenset( + { + Rule((p,), q, "defeasible", "dup"), + Rule((p,), r, "defeasible", "dup"), + } + ), + ) + with pytest.raises(ValueError, match="duplicate defeasible rule name"): + encode_aspic_theory(duplicate_system, KnowledgeBase(frozenset({p}), frozenset()), pref) + + assert _classify_extension_change( + (frozenset({"a"}), frozenset({"b"})), + (frozenset({"a"}),), + ) is AFChangeKind.DECISIVE + + calls: list[frozenset[str]] = [] + + def ranking(extension: frozenset[str]) -> int: + calls.append(extension) + return 0 + + state = ExtensionRevisionState.from_extensions( + frozenset(f"a{i}" for i in range(20)), + (frozenset({"a0"}),), + ranking=ranking, + ) + assert calls == [] + assert state.minimal_extensions((frozenset({"a0"}),)) == (frozenset({"a0"}),) + + assert strictly_weaker([1.0], [], "elitist") is True + + partial = PartialArgumentationFramework( + arguments=frozenset({"a", "b"}), + attacks=frozenset({("a", "b")}), + ignorance=frozenset({("b", "a")}), + non_attacks=frozenset({("a", "a"), ("b", "b")}), + ) + assert accepted_arguments(partial, semantics="grounded", mode="necessary_skeptical") == frozenset() + assert accepted_arguments(partial, semantics="grounded", mode="possible_skeptical") == frozenset({"a"}) + with pytest.raises(ValueError, match="necessary_skeptical"): + accepted_arguments(partial, semantics="grounded", mode="skeptical") + + assert _z_for_confidence(0.975) == pytest.approx(2.2414027276) diff --git a/tests/test_workstream_o_arg_dung_extensions_done.py b/tests/test_workstream_o_arg_dung_extensions_done.py index 9206a8b..7fd507d 100644 --- a/tests/test_workstream_o_arg_dung_extensions_done.py +++ b/tests/test_workstream_o_arg_dung_extensions_done.py @@ -1,44 +1,44 @@ -from __future__ import annotations - -import importlib - -import pytest - -from argumentation import bipolar, dung, labelling - - -pytestmark = pytest.mark.unit - - -def test_workstream_o_arg_dung_extensions_public_surface_is_done() -> None: - for name in ( - "legally_in", - "legally_out", - "complete_labellings", - "grounded_labelling", - "preferred_labellings", - "stable_labellings", - "semi_stable_labellings", - "eager_labelling", - "stage2_labellings", - ): - assert hasattr(labelling, name) - - for name in ( - "eager_extension", - "stage2_extensions", - "indirect_attacks", - "prudent_conflict_free", - "prudent_admissible", - "prudent_preferred_extensions", - "prudent_grounded_extension", - ): - assert hasattr(dung, name) - - for name in ("bipolar_grounded_extension", "bipolar_complete_extensions"): - assert hasattr(bipolar, name) - - with pytest.raises(ModuleNotFoundError): - importlib.import_module("argumentation.dung_z3") - - assert not hasattr(dung, "_AUTO_BACKEND_MAX_ARGS") +from __future__ import annotations + +import importlib + +import pytest + +from argumentation.core import bipolar, dung, labelling + + +pytestmark = pytest.mark.unit + + +def test_workstream_o_arg_dung_extensions_public_surface_is_done() -> None: + for name in ( + "legally_in", + "legally_out", + "complete_labellings", + "grounded_labelling", + "preferred_labellings", + "stable_labellings", + "semi_stable_labellings", + "eager_labelling", + "stage2_labellings", + ): + assert hasattr(labelling, name) + + for name in ( + "eager_extension", + "stage2_extensions", + "indirect_attacks", + "prudent_conflict_free", + "prudent_admissible", + "prudent_preferred_extensions", + "prudent_grounded_extension", + ): + assert hasattr(dung, name) + + for name in ("bipolar_grounded_extension", "bipolar_complete_extensions"): + assert hasattr(bipolar, name) + + with pytest.raises(ModuleNotFoundError): + importlib.import_module("argumentation.dung_z3") + + assert not hasattr(dung, "_AUTO_BACKEND_MAX_ARGS") diff --git a/tests/test_workstream_o_arg_gradual_done.py b/tests/test_workstream_o_arg_gradual_done.py index 9d9aa49..feefea1 100644 --- a/tests/test_workstream_o_arg_gradual_done.py +++ b/tests/test_workstream_o_arg_gradual_done.py @@ -7,13 +7,12 @@ def test_workstream_o_arg_gradual_public_surface_is_complete() -> None: - import argumentation - from argumentation import dfquad, equational, gradual, gradual_principles, matt_toni + from argumentation.gradual import dfquad, equational, gradual, gradual_principles + from argumentation.ranking import matt_toni - assert argumentation.dfquad is dfquad - assert argumentation.matt_toni is matt_toni - assert argumentation.equational is equational - assert argumentation.gradual_principles is gradual_principles + assert matt_toni is not None + assert equational is not None + assert gradual_principles is not None assert hasattr(gradual, "quadratic_energy_strengths_continuous") assert hasattr(dfquad, "dfquad_bipolar_strengths") assert "integration_method" in { diff --git a/tests/test_workstream_o_arg_vaf_completion_done.py b/tests/test_workstream_o_arg_vaf_completion_done.py index ccde285..4d4c75c 100644 --- a/tests/test_workstream_o_arg_vaf_completion_done.py +++ b/tests/test_workstream_o_arg_vaf_completion_done.py @@ -1,7 +1,7 @@ from __future__ import annotations -from argumentation.vaf import ValueBasedArgumentationFramework -from argumentation.vaf_completion import ( +from argumentation.frameworks.vaf import ValueBasedArgumentationFramework +from argumentation.frameworks.vaf_completion import ( FACT_VALUE, ArgumentChain, ArgumentLine, diff --git a/tests/test_workstream_o_arg_vaf_ranking_done.py b/tests/test_workstream_o_arg_vaf_ranking_done.py index 69582f2..b902278 100644 --- a/tests/test_workstream_o_arg_vaf_ranking_done.py +++ b/tests/test_workstream_o_arg_vaf_ranking_done.py @@ -1,78 +1,80 @@ -from __future__ import annotations - -import importlib - -import pytest - -import argumentation -from argumentation.dung import ArgumentationFramework -from argumentation.ranking import RankingResult, categoriser_scores -from argumentation.ranking_axioms import ( - abstraction, - cardinality_precedence, - counter_transitivity, - defense_precedence, - distributed_defense_precedence, - independence, - quality_precedence, - self_contradiction, - strict_addition_of_defense_branch, - strict_counter_transitivity, - strict_preference_transitive, - void_precedence, -) - - -def test_workstream_o_arg_vaf_ranking_public_surface_is_closed() -> None: - assert argumentation.vaf is importlib.import_module("argumentation.vaf") - assert argumentation.practical_reasoning is importlib.import_module( - "argumentation.practical_reasoning" - ) - assert argumentation.subjective_aspic is importlib.import_module( - "argumentation.subjective_aspic" - ) - assert argumentation.ranking_axioms is importlib.import_module( - "argumentation.ranking_axioms" - ) - - with pytest.raises(ModuleNotFoundError): - importlib.import_module("argumentation.value_based") - - -def test_workstream_o_arg_vaf_ranking_contracts_are_closed() -> None: - framework = ArgumentationFramework( - arguments=frozenset({"a", "b"}), - defeats=frozenset({("a", "b"), ("b", "a")}), - ) - - result = categoriser_scores(framework, max_iterations=1, tolerance=1e-30) - - assert isinstance(result, RankingResult) - assert result.converged is False - assert { - abstraction, - cardinality_precedence, - counter_transitivity, - defense_precedence, - distributed_defense_precedence, - independence, - quality_precedence, - self_contradiction, - strict_addition_of_defense_branch, - strict_counter_transitivity, - strict_preference_transitive, - void_precedence, - } == { - argumentation.ranking_axioms.abstraction, - argumentation.ranking_axioms.cardinality_precedence, - argumentation.ranking_axioms.counter_transitivity, - argumentation.ranking_axioms.defense_precedence, - argumentation.ranking_axioms.distributed_defense_precedence, - argumentation.ranking_axioms.independence, - argumentation.ranking_axioms.quality_precedence, - argumentation.ranking_axioms.self_contradiction, - argumentation.ranking_axioms.strict_addition_of_defense_branch, - argumentation.ranking_axioms.strict_counter_transitivity, - argumentation.ranking_axioms.strict_preference_transitive, - argumentation.ranking_axioms.void_precedence, - } +from __future__ import annotations + +import importlib + +import pytest + +from argumentation.core.dung import ArgumentationFramework +from argumentation.ranking.ranking import RankingResult, categoriser_scores +from argumentation.ranking.ranking_axioms import ( + abstraction, + cardinality_precedence, + counter_transitivity, + defense_precedence, + distributed_defense_precedence, + independence, + quality_precedence, + self_contradiction, + strict_addition_of_defense_branch, + strict_counter_transitivity, + strict_preference_transitive, + void_precedence, +) + + +def test_workstream_o_arg_vaf_ranking_public_surface_is_closed() -> None: + assert importlib.import_module("argumentation.frameworks.vaf").__name__ == ( + "argumentation.frameworks.vaf" + ) + assert importlib.import_module("argumentation.frameworks.practical_reasoning").__name__ == ( + "argumentation.frameworks.practical_reasoning" + ) + assert importlib.import_module("argumentation.structured.aspic.subjective_aspic").__name__ == ( + "argumentation.structured.aspic.subjective_aspic" + ) + assert importlib.import_module("argumentation.ranking.ranking_axioms").__name__ == ( + "argumentation.ranking.ranking_axioms" + ) + + with pytest.raises(ModuleNotFoundError): + importlib.import_module("argumentation.value_based") + + +def test_workstream_o_arg_vaf_ranking_contracts_are_closed() -> None: + ranking_axioms = importlib.import_module("argumentation.ranking.ranking_axioms") + framework = ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset({("a", "b"), ("b", "a")}), + ) + + result = categoriser_scores(framework, max_iterations=1, tolerance=1e-30) + + assert isinstance(result, RankingResult) + assert result.converged is False + assert { + abstraction, + cardinality_precedence, + counter_transitivity, + defense_precedence, + distributed_defense_precedence, + independence, + quality_precedence, + self_contradiction, + strict_addition_of_defense_branch, + strict_counter_transitivity, + strict_preference_transitive, + void_precedence, + } == { + ranking_axioms.abstraction, + ranking_axioms.cardinality_precedence, + ranking_axioms.counter_transitivity, + ranking_axioms.defense_precedence, + ranking_axioms.distributed_defense_precedence, + ranking_axioms.independence, + ranking_axioms.quality_precedence, + ranking_axioms.self_contradiction, + ranking_axioms.strict_addition_of_defense_branch, + ranking_axioms.strict_counter_transitivity, + ranking_axioms.strict_preference_transitive, + ranking_axioms.void_precedence, + } diff --git a/tools/aba_abcgen_telemetry.py b/tools/aba_abcgen_telemetry.py index 262e326..2c2a35a 100644 --- a/tools/aba_abcgen_telemetry.py +++ b/tools/aba_abcgen_telemetry.py @@ -9,11 +9,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from argumentation.aba_telemetry import ( +from argumentation.structured.aba.aba_telemetry import ( STRUCTURAL_TELEMETRY_KEYS, aba_structural_telemetry, ) -from argumentation.iccma import parse_aba +from argumentation.interop.iccma import parse_aba from tools.iccma2025_run_native import DATA_ROOT, read_instance_text, resolve_instance_path diff --git a/tools/aba_iccma_probe.py b/tools/aba_iccma_probe.py index 15fd578..ceb6f14 100644 --- a/tools/aba_iccma_probe.py +++ b/tools/aba_iccma_probe.py @@ -11,9 +11,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from argumentation import aba_sat -from argumentation.aba_preprocessing import simplify_aba -from argumentation.iccma import parse_aba +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba_preprocessing import simplify_aba +from argumentation.interop.iccma import parse_aba def _run_with_timeout(instance: Path, mode: str, *, timeout_seconds: float) -> dict[str, Any]: diff --git a/tools/aba_shape_benchmark.py b/tools/aba_shape_benchmark.py index 96e4515..d8526e9 100644 --- a/tools/aba_shape_benchmark.py +++ b/tools/aba_shape_benchmark.py @@ -15,13 +15,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from argumentation import aba as native_aba -from argumentation.aba import ABAFramework, AssumptionSet -from argumentation.aba_decomposition import plan_decomposed_prefsat -from argumentation.aba_preprocessing import simplify_aba -from argumentation.aba_route_policy import native_cnf_prefsat_dense_shape -from argumentation.aspic import GroundAtom, Literal -from argumentation.iccma import parse_aba +from argumentation.structured.aba import aba as native_aba +from argumentation.structured.aba.aba import ABAFramework, AssumptionSet +from argumentation.structured.aba.aba_decomposition import plan_decomposed_prefsat +from argumentation.structured.aba.aba_preprocessing import simplify_aba +from argumentation.structured.aba.aba_route_policy import native_cnf_prefsat_dense_shape +from argumentation.structured.aspic.aspic import GroundAtom, Literal +from argumentation.interop.iccma import parse_aba from tools.iccma2025_run_native import TASK_TO_SEMANTICS, run_child as run_native_child @@ -1050,7 +1050,7 @@ def _large_witness_validation( subtrack: str, witness: AssumptionSet, ) -> dict[str, Any]: - from argumentation import aba_sat + from argumentation.structured.aba import aba_sat semantics = solver_class("aba", subtrack).split("/")[-1] if not witness <= framework.assumptions: diff --git a/tools/aba_stable_diagnostics.py b/tools/aba_stable_diagnostics.py index f3dd5a7..2d2ea84 100644 --- a/tools/aba_stable_diagnostics.py +++ b/tools/aba_stable_diagnostics.py @@ -11,10 +11,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from argumentation import aba_sat -from argumentation.aba import ABAFramework -from argumentation.aspic import Literal -from argumentation.iccma import parse_aba +from argumentation.structured.aba import aba_sat +from argumentation.structured.aba.aba import ABAFramework +from argumentation.structured.aspic.aspic import Literal +from argumentation.interop.iccma import parse_aba def stable_diagnostics( diff --git a/tools/iccma2025_run_native.py b/tools/iccma2025_run_native.py index 90669c5..fa72b57 100644 --- a/tools/iccma2025_run_native.py +++ b/tools/iccma2025_run_native.py @@ -757,8 +757,8 @@ def find_query_path(instance_path: Path) -> Path | None: def solve_af_job(job: dict[str, Any]) -> dict[str, Any]: - from argumentation.iccma import parse_af, parse_apx, parse_tgf - from argumentation.solver import ( + from argumentation.interop.iccma import parse_af, parse_apx, parse_tgf + from argumentation.solving.solver import ( AcceptanceSolverSuccess, ICCMAConfig, SATConfig, @@ -915,9 +915,9 @@ def infer_year(instance: dict[str, Any], task: dict[str, Any]) -> str | None: def solve_aba_job(job: dict[str, Any]) -> dict[str, Any]: - from argumentation.aspic import GroundAtom, Literal - from argumentation.iccma import parse_aba - from argumentation.solver import ( + from argumentation.structured.aspic.aspic import GroundAtom, Literal + from argumentation.interop.iccma import parse_aba + from argumentation.solving.solver import ( AcceptanceSolverSuccess, ICCMAConfig, SingleExtensionSolverSuccess, diff --git a/tools/perf_calibrate.py b/tools/perf_calibrate.py index 35037fe..fe1130d 100644 --- a/tools/perf_calibrate.py +++ b/tools/perf_calibrate.py @@ -14,9 +14,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from argumentation.aba import ABAFramework, derives -from argumentation.aspic import GroundAtom, Literal, Rule -from argumentation.iccma import parse_aba, write_aba +from argumentation.structured.aba.aba import ABAFramework, derives +from argumentation.structured.aspic.aspic import GroundAtom, Literal, Rule +from argumentation.interop.iccma import parse_aba, write_aba SCHEMA_VERSION = 1 diff --git a/uv.lock b/uv.lock index bd5e17c..d3f289c 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "clingo" version = "5.8.0" @@ -133,7 +145,7 @@ wheels = [ [[package]] name = "formal-argumentation" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } [package.optional-dependencies] @@ -155,6 +167,7 @@ dev = [ { name = "clingo" }, { name = "gunray" }, { name = "hypothesis" }, + { name = "import-linter" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-timeout" }, @@ -176,6 +189,7 @@ dev = [ { name = "clingo", specifier = ">=5.7" }, { name = "gunray", git = "https://github.com/ctoth/gunray.git" }, { name = "hypothesis", specifier = ">=6.0" }, + { name = "import-linter", specifier = ">=2.0" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-timeout", specifier = ">=2.3" }, @@ -183,6 +197,99 @@ dev = [ { name = "z3-solver", specifier = ">=4.12" }, ] +[[package]] +name = "grimp" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload-time = "2025-12-10T17:55:01.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/31/d4a86207c38954b6c3d859a1fc740a80b04bbe6e3b8a39f4e66f9633dfa4/grimp-3.14-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f1c91e3fa48c2196bf62e3c71492140d227b2bfcd6d15e735cbc0b3e2d5308e0", size = 2185572, upload-time = "2025-12-10T17:53:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/f5/61/ed4cba5bd75d37fe46e17a602f616619a9e4f74ad8adfcf560ce4b2a1697/grimp-3.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6291c8f1690a9fe21b70923c60b075f4a89676541999e3d33084cbc69ac06a1", size = 2118002, upload-time = "2025-12-10T17:53:18.546Z" }, + { url = "https://files.pythonhosted.org/packages/77/6a/688f6144d0b207d7845bd8ab403820a83630ce3c9420cbbc7c9e9282f9c0/grimp-3.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ec312383935c2d09e4085c8435780ada2e13ebef14e105609c2988a02a5b2ce", size = 2283939, upload-time = "2025-12-10T17:52:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/a5/98/4c540de151bf3fd58d6d7b3fe2269b6a6af6c61c915de1bc991802bfaff8/grimp-3.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f43cbf640e73ee703ad91639591046828d20103a1c363a02516e77a66a4ac07", size = 2233693, upload-time = "2025-12-10T17:52:18.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7b/84b4b52b6c6dd5bf083cb1a72945748f56ea2e61768bbebf87e8d9d0ef75/grimp-3.14-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a93c9fddccb9ff16f5c6b5fca44227f5f86cba7cffc145d2176119603d2d7c7", size = 2389745, upload-time = "2025-12-10T17:53:00.659Z" }, + { url = "https://files.pythonhosted.org/packages/a7/33/31b96907c7dd78953df5e1ce67c558bd6057220fa1203d28d52566315a2e/grimp-3.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5653a2769fdc062cb7598d12200352069c9c6559b6643af6ada3639edb98fcc3", size = 2569055, upload-time = "2025-12-10T17:52:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/ce1a8110f3d5b178153b903aafe54b6a9216588b5bff3656e30af43e9c29/grimp-3.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:071c7ddf5e5bb7b2fdf79aefdf6e1c237cd81c095d6d0a19620e777e85bf103c", size = 2358044, upload-time = "2025-12-10T17:52:47.545Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/16d98c02287bc99884843478b9a68b04a2ef13b5cb8b9f36a9ca7daea75b/grimp-3.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e01b7a4419f535b667dfdcb556d3815b52981474f791fb40d72607228389a31", size = 2310304, upload-time = "2025-12-10T17:53:09.679Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/0fde9781b0f6b4f9227d485685f48f6bcc70b95af22e2f85ff7f416cbfc1/grimp-3.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c29682f336151d1d018d0c3aa9eeaa35734b970e4593fa396b901edca7ef5c79", size = 2463682, upload-time = "2025-12-10T17:53:49.185Z" }, + { url = "https://files.pythonhosted.org/packages/51/cb/2baff301c2c2cc2792b6e225ea0784793ca587c81b97572be0bad122cfc8/grimp-3.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a5c4fd71f363ea39e8aab0630010ced77a8de9789f27c0acdd0d7e6269d4a8ef", size = 2500573, upload-time = "2025-12-10T17:54:03.899Z" }, + { url = "https://files.pythonhosted.org/packages/96/69/797e4242f42d6665da5fe22cb250cae3f14ece4cb22ad153e9cd97158179/grimp-3.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766911e3ba0b13d833fdd03ad1f217523a8a2b2527b5507335f71dca1153183d", size = 2503005, upload-time = "2025-12-10T17:54:32.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/45/da1a27a6377807ca427cd56534231f0920e1895e16630204f382a0df14c5/grimp-3.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:154e84a2053e9f858ae48743de23a5ad4eb994007518c29371276f59b8419036", size = 2515776, upload-time = "2025-12-10T17:54:47.962Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/b918a29ce98029cd7a9e33a584be43a93288d5283fb7ccef5b6b2ba39ede/grimp-3.14-cp311-cp311-win32.whl", hash = "sha256:3189c86c3e73016a1907ee3ba9f7a6ca037e3601ad09e60ce9bf12b88877f812", size = 1873189, upload-time = "2025-12-10T17:55:11.872Z" }, + { url = "https://files.pythonhosted.org/packages/90/d7/2327c203f83a25766fbd62b0df3b24230d422b6e53518ff4d1c5e69793f1/grimp-3.14-cp311-cp311-win_amd64.whl", hash = "sha256:201f46a6a4e5ee9dfba4a2f7d043f7deab080d1d84233f4a1aee812678c25307", size = 2014277, upload-time = "2025-12-10T17:55:04.144Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/a35ff62f35aa5fd148053506eddd7a8f2f6afaed31870dc608dd0eb38e4f/grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133", size = 2178573, upload-time = "2025-12-10T17:53:42.836Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/bd2e80273da4d46110969fc62252e5372e0249feb872bc7fe76fdc7f1818/grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2", size = 2110452, upload-time = "2025-12-10T17:53:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/c3/7307249c657d34dca9d250d73ba027d6cfe15a98fb3119b6e5210bc388b7/grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156", size = 2283064, upload-time = "2025-12-10T17:52:07.673Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d2/cae4cf32dc8d4188837cc4ab183300d655f898969b0f169e240f3b7c25be/grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f", size = 2235893, upload-time = "2025-12-10T17:52:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/3f58bc3064fc305dac107d08003ba65713a5bc89a6d327f1c06b30cce752/grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67", size = 2393376, upload-time = "2025-12-10T17:53:02.397Z" }, + { url = "https://files.pythonhosted.org/packages/06/b8/f476f30edf114f04cb58e8ae162cb4daf52bda0ab01919f3b5b7edb98430/grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab", size = 2571342, upload-time = "2025-12-10T17:52:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/2e44d3c4f591f95f86322a8f4dbb5aac17001d49e079f3a80e07e7caaf09/grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb", size = 2359022, upload-time = "2025-12-10T17:52:49.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/42b4d6bc0ea119ce2e91e1788feabf32c5433e9617dbb495c2a3d0dc7f12/grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c", size = 2309424, upload-time = "2025-12-10T17:53:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/6a731989625c1790f4da7602dcbf9d6525512264e853cda77b3b3602d5e0/grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745", size = 2462754, upload-time = "2025-12-10T17:53:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/3d1571c0a39a59dd68be4835f766da64fe64cbab0d69426210b716a8bdf0/grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531", size = 2501356, upload-time = "2025-12-10T17:54:06.014Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/8950b8229095ebda5c54c8784e4d1f0a6e19423f2847289ef9751f878798/grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744", size = 2504631, upload-time = "2025-12-10T17:54:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/23bed3da9206138d36d01890b656c7fb7adfb3a37daac8842d84d8777ade/grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185", size = 2514751, upload-time = "2025-12-10T17:54:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/6f1f55c97ee982f133ec5ccb22fc99bf5335aee70c208f4fb86cd833b8d5/grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06", size = 1875041, upload-time = "2025-12-10T17:55:13.326Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/03ba01288e2a41a948bc8526f32c2eeaddd683ed34be1b895e31658d5a4c/grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3", size = 2013868, upload-time = "2025-12-10T17:55:05.907Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bd/d12a9c821b79ba31fc52243e564712b64140fc6d011c2bdbb483d9092a12/grimp-3.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af8a625554beea84530b98cc471902155b5fc042b42dc47ec846fa3e32b0c615", size = 2178632, upload-time = "2025-12-10T17:53:44.55Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/d6620dbc245149d5a5a7a9342733556ba91a672f358259c0ab31d889b56b/grimp-3.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dd1942ffb419ad342f76b0c3d3d2d7f312b264ddc578179d13ce8d5acec1167", size = 2110288, upload-time = "2025-12-10T17:53:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/60/9d/ea51edc4eb295c99786040051c66466bfa235fd1def9f592057b36e03d0f/grimp-3.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537f784ce9b4acf8657f0b9714ab69a6c72ffa752eccc38a5a85506103b1a194", size = 2282197, upload-time = "2025-12-10T17:52:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/28/6e/7db27818ced6a797f976ca55d981a3af5c12aec6aeda12d63965847cd028/grimp-3.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78ab18c08770aa005bef67b873bc3946d33f65727e9f3e508155093db5fa57d6", size = 2235720, upload-time = "2025-12-10T17:52:21.806Z" }, + { url = "https://files.pythonhosted.org/packages/37/26/0e3bbae4826bd6eaabf404738400414071e73ddb1e65bf487dcce17858c4/grimp-3.14-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ca58728c27e7292c99f964e6ece9295c2f9cfdefc37c18dea0679c783ffb6f", size = 2393023, upload-time = "2025-12-10T17:53:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f2/7da91db5703da34c7ef4c7cddcbb1a8fc30cd85fe54756eba942c6fb27d8/grimp-3.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5577de29c6c5ae6e08d4ca0ac361b45dba323aa145796e6b320a6ea35414b7", size = 2571108, upload-time = "2025-12-10T17:52:36.523Z" }, + { url = "https://files.pythonhosted.org/packages/25/5e/4d6278f18032c7208696edf8be24a4b5f7fad80acc20ffca737344bcecb5/grimp-3.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d7d1f9f42306f455abcec34db877e4887ff15f2777a43491f7ccbd6936c449b", size = 2358531, upload-time = "2025-12-10T17:52:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/24/fb/231c32493161ac82f27af6a56965daefa0ec6030fdaf5b948ddd5d68d000/grimp-3.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39bd5c9b7cef59ee30a05535e9cb4cbf45a3c503f22edce34d0aa79362a311a9", size = 2308831, upload-time = "2025-12-10T17:53:12.587Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/f6db325bf5efbbebc9c85cad0af865e821a12a0ba58ee309e938cbd5fedf/grimp-3.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fec3116b4f780a1bc54176b19e6b9f2e36e2ef3164b8fc840660566af35df88", size = 2462138, upload-time = "2025-12-10T17:53:52.403Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/cc3fe29cf07f70364018086840c228a190539ab8105147e34588db590792/grimp-3.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0233a35a5bbb23688d63e1736b54415fa9994ace8dfeb7de8514ed9dee212968", size = 2501393, upload-time = "2025-12-10T17:54:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/54cada9a726455148da23f64577b5cd164164d23a6449e3fa14551157356/grimp-3.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e46b2fef0f1da7e7e2f8129eb93c7e79db716ff7810140a22ce5504e10ed86df", size = 2504514, upload-time = "2025-12-10T17:54:36.34Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/e6afe4f0652df07e8762f61899d1202b73c22c559c804d0a09e5aab2ff17/grimp-3.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e6d9b50623ee1c3d2a1927ec3f5d408995ea1f92f3e91ed996c908bb40e856f", size = 2514018, upload-time = "2025-12-10T17:54:50.76Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/2b8550acc1f010301f02c4fe9664810929fd9277cd032ab608b8534a96fb/grimp-3.14-cp313-cp313-win32.whl", hash = "sha256:fd57c56f5833c99320ec77e8ba5508d56f6fb48ec8032a942f7931cc6ebb80ce", size = 1874922, upload-time = "2025-12-10T17:55:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/bc9db5a54ef22972cd17d15ad80a8fee274a471bd3f02300405702d29ea5/grimp-3.14-cp313-cp313-win_amd64.whl", hash = "sha256:173307cf881a126fe5120b7bbec7d54384002e3c83dcd8c4df6ce7f0fee07c53", size = 2013705, upload-time = "2025-12-10T17:55:07.488Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/02710bf5e50997168c84ac622b10dd41d35515efd0c67549945ad20996a0/grimp-3.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe29f8f13fbd7c314908ed535183a36e6db71839355b04869b27f23c58fa082", size = 2281868, upload-time = "2025-12-10T17:52:10.589Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/2e440c6762cc78bd50582e1b092357d2255f0852ccc6218d8db25170ab31/grimp-3.14-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d285b00100153fd86064c7726bb1b6d610df1356d33bb42d3fd8809cb6e72", size = 2230917, upload-time = "2025-12-10T17:52:23.212Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bb/2e7dce129b88f07fc525fe5c97f28cfb7ed7b62c59386d39226b4d08969c/grimp-3.14-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6d6efc37e1728bbfcd881b89467be5f7b046292597b3ebe5f8e44e89ea8b6cb", size = 2571371, upload-time = "2025-12-10T17:52:37.84Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2b/8f1be8294af60c953687db7dec25525d87ed9c2aa26b66dcbe5244abaca2/grimp-3.14-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5337d65d81960b712574c41e85b480d4480bbb5c6f547c94e634f6c60d730889", size = 2356980, upload-time = "2025-12-10T17:52:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/ead91e04b3ddd4774ae74601860ea0f0f21bcf6b970b6769ba9571eb2904/grimp-3.14-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:84a7fea63e352b325daa89b0b7297db411b7f0036f8d710c32f8e5090e1fc3ca", size = 2461540, upload-time = "2025-12-10T17:53:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/aa/f8a085ff73c37d6e6a37de9f58799a3fea9e16badf267aaef6f11c9a53a3/grimp-3.14-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d0b19a3726377165fe1f7184a8af317734d80d32b371b6c5578747867ab53c0b", size = 2497925, upload-time = "2025-12-10T17:54:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a3/db3c2d6df07fe74faf5a28fcf3b44fad2831d323ba4a3c2ff66b77a6520c/grimp-3.14-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9caa4991f530750f88474a3f5ecf6ef9f0d064034889d92db00cfb4ecb78aa24", size = 2501794, upload-time = "2025-12-10T17:54:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/095f4e3765e7b60425a41e9fbd2b167f8b0acb957cc88c387f631778a09d/grimp-3.14-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1876efc119b99332a5cc2b08a6bdaada2f0ad94b596f0372a497e2aa8bda4d94", size = 2515203, upload-time = "2025-12-10T17:54:52.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5f/ee02a3a1237282d324f596a50923bf9d2cb1b1230ef2fef49fb4d3563c2c/grimp-3.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3ccf03e65864d6bc7bf1c003c319f5330a7627b3677f31143f11691a088464c2", size = 2177150, upload-time = "2025-12-10T17:53:46.145Z" }, + { url = "https://files.pythonhosted.org/packages/f2/64/2a92889e5fc78e8ef5c548e6a5c6fed78b817eeb0253aca586c28108393a/grimp-3.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ecd58fa58a270e7523f8bec9e6452f4fdb9c21e4cd370640829f1e43fa87a69", size = 2109280, upload-time = "2025-12-10T17:53:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/5d0b9ab54821e7fbdeb02f3919fa2cb8b9f0c3869fa6e4b969a5766f0ffa/grimp-3.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d75d1f8f7944978b39b08d870315174f1ffcd5123be6ccff8ce90467ace648a", size = 2283367, upload-time = "2025-12-10T17:52:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/c2/96/a77c40c92faf7500f42ac019ab8de108b04ffe3db8ec8d6f90416d2322ce/grimp-3.14-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f70bbb1dd6055d08d29e39a78a11c4118c1778b39d17cd8271e18e213524ca7", size = 2237125, upload-time = "2025-12-10T17:52:24.606Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/3e1483721c83057bff921cf454dd5ff3e661ae1d2e63150a380382d116c2/grimp-3.14-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f21b7c003626c902669dc26ede83a91220cf0a81b51b27128370998c2f247b4", size = 2391735, upload-time = "2025-12-10T17:53:05.619Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/25fad4a174fe672d42f3e5616761a8120a3b03c8e9e2ae3f31159561968a/grimp-3.14-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80d9f056415c936b45561310296374c4319b5df0003da802c84d2830a103792a", size = 2571388, upload-time = "2025-12-10T17:52:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/456df7f6a765ce3f160eb32a0f64ed0c1c3cd39b518555dde02087f9b6e4/grimp-3.14-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0332963cd63a45863775d4237e59dedf95455e0a1ea50c356be23100c5fc1d7c", size = 2359637, upload-time = "2025-12-10T17:52:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/3e5005ef21a4e2243f0da489aba86aaaff0bc11d5240d67113482cba88e0/grimp-3.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4144350d074f2058fe7c89230a26b34296b161f085b0471a692cb2fe27036f", size = 2308335, upload-time = "2025-12-10T17:53:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/03/4e055f756946d6f71ab7e9d1f8536a9e476777093dd7a050f40412d1a2b1/grimp-3.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e148e67975e92f90a8435b1b4c02180b9a3f3d725b7a188ba63793f1b1e445a0", size = 2463680, upload-time = "2025-12-10T17:53:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/26/b9/3c76b7c2e1587e4303a6eff6587c2117c3a7efe1b100cd13d8a4a5613572/grimp-3.14-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1093f7770cb5f3ca6f99fb152f9c949381cc0b078dfdfe598c8ab99abaccda3b", size = 2502808, upload-time = "2025-12-10T17:54:25.383Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/ada10b85ad3125ebedea10256d9c568b6bf28339d2f79d2d196a7b94f633/grimp-3.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a213f45ec69e9c2b28ffd3ba5ab12cc9859da17083ba4dc39317f2083b618111", size = 2504013, upload-time = "2025-12-10T17:54:39.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/7c369f749d50b0ceac23cd6874ca4695cc1359a96091c7010301e5c8b619/grimp-3.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f003ac3f226d2437a49af0b6036f26edba57f8a32d329275dbde1b2b2a00a56", size = 2515043, upload-time = "2025-12-10T17:54:54.437Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/85135fe83826ce11ae56a340d32a1391b91eed94d25ce7bc318019f735de/grimp-3.14-cp314-cp314-win32.whl", hash = "sha256:eec81be65a18f4b2af014b1e97296cc9ee20d1115529bf70dd7e06f457eac30b", size = 1877509, upload-time = "2025-12-10T17:55:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/e4a2234edecb3bb3cff8963bc4ec5cc482a9e3c54f8df0946d7d90003830/grimp-3.14-cp314-cp314-win_amd64.whl", hash = "sha256:cd3bab6164f1d5e313678f0ab4bf45955afe7f5bdb0f2f481014aa9cca7e81ba", size = 2014364, upload-time = "2025-12-10T17:55:08.896Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/3d304443fbf1df4d60c09668846d0c8a605c6c95646226e41d8f5c3254da/grimp-3.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1df33de479be4d620f69633d1876858a8e64a79c07907d47cf3aaf896af057", size = 2281385, upload-time = "2025-12-10T17:52:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/493e2648dbb83b3fc517ee675e464beb0154551d726053c7982a3138c6a8/grimp-3.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07096d4402e9d5a2c59c402ea3d601f4b7f99025f5e32f077468846fc8d3821b", size = 2231470, upload-time = "2025-12-10T17:52:26.104Z" }, + { url = "https://files.pythonhosted.org/packages/80/84/e772b302385a6b7ec752c88f84ffe35c33d14076245ae27a635aed9c63a2/grimp-3.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:712bc28f46b354316af50c469c77953ba3d6cb4166a62b8fb086436a8b05d301", size = 2571579, upload-time = "2025-12-10T17:52:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/5b23aa7b89c5f4f2cfa636cbeaf33e784378a6b0a823d77a3448670dfacc/grimp-3.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abe2bbef1cf8e27df636c02f60184319f138dee4f3a949405c21a4b491980397", size = 2356545, upload-time = "2025-12-10T17:52:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/bcf2116f4b1c3939ab35f9cdddd9ca59e953e57e9a0ac0c143deaf9f29cc/grimp-3.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2f9ae3fabb7a7a8468ddc96acc84ecabd84f168e7ca508ee94d8f32ea9bd5de2", size = 2461022, upload-time = "2025-12-10T17:53:56.923Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/1a076dce6bc22bca4b9ad5d1bbcd7e1023dcf7bf20ea9404c6462d78f049/grimp-3.14-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:efaf11ea73f7f12d847c54a5d6edcbe919e0369dce2d1aabae6c50792e16f816", size = 2498256, upload-time = "2025-12-10T17:54:27.214Z" }, + { url = "https://files.pythonhosted.org/packages/45/ea/ac735bed202c1c5c019e611b92d3861779e0cfbe2d20fdb0dec94266d248/grimp-3.14-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e089c9ab8aa755ff5af88c55891727783b4eb6b228e7bdf278e17209d954aa1e", size = 2502056, upload-time = "2025-12-10T17:54:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/80/8f/774ce522de6a7e70fbeceeaeb6fbe502f5dfb8365728fb3bb4cb23463da8/grimp-3.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ad14d5deb56721ac24ab939747f72ab3d378d42e7d1f038317d33b052b77", size = 2515157, upload-time = "2025-12-10T17:54:55.874Z" }, + { url = "https://files.pythonhosted.org/packages/65/cc/dbc00210d0324b8fc1242d8e857757c7e0b62ff0fc0c1bc8dcc42342da85/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c8a8aab9b4310a7e69d7d845cac21cf14563aa0520ea322b948eadeae56d303", size = 2284804, upload-time = "2025-12-10T17:52:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/80/89/851d3d345342e9bcec3fe85d3997db29501fa59f958c1566bf3e24d9d7d9/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d781943b27e5875a41c8f9cfc80f8f0a349f864379192b8c3faa0e6a22593313", size = 2235176, upload-time = "2025-12-10T17:52:30.795Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/5f94702a8d5c121cafcdc9664de34c34f19d0d91a1127bf3946a2631f7a3/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9630d4633607aff94d0ac84b9c64fef1382cdb05b00d9acbde47f8745e264871", size = 2391258, upload-time = "2025-12-10T17:53:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a2/df8c79de5c9e227856d048cc1551c4742a5f97660c40304ac278bd48607f/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cb00e1bcca583668554a8e9e1e4229a1d11b0620969310aae40148829ff6a32", size = 2571443, upload-time = "2025-12-10T17:52:43.853Z" }, + { url = "https://files.pythonhosted.org/packages/f0/21/747b7ed9572bbdc34a76dfec12ce510e80164b1aa06d3b21b34994e5f567/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3389da4ceaaa7f7de24a668c0afc307a9f95997bd90f81ec359a828a9bd1d270", size = 2357767, upload-time = "2025-12-10T17:52:57.84Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e6/485c5e3b64933e71f72f0cc45b0d7130418a6a5a13cedc2e8411bd76f290/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd7a32970ef97e42d4e7369397c7795287d84a736d788ccb90b6c14f0561d975", size = 2309069, upload-time = "2025-12-10T17:53:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/31/bd/12024a8cba1c77facc1422a7b48cd0d04c252fc9178fd6f99dc05a8af57b/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:fd1278623fa09f62abc0fd8a6500f31b421a1fd479980f44c2926020a0becf02", size = 2466429, upload-time = "2025-12-10T17:54:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7f/0e5977887e1c8f00f84bb4125217534806ffdcef9cf52f3580aa3b151f4b/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:9cfa52c89333d3d8fe9dc782529e888270d060231c3783e036d424044671dde0", size = 2501190, upload-time = "2025-12-10T17:54:30.107Z" }, + { url = "https://files.pythonhosted.org/packages/42/6b/06acb94b6d0d8c7277bb3e33f93224aa3be5b04643f853479d3bf7b23ace/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:48a5be4a12fca6587e6885b4fc13b9e242ab8bf874519292f0f13814aecf52cc", size = 2503440, upload-time = "2025-12-10T17:54:44.444Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4d/2e531370d12e7a564f67f680234710bbc08554238a54991cd244feb61fb6/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3fcc332466783a12a42cd317fd344c30fe734ba4fa2362efff132dc3f8d36da7", size = 2516525, upload-time = "2025-12-10T17:54:58.987Z" }, +] + [[package]] name = "gunray" version = "0.1.0" @@ -200,6 +307,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" }, ] +[[package]] +name = "import-linter" +version = "2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -209,6 +331,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -340,6 +483,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/12/4f31dbc771a3cb5708c2bd21a714bbe3c48444be1337f5fd696b01df0a50/python_sat-1.9.dev2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c3d8f6522dcb3cc991419b5a29c3e08bdbea4ed4889b8646dd24897b8e0c2cff", size = 4108440, upload-time = "2026-03-05T07:34:47.679Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "six" version = "1.17.0"