aspalchemy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aspalchemy/__init__.py +171 -0
- aspalchemy/aggregates.py +230 -0
- aspalchemy/choice.py +281 -0
- aspalchemy/clingo_handler.py +193 -0
- aspalchemy/conditional_literal.py +94 -0
- aspalchemy/conditioned_element.py +157 -0
- aspalchemy/core.py +1645 -0
- aspalchemy/exceptions.py +67 -0
- aspalchemy/operators.py +92 -0
- aspalchemy/optimization.py +265 -0
- aspalchemy/predicate.py +758 -0
- aspalchemy/program_elements.py +414 -0
- aspalchemy/py.typed +0 -0
- aspalchemy/scoping.py +612 -0
- aspalchemy/segment.py +609 -0
- aspalchemy/solve_result.py +1056 -0
- aspalchemy/solver.py +1950 -0
- aspalchemy/source_location.py +176 -0
- aspalchemy/statistics.py +233 -0
- aspalchemy/version.py +12 -0
- aspalchemy-1.0.0.dist-info/METADATA +188 -0
- aspalchemy-1.0.0.dist-info/RECORD +24 -0
- aspalchemy-1.0.0.dist-info/WHEEL +4 -0
- aspalchemy-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
aspalchemy/__init__.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASPAlchemy: A Python library for building clingo ASP (Answer Set Programming) programs
|
|
3
|
+
with a clean, object-oriented interface.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .aggregates import (
|
|
7
|
+
Aggregate,
|
|
8
|
+
Count,
|
|
9
|
+
Max,
|
|
10
|
+
Min,
|
|
11
|
+
Sum,
|
|
12
|
+
SumPlus,
|
|
13
|
+
)
|
|
14
|
+
from .choice import CardinalityType, Choice
|
|
15
|
+
from .clingo_handler import ClingoMessage, LogLevel
|
|
16
|
+
from .conditional_literal import ConditionalLiteral
|
|
17
|
+
from .conditioned_element import ConditionedElement, ConditionType
|
|
18
|
+
from .core import (
|
|
19
|
+
ANY,
|
|
20
|
+
INF,
|
|
21
|
+
SUP,
|
|
22
|
+
BasicTerm,
|
|
23
|
+
ComparableTerm,
|
|
24
|
+
Comparison,
|
|
25
|
+
Compl,
|
|
26
|
+
ConstantBase,
|
|
27
|
+
DefaultNegation,
|
|
28
|
+
DefinedConstant,
|
|
29
|
+
ExplicitPool,
|
|
30
|
+
Expression,
|
|
31
|
+
Infimum,
|
|
32
|
+
Negatable,
|
|
33
|
+
Not,
|
|
34
|
+
Number,
|
|
35
|
+
Pool,
|
|
36
|
+
PredicateOccurrence,
|
|
37
|
+
RangePool,
|
|
38
|
+
RenderingContext,
|
|
39
|
+
String,
|
|
40
|
+
Supremum,
|
|
41
|
+
Term,
|
|
42
|
+
V,
|
|
43
|
+
Value,
|
|
44
|
+
Variable,
|
|
45
|
+
Vars,
|
|
46
|
+
pool,
|
|
47
|
+
)
|
|
48
|
+
from .exceptions import ASPAlchemyError, GroundingError, UnsatisfiableError
|
|
49
|
+
from .operators import ComparisonOperator, Operation
|
|
50
|
+
from .optimization import OptStrategy
|
|
51
|
+
from .predicate import Field, FieldAsTermType, NegatedSignature, Predicate, PredicateField, TupleTermType
|
|
52
|
+
from .program_elements import ProgramElement, RenderedLine
|
|
53
|
+
from .segment import Segment, When
|
|
54
|
+
from .solve_result import (
|
|
55
|
+
AtomCollection,
|
|
56
|
+
BraveConsequences,
|
|
57
|
+
CautiousConsequences,
|
|
58
|
+
Consequences,
|
|
59
|
+
CostedModel,
|
|
60
|
+
Model,
|
|
61
|
+
OptimizeSteps,
|
|
62
|
+
Optimum,
|
|
63
|
+
PredicateTypes,
|
|
64
|
+
RefinementSteps,
|
|
65
|
+
Search,
|
|
66
|
+
SolveResult,
|
|
67
|
+
convert_predicate_to_symbol,
|
|
68
|
+
convert_symbol_to_predicate,
|
|
69
|
+
)
|
|
70
|
+
from .solver import ASPProgram, GroundedProgram, SignatureGrounding
|
|
71
|
+
from .source_location import (
|
|
72
|
+
SourceLocation,
|
|
73
|
+
attribute_to_caller,
|
|
74
|
+
capture_location,
|
|
75
|
+
location_override,
|
|
76
|
+
register_skip_package,
|
|
77
|
+
)
|
|
78
|
+
from .version import __version__
|
|
79
|
+
|
|
80
|
+
__all__ = [ # noqa: RUF022 (categorized deliberately, not sorted)
|
|
81
|
+
# The program and its results
|
|
82
|
+
"ASPProgram",
|
|
83
|
+
"Segment",
|
|
84
|
+
"When",
|
|
85
|
+
"GroundedProgram",
|
|
86
|
+
"SignatureGrounding",
|
|
87
|
+
"SolveResult",
|
|
88
|
+
"Model",
|
|
89
|
+
"CostedModel",
|
|
90
|
+
"AtomCollection",
|
|
91
|
+
"Consequences",
|
|
92
|
+
"BraveConsequences",
|
|
93
|
+
"CautiousConsequences",
|
|
94
|
+
"RefinementSteps",
|
|
95
|
+
"OptimizeSteps",
|
|
96
|
+
"Optimum",
|
|
97
|
+
"OptStrategy",
|
|
98
|
+
"Search",
|
|
99
|
+
"LogLevel",
|
|
100
|
+
"ClingoMessage",
|
|
101
|
+
"ASPAlchemyError",
|
|
102
|
+
"GroundingError",
|
|
103
|
+
"UnsatisfiableError",
|
|
104
|
+
"RenderedLine",
|
|
105
|
+
# Source locations (diagnostics point at the authoring Python line)
|
|
106
|
+
"SourceLocation",
|
|
107
|
+
"capture_location",
|
|
108
|
+
"register_skip_package",
|
|
109
|
+
"attribute_to_caller",
|
|
110
|
+
"location_override",
|
|
111
|
+
# Declaring predicates
|
|
112
|
+
"Predicate",
|
|
113
|
+
"NegatedSignature",
|
|
114
|
+
"Field",
|
|
115
|
+
"PredicateField",
|
|
116
|
+
# Rule-building objects
|
|
117
|
+
"Variable",
|
|
118
|
+
"ANY",
|
|
119
|
+
"V",
|
|
120
|
+
"Vars",
|
|
121
|
+
"Choice",
|
|
122
|
+
"ConditionalLiteral",
|
|
123
|
+
"RangePool",
|
|
124
|
+
"ExplicitPool",
|
|
125
|
+
"SUP",
|
|
126
|
+
"INF",
|
|
127
|
+
# Aggregates
|
|
128
|
+
"Count",
|
|
129
|
+
"Sum",
|
|
130
|
+
"SumPlus",
|
|
131
|
+
"Min",
|
|
132
|
+
"Max",
|
|
133
|
+
# Rule-building utilities
|
|
134
|
+
"pool",
|
|
135
|
+
"Not",
|
|
136
|
+
"Compl",
|
|
137
|
+
# Hierarchy types: these appear in public signatures and return types —
|
|
138
|
+
# annotate with them; you rarely construct them directly
|
|
139
|
+
"ConditionType",
|
|
140
|
+
"ConditionedElement",
|
|
141
|
+
"TupleTermType",
|
|
142
|
+
"CardinalityType",
|
|
143
|
+
"PredicateTypes",
|
|
144
|
+
"FieldAsTermType",
|
|
145
|
+
"PredicateOccurrence",
|
|
146
|
+
"Term",
|
|
147
|
+
"BasicTerm",
|
|
148
|
+
"Value",
|
|
149
|
+
"ConstantBase",
|
|
150
|
+
"ComparableTerm",
|
|
151
|
+
"Negatable",
|
|
152
|
+
"ProgramElement",
|
|
153
|
+
"Expression",
|
|
154
|
+
"Comparison",
|
|
155
|
+
"ComparisonOperator",
|
|
156
|
+
"Operation",
|
|
157
|
+
"RenderingContext",
|
|
158
|
+
"DefaultNegation",
|
|
159
|
+
"DefinedConstant",
|
|
160
|
+
"Aggregate",
|
|
161
|
+
"Pool",
|
|
162
|
+
"Number",
|
|
163
|
+
"String",
|
|
164
|
+
"Supremum",
|
|
165
|
+
"Infimum",
|
|
166
|
+
# Interop with raw clingo symbols
|
|
167
|
+
"convert_predicate_to_symbol",
|
|
168
|
+
"convert_symbol_to_predicate",
|
|
169
|
+
# Metadata
|
|
170
|
+
"__version__",
|
|
171
|
+
]
|
aspalchemy/aggregates.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import ClassVar, Self
|
|
4
|
+
|
|
5
|
+
from aspalchemy.conditioned_element import ConditionedElement, ConditionType, FreezableBuilder
|
|
6
|
+
from aspalchemy.core import (
|
|
7
|
+
AggregateBase,
|
|
8
|
+
ExtremeConstant,
|
|
9
|
+
PredicateOccurrence,
|
|
10
|
+
RenderingContext,
|
|
11
|
+
String,
|
|
12
|
+
)
|
|
13
|
+
from aspalchemy.predicate import Predicate, TupleTermType, coerce_tuple_term
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AggregateType(StrEnum):
|
|
17
|
+
"""Enum for the different types of aggregate functions available in ASP."""
|
|
18
|
+
|
|
19
|
+
COUNT = "#count"
|
|
20
|
+
SUM = "#sum"
|
|
21
|
+
SUM_PLUS = "#sum+"
|
|
22
|
+
MIN = "#min"
|
|
23
|
+
MAX = "#max"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Aggregate(FreezableBuilder, AggregateBase, ABC):
|
|
27
|
+
"""
|
|
28
|
+
Abstract base class for aggregates in ASP programs.
|
|
29
|
+
|
|
30
|
+
Aggregates calculate values over sets of elements, used in expressions like
|
|
31
|
+
#count{ X : p(X) } = 3 or #sum{ W,X : p(X,W) } > 10.
|
|
32
|
+
|
|
33
|
+
An aggregate is a mutable builder until a rule captures it, which
|
|
34
|
+
freezes it. A frozen aggregate is a value: build once, use in as many
|
|
35
|
+
rules as you like (capture may be transitive, through a comparison
|
|
36
|
+
holding the aggregate). Only mutation is fenced (it would silently
|
|
37
|
+
rewrite every rule that holds the builder).
|
|
38
|
+
|
|
39
|
+
BANDED tests (count between 2 and 4): write two comparisons over the
|
|
40
|
+
aggregate — Count(...) >= 2, Count(...) <= 4 — which grounds to the
|
|
41
|
+
same solver input as gringo's two-guard interval literal (verified at
|
|
42
|
+
the aspif level; the rendered text repeats the elements, the ground
|
|
43
|
+
representation does not). Do NOT bind-then-compare
|
|
44
|
+
(N == Count(...), N >= 2, N <= 4) when the band is wide: gringo
|
|
45
|
+
grounds one aggregate body per feasible N, multiplying the solver
|
|
46
|
+
input by the band width.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Set by subclasses to specify which aggregate function to use
|
|
50
|
+
_AGGREGATE_TYPE: ClassVar[AggregateType]
|
|
51
|
+
|
|
52
|
+
_RECEIPT_NOUN = "aggregate"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
element: TupleTermType | tuple[TupleTermType, ...],
|
|
57
|
+
condition: ConditionType | list[ConditionType] | None = None,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Create an aggregate with an initial element; see add() for further elements.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
element: The value, predicate, or tuple to be aggregated
|
|
64
|
+
condition: Condition(s) determining when the element is included
|
|
65
|
+
If None, it's an unconditional element
|
|
66
|
+
"""
|
|
67
|
+
self._elements: list[ConditionedElement] = []
|
|
68
|
+
self.add(element, condition)
|
|
69
|
+
|
|
70
|
+
def add(
|
|
71
|
+
self,
|
|
72
|
+
element: TupleTermType | tuple[TupleTermType, ...],
|
|
73
|
+
condition: ConditionType | list[ConditionType] | None = None,
|
|
74
|
+
) -> Self:
|
|
75
|
+
"""
|
|
76
|
+
Add an element with optional condition(s); returns self for chaining.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
element: The value, predicate, or tuple to be aggregated
|
|
80
|
+
condition: Condition(s) determining when the element is included
|
|
81
|
+
If None, it's an unconditional element
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> from aspalchemy import Variable
|
|
85
|
+
>>> X, Y, Z, W = (Variable(n) for n in "XYZW")
|
|
86
|
+
>>> p, q, r = (Predicate.define(name, ["x"]) for name in "pqr")
|
|
87
|
+
>>> Count(X).add(Y, p(x=Y)).add((Z, W), [q(x=Z), r(x=W)]).render()
|
|
88
|
+
'#count{ X; Y : p(Y); Z, W : q(Z), r(W) }'
|
|
89
|
+
"""
|
|
90
|
+
self._require_mutable()
|
|
91
|
+
raw_tuple = element if isinstance(element, tuple) else (element,)
|
|
92
|
+
if not raw_tuple:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"An aggregate element tuple cannot be empty: gringo ignores the element "
|
|
95
|
+
f"(#min/#max) or counts the bare empty tuple (#count gives 0/1, an existence "
|
|
96
|
+
f"test) — neither is a {self._AGGREGATE_TYPE.value} over anything. Aggregate "
|
|
97
|
+
f"over a term, e.g. {self._AGGREGATE_TYPE.value}{{ X : p(X) }}."
|
|
98
|
+
)
|
|
99
|
+
element_tuple = tuple(coerce_tuple_term(item, "Aggregate") for item in raw_tuple)
|
|
100
|
+
|
|
101
|
+
# Per-class: #sum/#sum+ SUM the first tuple term, so a literal String
|
|
102
|
+
# (or #sup/#inf) there draws gringo's "tuple ignored" info at ground —
|
|
103
|
+
# weights are integer-valued. Min/Max/Count order terms and take
|
|
104
|
+
# strings (and the ordering's end markers) legally.
|
|
105
|
+
if self._AGGREGATE_TYPE in (AggregateType.SUM, AggregateType.SUM_PLUS):
|
|
106
|
+
if isinstance(element_tuple[0], String):
|
|
107
|
+
raise TypeError(
|
|
108
|
+
f"{self._AGGREGATE_TYPE.value} weights are integer-valued; the first tuple term "
|
|
109
|
+
f"{element_tuple[0].render()} is a String, which gringo silently ignores. For "
|
|
110
|
+
f"term-ordered aggregation over strings use Min/Max, or Count for cardinality."
|
|
111
|
+
)
|
|
112
|
+
if isinstance(element_tuple[0], ExtremeConstant):
|
|
113
|
+
raise TypeError(
|
|
114
|
+
f"{self._AGGREGATE_TYPE.value} weights are integer-valued, got "
|
|
115
|
+
f"{element_tuple[0].render()} as the first tuple term."
|
|
116
|
+
)
|
|
117
|
+
if isinstance(element_tuple[0], Predicate):
|
|
118
|
+
raise TypeError(
|
|
119
|
+
f"{self._AGGREGATE_TYPE.value} weights are integer-valued; the first tuple "
|
|
120
|
+
f"term {element_tuple[0].render()} is a predicate, which gringo silently "
|
|
121
|
+
f"ignores (tuple ignored). Lead the tuple with the weight."
|
|
122
|
+
)
|
|
123
|
+
self._elements.append(ConditionedElement(element_tuple, condition, "aggregate"))
|
|
124
|
+
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def elements(self) -> list[ConditionedElement]:
|
|
129
|
+
"""The elements of this aggregate (a defensive copy of the list)."""
|
|
130
|
+
return self._elements.copy()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_grounded(self) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Grounded means NO variables anywhere, construct-local ones included:
|
|
136
|
+
#count{ X : p(X) } reports False even though X is local in ASP
|
|
137
|
+
semantics — the same strict reading every Term uses.
|
|
138
|
+
"""
|
|
139
|
+
return all(element.is_grounded for element in self._elements)
|
|
140
|
+
|
|
141
|
+
def render(self, context: RenderingContext = RenderingContext.DEFAULT) -> str:
|
|
142
|
+
elements_str = "; ".join(element.render() for element in self._elements)
|
|
143
|
+
|
|
144
|
+
return f"{self._AGGREGATE_TYPE.value}{{ {elements_str} }}"
|
|
145
|
+
|
|
146
|
+
def __str__(self) -> str:
|
|
147
|
+
return self.render()
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"{type(self).__name__}({self.render()!r})"
|
|
151
|
+
|
|
152
|
+
def validate_in_context(self, is_in_head: bool) -> None:
|
|
153
|
+
"""Aggregates are only valid inside comparisons: always raises."""
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Aggregates must be used in comparisons (e.g., #count{ ... } > 0) "
|
|
156
|
+
"and cannot appear directly in rule heads or bodies"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def collect_defined_constants(self) -> set[str]:
|
|
160
|
+
constants: set[str] = set()
|
|
161
|
+
|
|
162
|
+
for element in self._elements:
|
|
163
|
+
constants.update(element.collect_defined_constants())
|
|
164
|
+
|
|
165
|
+
return constants
|
|
166
|
+
|
|
167
|
+
def collect_variables(self) -> set[str]:
|
|
168
|
+
variables: set[str] = set()
|
|
169
|
+
|
|
170
|
+
for element in self._elements:
|
|
171
|
+
variables.update(element.collect_variables())
|
|
172
|
+
|
|
173
|
+
return variables
|
|
174
|
+
|
|
175
|
+
def collect_predicate_occurrences(self, *, as_argument: bool) -> set[PredicateOccurrence]:
|
|
176
|
+
# Tuple terms sit in argument positions (data); conditions hold real
|
|
177
|
+
# atoms in the aggregate's own position
|
|
178
|
+
occurrences: set[PredicateOccurrence] = set()
|
|
179
|
+
for element in self._elements:
|
|
180
|
+
for target in element.targets:
|
|
181
|
+
occurrences.update(target.collect_predicate_occurrences(as_argument=True))
|
|
182
|
+
for condition in element.conditions:
|
|
183
|
+
occurrences.update(condition.collect_predicate_occurrences(as_argument=as_argument))
|
|
184
|
+
return occurrences
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Count(Aggregate):
|
|
188
|
+
"""#count: the number of distinct matching tuples, e.g. #count{ X : p(X) } = 3."""
|
|
189
|
+
|
|
190
|
+
_AGGREGATE_TYPE = AggregateType.COUNT
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Sum(Aggregate):
|
|
194
|
+
"""
|
|
195
|
+
#sum: the sum of weights over distinct matching tuples, e.g. #sum{ W,X : p(X,W) } > 10.
|
|
196
|
+
|
|
197
|
+
The first element in each tuple is the weight.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
_AGGREGATE_TYPE = AggregateType.SUM
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class SumPlus(Aggregate):
|
|
204
|
+
"""
|
|
205
|
+
#sum+: like Sum, but negative weights are treated as zero.
|
|
206
|
+
|
|
207
|
+
The first element in each tuple is the weight.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
_AGGREGATE_TYPE = AggregateType.SUM_PLUS
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Min(Aggregate):
|
|
214
|
+
"""
|
|
215
|
+
#min: the minimum value over distinct matching tuples, e.g. #min{ W,X : p(X,W) } < 5.
|
|
216
|
+
|
|
217
|
+
The first element in each tuple is the value.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
_AGGREGATE_TYPE = AggregateType.MIN
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Max(Aggregate):
|
|
224
|
+
"""
|
|
225
|
+
#max: the maximum value over distinct matching tuples, e.g. #max{ W,X : p(X,W) } < 100.
|
|
226
|
+
|
|
227
|
+
The first element in each tuple is the value.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
_AGGREGATE_TYPE = AggregateType.MAX
|
aspalchemy/choice.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from aspalchemy.conditioned_element import ConditionedElement, ConditionType, FreezableBuilder
|
|
4
|
+
from aspalchemy.core import (
|
|
5
|
+
Expression,
|
|
6
|
+
ExtremeConstant,
|
|
7
|
+
Number,
|
|
8
|
+
PredicateOccurrence,
|
|
9
|
+
RenderingContext,
|
|
10
|
+
String,
|
|
11
|
+
Term,
|
|
12
|
+
Value,
|
|
13
|
+
Variable,
|
|
14
|
+
negated_literal_value,
|
|
15
|
+
)
|
|
16
|
+
from aspalchemy.predicate import Predicate
|
|
17
|
+
|
|
18
|
+
type CardinalityType = int | Value | Expression
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Choice(FreezableBuilder, Term):
|
|
22
|
+
"""
|
|
23
|
+
Represents a choice rule in ASP programs.
|
|
24
|
+
|
|
25
|
+
Choice rules in ASP allow for specifying sets of atoms from which some
|
|
26
|
+
subset can be chosen to be true, optionally with cardinality constraints.
|
|
27
|
+
|
|
28
|
+
Examples in ASP syntax:
|
|
29
|
+
- { p(X) : q(X) }
|
|
30
|
+
- 2 { p(X) : q(X) } 4
|
|
31
|
+
- { p(X) : q(X) } = 3
|
|
32
|
+
|
|
33
|
+
A Choice is a mutable builder until a rule captures it, which freezes
|
|
34
|
+
it. A frozen Choice is a value: further rules may capture it too — the
|
|
35
|
+
same choice under different bodies — and it renders identically in
|
|
36
|
+
each. Only mutation is fenced (it would silently rewrite every rule
|
|
37
|
+
that holds the builder).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_RECEIPT_NOUN = "Choice"
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
element: Predicate,
|
|
45
|
+
condition: ConditionType | list[ConditionType] | None = None,
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Create a choice rule with an initial element; see add() for further elements.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
element: The predicate that can be chosen
|
|
52
|
+
condition: Condition(s) determining when the element is considered
|
|
53
|
+
If None, it's an unconditional choice
|
|
54
|
+
"""
|
|
55
|
+
self._elements: list[ConditionedElement] = []
|
|
56
|
+
self._min_cardinality: None | Value | Expression = None
|
|
57
|
+
self._max_cardinality: None | Value | Expression = None
|
|
58
|
+
|
|
59
|
+
self.add(element, condition)
|
|
60
|
+
|
|
61
|
+
def add(
|
|
62
|
+
self,
|
|
63
|
+
element: Predicate,
|
|
64
|
+
condition: ConditionType | list[ConditionType] | None = None,
|
|
65
|
+
) -> Self:
|
|
66
|
+
"""
|
|
67
|
+
Add another element with optional condition(s); returns self for chaining.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
element: The predicate that can be chosen
|
|
71
|
+
condition: Condition(s) determining when the element is considered
|
|
72
|
+
If None, it's an unconditional choice
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> from aspalchemy import Predicate, Variable
|
|
76
|
+
>>> X = Variable("X")
|
|
77
|
+
>>> p, q, r, s, t, u = (Predicate.define(name, ["x"]) for name in "pqrstu")
|
|
78
|
+
>>> Choice(p(x=X)).add(q(x=X), r(x=X)).add(s(x=X), [t(x=X), u(x=X)]).render()
|
|
79
|
+
'{ p(X); q(X) : r(X); s(X) : t(X), u(X) }'
|
|
80
|
+
"""
|
|
81
|
+
self._require_mutable()
|
|
82
|
+
if not isinstance(element, Predicate):
|
|
83
|
+
raise TypeError(f"Choice element must be a Predicate, got {type(element).__name__}")
|
|
84
|
+
|
|
85
|
+
self._elements.append(ConditionedElement((element,), condition, "choice"))
|
|
86
|
+
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _validate_cardinality(count: int | Value | Expression, description: str) -> Value | Expression:
|
|
91
|
+
"""Validate a cardinality value, coercing ints to Number; raises on bad type or negative int."""
|
|
92
|
+
if not isinstance(count, (int, Value, Expression)):
|
|
93
|
+
raise TypeError(f"{description} must be an integer, Value, or Expression, got {type(count).__name__}")
|
|
94
|
+
|
|
95
|
+
# Reject string constants which don't make sense for cardinality
|
|
96
|
+
if isinstance(count, String):
|
|
97
|
+
raise TypeError(f"{description} cannot be a String")
|
|
98
|
+
if isinstance(count, Variable) and count.is_anonymous:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"{description} cannot be '_': an anonymous bound binds nothing, and "
|
|
101
|
+
f"gringo rejects the rule as unsafe. Use a named Variable bound in the body."
|
|
102
|
+
)
|
|
103
|
+
if isinstance(count, ExtremeConstant):
|
|
104
|
+
raise TypeError(f"{description} must be integer-valued, got {count.render()}")
|
|
105
|
+
|
|
106
|
+
count = Number(count) if isinstance(count, int) else count
|
|
107
|
+
# Checked after coercion so Number(-1) is caught the same as -1;
|
|
108
|
+
# the literal spelling -Number(1) is caught the same way
|
|
109
|
+
if isinstance(count, Number) and count.value < 0:
|
|
110
|
+
raise ValueError(f"{description} must be non-negative, got {count.value}")
|
|
111
|
+
if (folded := negated_literal_value(count)) is not None and folded < 0:
|
|
112
|
+
raise ValueError(f"{description} must be non-negative, got {folded}")
|
|
113
|
+
|
|
114
|
+
return count
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _check_cardinality_possible(minimum: Value | Expression | None, maximum: Value | Expression | None) -> None:
|
|
118
|
+
"""Reject statically impossible bounds; renders fine but is silently UNSAT."""
|
|
119
|
+
if isinstance(minimum, Number) and isinstance(maximum, Number) and minimum.value > maximum.value:
|
|
120
|
+
raise ValueError(f"Choice cardinality is impossible: at_least({minimum.value}) > at_most({maximum.value})")
|
|
121
|
+
|
|
122
|
+
def exactly(self, count: CardinalityType) -> Self:
|
|
123
|
+
"""
|
|
124
|
+
Set the exact cardinality (min = max = count); returns self for chaining.
|
|
125
|
+
|
|
126
|
+
Raises ValueError if cardinality constraints are already set.
|
|
127
|
+
"""
|
|
128
|
+
self._require_mutable()
|
|
129
|
+
count = self._validate_cardinality(count, "Exact cardinality")
|
|
130
|
+
|
|
131
|
+
if self._min_cardinality is not None or self._max_cardinality is not None:
|
|
132
|
+
raise ValueError("Cardinality constraints are already set")
|
|
133
|
+
|
|
134
|
+
self._min_cardinality = count
|
|
135
|
+
self._max_cardinality = count
|
|
136
|
+
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def at_least(self, count: CardinalityType) -> Self:
|
|
140
|
+
"""
|
|
141
|
+
Set the minimum cardinality; returns self for chaining.
|
|
142
|
+
|
|
143
|
+
Raises ValueError if minimum cardinality is already set.
|
|
144
|
+
"""
|
|
145
|
+
self._require_mutable()
|
|
146
|
+
count = self._validate_cardinality(count, "Minimum cardinality")
|
|
147
|
+
|
|
148
|
+
if self._min_cardinality is not None:
|
|
149
|
+
raise ValueError("Minimum cardinality is already set")
|
|
150
|
+
self._check_cardinality_possible(minimum=count, maximum=self._max_cardinality)
|
|
151
|
+
|
|
152
|
+
self._min_cardinality = count
|
|
153
|
+
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def at_most(self, count: CardinalityType) -> Self:
|
|
157
|
+
"""
|
|
158
|
+
Set the maximum cardinality; returns self for chaining.
|
|
159
|
+
|
|
160
|
+
Raises ValueError if maximum cardinality is already set.
|
|
161
|
+
"""
|
|
162
|
+
self._require_mutable()
|
|
163
|
+
count = self._validate_cardinality(count, "Maximum cardinality")
|
|
164
|
+
|
|
165
|
+
if self._max_cardinality is not None:
|
|
166
|
+
raise ValueError("Maximum cardinality is already set")
|
|
167
|
+
self._check_cardinality_possible(minimum=self._min_cardinality, maximum=count)
|
|
168
|
+
|
|
169
|
+
self._max_cardinality = count
|
|
170
|
+
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def elements(self) -> list[ConditionedElement]:
|
|
175
|
+
"""The elements of this choice rule (a defensive copy of the list)."""
|
|
176
|
+
return self._elements.copy()
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def min_cardinality(self) -> Value | Expression | None:
|
|
180
|
+
"""The minimum cardinality constraint, or None if not set."""
|
|
181
|
+
return self._min_cardinality
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def max_cardinality(self) -> Value | Expression | None:
|
|
185
|
+
"""The maximum cardinality constraint, or None if not set."""
|
|
186
|
+
return self._max_cardinality
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def is_grounded(self) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Grounded means NO variables anywhere, construct-local ones included:
|
|
192
|
+
{X : p(X)} reports False even though X is local in ASP semantics —
|
|
193
|
+
the same strict reading every Term uses.
|
|
194
|
+
"""
|
|
195
|
+
for element in self._elements:
|
|
196
|
+
if not element.is_grounded:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
if self.min_cardinality and not self.min_cardinality.is_grounded:
|
|
200
|
+
return False
|
|
201
|
+
if self.max_cardinality and not self.max_cardinality.is_grounded: # noqa: SIM103
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def render(self, context: RenderingContext = RenderingContext.DEFAULT) -> str:
|
|
207
|
+
prefix = ""
|
|
208
|
+
suffix = ""
|
|
209
|
+
|
|
210
|
+
if self.min_cardinality and self.max_cardinality:
|
|
211
|
+
min_str = self.min_cardinality.render()
|
|
212
|
+
max_str = self.max_cardinality.render()
|
|
213
|
+
|
|
214
|
+
if min_str == max_str:
|
|
215
|
+
# Exact cardinality: { ... } = n
|
|
216
|
+
suffix = f" = {min_str}"
|
|
217
|
+
else:
|
|
218
|
+
# Range cardinality: n { ... } m
|
|
219
|
+
prefix = f"{min_str} "
|
|
220
|
+
suffix = f" {max_str}"
|
|
221
|
+
elif self.min_cardinality:
|
|
222
|
+
# Only minimum: n { ... }
|
|
223
|
+
min_str = self.min_cardinality.render()
|
|
224
|
+
prefix = f"{min_str} "
|
|
225
|
+
elif self.max_cardinality:
|
|
226
|
+
# Only maximum: { ... } m
|
|
227
|
+
max_str = self.max_cardinality.render()
|
|
228
|
+
suffix = f" {max_str}"
|
|
229
|
+
|
|
230
|
+
elements_str = "; ".join(element.render() for element in self._elements)
|
|
231
|
+
|
|
232
|
+
return f"{prefix}{{ {elements_str} }}{suffix}"
|
|
233
|
+
|
|
234
|
+
def __str__(self) -> str:
|
|
235
|
+
return self.render()
|
|
236
|
+
|
|
237
|
+
def __repr__(self) -> str:
|
|
238
|
+
return f"{type(self).__name__}({self.render()!r})"
|
|
239
|
+
|
|
240
|
+
def validate_in_context(self, is_in_head: bool) -> None:
|
|
241
|
+
"""Choice rules are head-only: raises in bodies, teaching the body spelling."""
|
|
242
|
+
if not is_in_head:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"A Choice belongs in a rule head, where braces CHOOSE. In a body, "
|
|
245
|
+
"clingo's braces mean a cardinality TEST — a different construct "
|
|
246
|
+
"aspalchemy spells as a Count comparison: Count(X, condition=...) >= n."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def collect_defined_constants(self) -> set[str]:
|
|
250
|
+
constants = set()
|
|
251
|
+
|
|
252
|
+
for element in self._elements:
|
|
253
|
+
constants.update(element.collect_defined_constants())
|
|
254
|
+
|
|
255
|
+
if self.min_cardinality:
|
|
256
|
+
constants.update(self.min_cardinality.collect_defined_constants())
|
|
257
|
+
|
|
258
|
+
if self.max_cardinality:
|
|
259
|
+
constants.update(self.max_cardinality.collect_defined_constants())
|
|
260
|
+
|
|
261
|
+
return constants
|
|
262
|
+
|
|
263
|
+
def collect_variables(self) -> set[str]:
|
|
264
|
+
variables = set()
|
|
265
|
+
|
|
266
|
+
for element in self._elements:
|
|
267
|
+
variables.update(element.collect_variables())
|
|
268
|
+
|
|
269
|
+
if self.min_cardinality is not None:
|
|
270
|
+
variables.update(self.min_cardinality.collect_variables())
|
|
271
|
+
|
|
272
|
+
if self.max_cardinality is not None:
|
|
273
|
+
variables.update(self.max_cardinality.collect_variables())
|
|
274
|
+
|
|
275
|
+
return variables
|
|
276
|
+
|
|
277
|
+
def collect_predicate_occurrences(self, *, as_argument: bool) -> set[PredicateOccurrence]:
|
|
278
|
+
occurrences: set[PredicateOccurrence] = set()
|
|
279
|
+
for element in self._elements:
|
|
280
|
+
occurrences.update(element.collect_predicate_occurrences(as_argument=as_argument))
|
|
281
|
+
return occurrences
|