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 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
+ ]
@@ -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