kele 0.0.1a1__cp313-cp313-win32.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.
- kele/__init__.py +38 -0
- kele/_version.py +1 -0
- kele/config.py +243 -0
- kele/control/README_metrics.md +102 -0
- kele/control/__init__.py +20 -0
- kele/control/callback.py +255 -0
- kele/control/grounding_selector/__init__.py +5 -0
- kele/control/grounding_selector/_rule_strategies/README.md +13 -0
- kele/control/grounding_selector/_rule_strategies/__init__.py +24 -0
- kele/control/grounding_selector/_rule_strategies/_sequential_strategy.py +42 -0
- kele/control/grounding_selector/_rule_strategies/strategy_protocol.py +51 -0
- kele/control/grounding_selector/_selector_utils.py +123 -0
- kele/control/grounding_selector/_term_strategies/__init__.py +24 -0
- kele/control/grounding_selector/_term_strategies/_exhausted_strategy.py +34 -0
- kele/control/grounding_selector/_term_strategies/strategy_protocol.py +50 -0
- kele/control/grounding_selector/rule_selector.py +98 -0
- kele/control/grounding_selector/term_selector.py +89 -0
- kele/control/infer_path.py +306 -0
- kele/control/metrics.py +357 -0
- kele/control/status.py +286 -0
- kele/egg_equiv.pyd +0 -0
- kele/egg_equiv.pyi +11 -0
- kele/equality/README.md +8 -0
- kele/equality/__init__.py +4 -0
- kele/equality/_egg_equiv/src/lib.rs +267 -0
- kele/equality/_equiv_elem.py +67 -0
- kele/equality/_utils.py +36 -0
- kele/equality/equivalence.py +141 -0
- kele/executer/__init__.py +4 -0
- kele/executer/executing.py +139 -0
- kele/grounder/README.md +83 -0
- kele/grounder/__init__.py +17 -0
- kele/grounder/grounded_rule_ds/__init__.py +6 -0
- kele/grounder/grounded_rule_ds/_nodes/__init__.py +24 -0
- kele/grounder/grounded_rule_ds/_nodes/_assertion.py +353 -0
- kele/grounder/grounded_rule_ds/_nodes/_conn.py +116 -0
- kele/grounder/grounded_rule_ds/_nodes/_op.py +57 -0
- kele/grounder/grounded_rule_ds/_nodes/_root.py +71 -0
- kele/grounder/grounded_rule_ds/_nodes/_rule.py +119 -0
- kele/grounder/grounded_rule_ds/_nodes/_term.py +390 -0
- kele/grounder/grounded_rule_ds/_nodes/_tftable.py +15 -0
- kele/grounder/grounded_rule_ds/_nodes/_tupletable.py +444 -0
- kele/grounder/grounded_rule_ds/_nodes/_typing_polars.py +26 -0
- kele/grounder/grounded_rule_ds/grounded_class.py +461 -0
- kele/grounder/grounded_rule_ds/grounded_ds_utils.py +91 -0
- kele/grounder/grounded_rule_ds/rule_check.py +373 -0
- kele/grounder/grounding.py +118 -0
- kele/knowledge_bases/README.md +112 -0
- kele/knowledge_bases/__init__.py +6 -0
- kele/knowledge_bases/builtin_base/__init__.py +1 -0
- kele/knowledge_bases/builtin_base/builtin_concepts.py +13 -0
- kele/knowledge_bases/builtin_base/builtin_facts.py +43 -0
- kele/knowledge_bases/builtin_base/builtin_operators.py +105 -0
- kele/knowledge_bases/builtin_base/builtin_rules.py +14 -0
- kele/knowledge_bases/fact_base.py +158 -0
- kele/knowledge_bases/ontology_base.py +67 -0
- kele/knowledge_bases/rule_base.py +194 -0
- kele/main.py +464 -0
- kele/py.typed +0 -0
- kele/syntax/CONCEPT_README.md +117 -0
- kele/syntax/__init__.py +40 -0
- kele/syntax/_cnf_converter.py +161 -0
- kele/syntax/_sat_solver.py +116 -0
- kele/syntax/base_classes.py +1482 -0
- kele/syntax/connectives.py +20 -0
- kele/syntax/dnf_converter.py +145 -0
- kele/syntax/external.py +17 -0
- kele/syntax/sub_concept.py +87 -0
- kele/syntax/syntacticsugar.py +201 -0
- kele-0.0.1a1.dist-info/METADATA +166 -0
- kele-0.0.1a1.dist-info/RECORD +74 -0
- kele-0.0.1a1.dist-info/WHEEL +4 -0
- kele-0.0.1a1.dist-info/licenses/LICENSE +28 -0
- kele-0.0.1a1.dist-info/licenses/licensecheck.json +20 -0
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import itertools
|
|
6
|
+
import warnings
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, Self, cast
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .connectives import AND, EQUAL, IMPLIES, NOT, OR, Connective
|
|
14
|
+
|
|
15
|
+
# When enabled, `from_parts` will run full `__init__` validation instead of bypassing checks.
|
|
16
|
+
# Use this in tests/CI to catch invalid internal object construction early.
|
|
17
|
+
_RUN_INIT_VALIDATION_IN_FROM_PARTS = os.getenv("RUN_INIT_VALIDATION_IN_FROM_PARTS", "").strip().lower() not in {"", "0", "false", "no"}
|
|
18
|
+
|
|
19
|
+
LogicalConnective = Literal[
|
|
20
|
+
Connective.AND,
|
|
21
|
+
Connective.OR,
|
|
22
|
+
Connective.NOT,
|
|
23
|
+
Connective.IMPLIES,
|
|
24
|
+
Connective.EQUAL,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
UnaryConnectiveLike = Literal[Connective.NOT, "NOT"]
|
|
28
|
+
BinaryConnectiveLike = Literal[
|
|
29
|
+
Connective.AND, Connective.OR, Connective.IMPLIES, Connective.EQUAL,
|
|
30
|
+
"AND", "OR", "IMPLIES", "EQUAL",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HashableAndStringable(Protocol):
|
|
39
|
+
"""An object that can be converted to a string."""
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
"""Return the string representation of the object."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def __hash__(self) -> int:
|
|
46
|
+
"""Return the hash value of the object."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def __eq__(self, other: object, /) -> bool:
|
|
50
|
+
"""Return whether this object equals another object."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Constant:
|
|
55
|
+
"""object"""
|
|
56
|
+
|
|
57
|
+
def __init__(self,
|
|
58
|
+
symbol: HashableAndStringable,
|
|
59
|
+
belong_concepts: Concept | str | Sequence[Concept | str], # FIXME: Rename to belong_conceptss.
|
|
60
|
+
comments: str = '',
|
|
61
|
+
) -> None:
|
|
62
|
+
"""
|
|
63
|
+
:param symbol: Constant value.
|
|
64
|
+
:param belong_concepts: Each Constant must belong to at least one Concept.
|
|
65
|
+
:param comments: Optional annotations.
|
|
66
|
+
"""
|
|
67
|
+
self.symbol = symbol # Allow any value with __str__ and __hash__ for flexibility.
|
|
68
|
+
|
|
69
|
+
self.belong_concepts = self._normalize_concepts(belong_concepts)
|
|
70
|
+
self.belong_concepts_hash_key = tuple(sorted(self.belong_concepts)) # Lists are unhashable; sort concept names into a tuple.
|
|
71
|
+
|
|
72
|
+
self.comments = comments
|
|
73
|
+
|
|
74
|
+
def __eq__(self, other: object) -> bool:
|
|
75
|
+
if not isinstance(other, Constant):
|
|
76
|
+
return False
|
|
77
|
+
return self.symbol == other.symbol and bool(self.belong_concepts & other.belong_concepts) # Sets have no duplicates; any overlap is enough.
|
|
78
|
+
|
|
79
|
+
def __hash__(self) -> int:
|
|
80
|
+
return hash((self.symbol, self.belong_concepts_hash_key))
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
return str(self.symbol)
|
|
84
|
+
|
|
85
|
+
@functools.cached_property
|
|
86
|
+
def free_variables(self) -> tuple[Variable, ...]: # Tuple (not set) to allow same-symbol variables at different addresses.
|
|
87
|
+
"""Return free variables contained within."""
|
|
88
|
+
return ()
|
|
89
|
+
|
|
90
|
+
@functools.cached_property
|
|
91
|
+
def is_action_term(self) -> bool:
|
|
92
|
+
"""Return whether this is an action term."""
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> Constant:
|
|
96
|
+
"""Return a grounded instance for the current object."""
|
|
97
|
+
# Constant is returned directly; no replacement needed.
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def belong_concepts_str(self) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Return a human-readable string representation of the concepts this object belongs to.
|
|
104
|
+
"""
|
|
105
|
+
return "∩".join([str(c) for c in list(self.belong_concepts)])
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _normalize_concepts(belong_concepts: Concept | str | Sequence[Concept | str]) -> set[Concept]:
|
|
109
|
+
"""
|
|
110
|
+
Normalize the given concept(s).
|
|
111
|
+
|
|
112
|
+
If a concept or a sequence of concepts is provided, this method attempts to
|
|
113
|
+
retrieve or create them from the declared concepts.
|
|
114
|
+
|
|
115
|
+
:param concepts: A single Concept, a concept name (str), or a sequence of Concepts or names.
|
|
116
|
+
:type concepts: Concept | str | Sequence[Concept | str]
|
|
117
|
+
:return: A non-empty tuple of normalized Concept objects.
|
|
118
|
+
:rtype: tuple[Concept, ...]
|
|
119
|
+
|
|
120
|
+
:raises TypeError: If ``concepts`` is not a Concept, str, or a valid sequence of them.
|
|
121
|
+
:raises ValueError: If ``concepts`` is empty after normalization.
|
|
122
|
+
""" # noqa: DOC501
|
|
123
|
+
if not isinstance(belong_concepts, (Concept, str, Sequence)) or isinstance(belong_concepts, (bytes, bytearray)): # type: ignore[unreachable]
|
|
124
|
+
# Skip details like Sequence[xx] checks here.
|
|
125
|
+
raise TypeError(
|
|
126
|
+
f"belong_concepts must be Concept, str or Sequence[Concept | str]; got {type(belong_concepts)!s}."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
concepts = Concept.normalize_to_set(belong_concepts)
|
|
130
|
+
if not concepts:
|
|
131
|
+
raise ValueError("belong_concepts must be nonempty; a Constant must belong to at least one Concept.")
|
|
132
|
+
|
|
133
|
+
return concepts
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Variable:
|
|
137
|
+
"""
|
|
138
|
+
Variable.
|
|
139
|
+
|
|
140
|
+
- The external display (str) always uses the original user-provided symbol.
|
|
141
|
+
- When the engine accesses `symbol`, if RuleBase renamed it, return the unique symbol; otherwise return the user symbol.
|
|
142
|
+
"""
|
|
143
|
+
def __init__(self, symbol: HashableAndStringable, *, _original_symbol: str | None = None) -> None:
|
|
144
|
+
self.symbol = str(symbol)
|
|
145
|
+
self._original_symbol = _original_symbol # Keep the original user-provided symbol internally.
|
|
146
|
+
|
|
147
|
+
def create_renamed_variable(self, new_name: str) -> Variable:
|
|
148
|
+
"""Create a renamed variable while preserving the original display symbol."""
|
|
149
|
+
return Variable(new_name, _original_symbol=self._original_symbol or self.symbol)
|
|
150
|
+
|
|
151
|
+
def __eq__(self, other: object) -> bool:
|
|
152
|
+
if not isinstance(other, Variable):
|
|
153
|
+
return False
|
|
154
|
+
return self.symbol == other.symbol
|
|
155
|
+
|
|
156
|
+
def __hash__(self) -> int:
|
|
157
|
+
return hash(self.symbol)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def display_name(self) -> str:
|
|
161
|
+
"""Display symbol for external use, usually the user input, distinct from internal unique IDs."""
|
|
162
|
+
return self._original_symbol if self._original_symbol is not None else self.symbol
|
|
163
|
+
|
|
164
|
+
def __str__(self) -> str:
|
|
165
|
+
return self.display_name
|
|
166
|
+
|
|
167
|
+
def __lt__(self, other: Variable) -> bool:
|
|
168
|
+
return self.display_name < other.display_name # Prefer original names for display-centric sorting.
|
|
169
|
+
|
|
170
|
+
@functools.cached_property
|
|
171
|
+
def free_variables(self) -> tuple[Variable, ...]:
|
|
172
|
+
"""Return free variables contained within."""
|
|
173
|
+
return (self, )
|
|
174
|
+
|
|
175
|
+
@functools.cached_property
|
|
176
|
+
def is_action_term(self) -> bool:
|
|
177
|
+
"""Return whether this is an action term."""
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> TERM_TYPE:
|
|
181
|
+
"""Return a grounded instance for the current object."""
|
|
182
|
+
return var_map[self]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Concept:
|
|
186
|
+
"""A collection of Constants that share the something in common.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# Store declared Concepts to avoid duplicate declarations.
|
|
190
|
+
declared_concepts: ClassVar[dict[str, Concept]] = {}
|
|
191
|
+
|
|
192
|
+
# --- Transitive closure structures for fast subsumption checks ---
|
|
193
|
+
_parents: ClassVar[dict[Concept, set[Concept]]] = {}
|
|
194
|
+
_children: ClassVar[dict[Concept, set[Concept]]] = {}
|
|
195
|
+
_ancestors_inclusive: ClassVar[dict[Concept, set[Concept]]] = {}
|
|
196
|
+
_descendants_inclusive: ClassVar[dict[Concept, set[Concept]]] = {}
|
|
197
|
+
|
|
198
|
+
def __init__(self, name: HashableAndStringable, comments: str = '', parents: Sequence[Concept | str] | None = None) -> None:
|
|
199
|
+
self.name = str(name)
|
|
200
|
+
self.comments = comments
|
|
201
|
+
|
|
202
|
+
Concept._initial_subsumption_structure(self)
|
|
203
|
+
if parents:
|
|
204
|
+
for par in parents:
|
|
205
|
+
Concept.add_subsumption(self, par)
|
|
206
|
+
|
|
207
|
+
def __new__(cls, name: HashableAndStringable, comments: str = '', parents: Sequence[Concept | str] | None = None) -> Concept: # noqa: PYI034
|
|
208
|
+
"""Ensure Concept uniqueness."""
|
|
209
|
+
key = str(name)
|
|
210
|
+
if key in cls.declared_concepts:
|
|
211
|
+
return cls.declared_concepts[key]
|
|
212
|
+
obj = super().__new__(cls)
|
|
213
|
+
cls.declared_concepts[key] = obj
|
|
214
|
+
return obj
|
|
215
|
+
|
|
216
|
+
def __eq__(self, other: object) -> bool:
|
|
217
|
+
if not isinstance(other, Concept):
|
|
218
|
+
return False
|
|
219
|
+
return self.name == other.name
|
|
220
|
+
|
|
221
|
+
def __hash__(self) -> int:
|
|
222
|
+
return hash(self.name)
|
|
223
|
+
|
|
224
|
+
def __str__(self) -> str:
|
|
225
|
+
return self.name
|
|
226
|
+
|
|
227
|
+
# ---- Concept subsumption maintenance and queries ----
|
|
228
|
+
@classmethod
|
|
229
|
+
def _initial_subsumption_structure(cls, c: Concept) -> None:
|
|
230
|
+
if c not in cls._parents:
|
|
231
|
+
cls._parents[c] = set()
|
|
232
|
+
if c not in cls._children:
|
|
233
|
+
cls._children[c] = set()
|
|
234
|
+
if c not in cls._ancestors_inclusive:
|
|
235
|
+
cls._ancestors_inclusive[c] = {c}
|
|
236
|
+
else:
|
|
237
|
+
cls._ancestors_inclusive[c].add(c)
|
|
238
|
+
if c not in cls._descendants_inclusive:
|
|
239
|
+
cls._descendants_inclusive[c] = {c}
|
|
240
|
+
else:
|
|
241
|
+
cls._descendants_inclusive[c].add(c)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def add_subsumption(cls, child: Concept | str, parent: Concept | str) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Declare a subsumption (subset) relation: child ⊆ parent.
|
|
247
|
+
:raise ValueError: Disallow A ⊊ b and b ⊊ a at the same time.
|
|
248
|
+
""" # noqa: DOC501
|
|
249
|
+
child_c = cls._convert_concept(child)
|
|
250
|
+
parent_c = cls._convert_concept(parent)
|
|
251
|
+
|
|
252
|
+
cls._initial_subsumption_structure(child_c)
|
|
253
|
+
cls._initial_subsumption_structure(parent_c)
|
|
254
|
+
|
|
255
|
+
# Ignore reflexive relationships.
|
|
256
|
+
if child_c is parent_c:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
# Disallow mutual subsets: if parent ⊆ child already exists, reject child ⊆ parent.
|
|
260
|
+
anc_parent = cls._ancestors_inclusive.get(parent_c, {parent_c})
|
|
261
|
+
if child_c in anc_parent:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"Mutual subsumption is not allowed: {parent_c} ⊆ {child_c} already exists, cannot add {child_c} ⊆ {parent_c}."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Skip if already exists (including transitive).
|
|
267
|
+
if parent_c in cls._ancestors_inclusive[child_c]:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Direct edge.
|
|
271
|
+
cls._parents[child_c].add(parent_c)
|
|
272
|
+
cls._children[parent_c].add(child_c)
|
|
273
|
+
|
|
274
|
+
# Incremental closure update.
|
|
275
|
+
child_descs = set(cls._descendants_inclusive[child_c])
|
|
276
|
+
parent_ancs = set(cls._ancestors_inclusive[parent_c])
|
|
277
|
+
|
|
278
|
+
# Merge parent's ancestors into all descendants' ancestors.
|
|
279
|
+
for d in child_descs:
|
|
280
|
+
anc = cls._ancestors_inclusive[d]
|
|
281
|
+
plus = parent_ancs - anc
|
|
282
|
+
if plus:
|
|
283
|
+
anc |= plus
|
|
284
|
+
|
|
285
|
+
# Merge child's descendants into all ancestors' descendants.
|
|
286
|
+
for a in parent_ancs:
|
|
287
|
+
des = cls._descendants_inclusive[a]
|
|
288
|
+
plus = child_descs - des
|
|
289
|
+
if plus:
|
|
290
|
+
des |= plus
|
|
291
|
+
|
|
292
|
+
def set_parents(self, parents: Sequence[Concept | str]) -> Concept:
|
|
293
|
+
"""Directly register parents for the current concept."""
|
|
294
|
+
for p in parents:
|
|
295
|
+
Concept.add_subsumption(self, p)
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def _convert_concept(cls, c: Concept | str) -> Concept:
|
|
300
|
+
if isinstance(c, str):
|
|
301
|
+
warnings.warn(f"Concept '{c!s}' not found; created automatically.", stacklevel=2)
|
|
302
|
+
return Concept(c)
|
|
303
|
+
|
|
304
|
+
return c
|
|
305
|
+
|
|
306
|
+
def __le__(self, other: Concept) -> bool:
|
|
307
|
+
return self.is_subconcept_of(other)
|
|
308
|
+
|
|
309
|
+
def __lt__(self, other: Concept) -> bool:
|
|
310
|
+
return self is not other and self <= other
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def is_subconcept_rel(c1: Concept, c2: Concept) -> bool:
|
|
314
|
+
"""Return whether c1 is a subconcept of c2 (or the same concept)."""
|
|
315
|
+
# 1. Same concept.
|
|
316
|
+
if c1 is c2:
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
# FREEVARANY wildcard.
|
|
320
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import FREEVARANY_CONCEPT # noqa: PLC0415
|
|
321
|
+
if c1 is FREEVARANY_CONCEPT or c2 is FREEVARANY_CONCEPT:
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
# Subsumption: check if c2 is in c1's ancestors; parent sets are usually smaller.
|
|
325
|
+
anc = Concept._ancestors_inclusive.get(c1)
|
|
326
|
+
return bool(anc and c2 in anc)
|
|
327
|
+
|
|
328
|
+
def is_subconcept_of(self, c: Concept) -> bool:
|
|
329
|
+
"""Return whether the current concept is a subconcept of c (or equal)."""
|
|
330
|
+
return self.is_subconcept_rel(self, c)
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def normalize(cls, spec: Concept | str | Sequence[Concept | str]) -> tuple[Concept, ...]:
|
|
334
|
+
"""
|
|
335
|
+
If a concept or a sequence of concepts is provided, this method attempts to
|
|
336
|
+
retrieve from the declared concepts or create them, and then return a tuple of them.
|
|
337
|
+
"""
|
|
338
|
+
if isinstance(spec, (Concept, str)):
|
|
339
|
+
return (cls._convert_concept(spec),)
|
|
340
|
+
return tuple(cls._convert_concept(x) for x in spec)
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def normalize_to_set(cls, spec: Concept | str | Sequence[Concept | str]) -> set[Concept]:
|
|
344
|
+
"""
|
|
345
|
+
If a concept or a sequence of concepts is provided, this method attempts to
|
|
346
|
+
retrieve from the declared concepts or create them, and then return a set of them.
|
|
347
|
+
"""
|
|
348
|
+
if isinstance(spec, (Concept, str)):
|
|
349
|
+
return {cls._convert_concept(spec)}
|
|
350
|
+
return {cls._convert_concept(x) for x in spec}
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def _upward_closure(cls, cons: set[Concept]) -> set[Concept]:
|
|
354
|
+
"""Upward closure: each concept + all its ancestors (including itself)."""
|
|
355
|
+
out: set[Concept] = set()
|
|
356
|
+
for c in cons:
|
|
357
|
+
out |= cls._ancestors_inclusive.get(c, {c})
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def _downward_closure(cls, cons: set[Concept]) -> set[Concept]:
|
|
362
|
+
"""Downward closure: each concept + all its descendants (including itself)."""
|
|
363
|
+
out: set[Concept] = set()
|
|
364
|
+
for c in cons:
|
|
365
|
+
out |= cls._descendants_inclusive.get(c, {c})
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def belong_intersection_match(cls, con_candidate: set[Concept], con_constraint: set[Concept]) -> bool:
|
|
370
|
+
"""
|
|
371
|
+
Check whether con_candidate satisfies con_constraint.
|
|
372
|
+
For each constraint concept c, there exists a candidate x such that x ⊆ c (more specific is acceptable).
|
|
373
|
+
"""
|
|
374
|
+
if not con_constraint:
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
# FREEVARANY wildcard (following is_subconcept_rel semantics).
|
|
378
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import \
|
|
379
|
+
FREEVARANY_CONCEPT # noqa: PLC0415
|
|
380
|
+
if FREEVARANY_CONCEPT in con_candidate:
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
upward = cls._upward_closure(con_candidate)
|
|
384
|
+
|
|
385
|
+
return con_constraint.issubset(upward)
|
|
386
|
+
|
|
387
|
+
@classmethod
|
|
388
|
+
def union_match(cls, con_s1: set[Concept], con_s2: set[Concept]) -> bool:
|
|
389
|
+
"""Loose matching: treat subsumption as "intersection" matching, no input/constraint distinction.
|
|
390
|
+
Returns whether there is a non-empty common concept set aligned by the hierarchy.
|
|
391
|
+
"""
|
|
392
|
+
if not con_s1 or not con_s2: # Empty means universal set.
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import \
|
|
396
|
+
FREEVARANY_CONCEPT # noqa: PLC0415 # TODO: Replace FREEVARANY_CONCEPT with wildcard for consistency.
|
|
397
|
+
|
|
398
|
+
# Wildcard: any FREEVARANY passes.
|
|
399
|
+
if FREEVARANY_CONCEPT in con_s1:
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
upward_input = cls._downward_closure(con_s1) # FIXME: Consider fixed point / upward closure carefully.
|
|
403
|
+
upward_constraint = cls._downward_closure(con_s2)
|
|
404
|
+
|
|
405
|
+
return bool(upward_input & upward_constraint)
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def is_compatible( # TODO: Consider recording mismatch details later.
|
|
409
|
+
cls,
|
|
410
|
+
con_candidate: Concept | str | Sequence[Concept | str] | set[Concept],
|
|
411
|
+
con_constraint: Concept | str | Sequence[Concept | str] | set[Concept],
|
|
412
|
+
*,
|
|
413
|
+
fuzzy_match: bool = True
|
|
414
|
+
) -> bool:
|
|
415
|
+
"""
|
|
416
|
+
determine whether x and y are compatible.
|
|
417
|
+
|
|
418
|
+
con_candidate defaults to the intersection of concepts.
|
|
419
|
+
|
|
420
|
+
con_constraint depends on fuzzy_match:
|
|
421
|
+
|
|
422
|
+
fuzzy_match = False:
|
|
423
|
+
- Strict: require con_s1 to be a subset of con_s2 (correct type inference).
|
|
424
|
+
fuzzy_match = True:
|
|
425
|
+
- Loose: require con_s1 to intersect con_s2. This allows users to omit complete concept annotations by
|
|
426
|
+
using a union for constraints instead of the default intersection.
|
|
427
|
+
|
|
428
|
+
When the type is set, it is viewed as an internal call; accept Concepts only (no Concept | str).
|
|
429
|
+
"""
|
|
430
|
+
if not isinstance(con_candidate, set):
|
|
431
|
+
con_candidate = cls.normalize_to_set(con_candidate)
|
|
432
|
+
if not isinstance(con_constraint, set):
|
|
433
|
+
con_constraint = cls.normalize_to_set(con_constraint)
|
|
434
|
+
|
|
435
|
+
if fuzzy_match:
|
|
436
|
+
return Concept.union_match(con_candidate, con_constraint)
|
|
437
|
+
|
|
438
|
+
return Concept.belong_intersection_match(con_candidate, con_constraint)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class Operator:
|
|
442
|
+
"""Syntax element for assertion logic expressing relations among individuals and concepts."""
|
|
443
|
+
|
|
444
|
+
# Store declared Operators to avoid duplicate declarations.
|
|
445
|
+
declared_operators: ClassVar[dict[str, Operator]] = {}
|
|
446
|
+
|
|
447
|
+
def __init__(
|
|
448
|
+
self,
|
|
449
|
+
name: HashableAndStringable,
|
|
450
|
+
input_concepts: Sequence[Concept | str],
|
|
451
|
+
output_concept: Concept | str,
|
|
452
|
+
implement_func: Callable[[FlatCompoundTerm], TERM_TYPE] | None = None, # If action_op always targets
|
|
453
|
+
# FlatCompoundTerm, consider passing arity const values to implement_func for convenience.
|
|
454
|
+
comments: str = '',
|
|
455
|
+
) -> None:
|
|
456
|
+
"""
|
|
457
|
+
:param name: Operator name, used to uniquely identify the operation.
|
|
458
|
+
:param input_concepts: Input concept list describing accepted parameter types.
|
|
459
|
+
:param output_concept: Output concept describing the return type.
|
|
460
|
+
:param implement_func: Optional function defining operator semantics, reducing fact input.
|
|
461
|
+
:param comments: Optional annotation describing usage or notes.
|
|
462
|
+
|
|
463
|
+
:raises TypeError: Raised when `input_concepts` is not a Concept list, `output_concept` is not a Concept,
|
|
464
|
+
or `implement_func` is not callable (non-None and non-callable).
|
|
465
|
+
""" # noqa: DOC501
|
|
466
|
+
|
|
467
|
+
# Validation and defaults: auto-declare missing concepts with warnings.
|
|
468
|
+
# Process input concepts.
|
|
469
|
+
input_concept_instances = self._normalize_concepts(input_concepts)
|
|
470
|
+
|
|
471
|
+
if not isinstance(output_concept, (str, Concept)):
|
|
472
|
+
raise TypeError(f"output_concept must be a Concept or its name, got {type(output_concept)}")
|
|
473
|
+
output_concept_instance = self._normalize_concepts(output_concept)[0] # Output has only one concept.
|
|
474
|
+
|
|
475
|
+
if implement_func is not None and not callable(implement_func):
|
|
476
|
+
raise TypeError('implement_func must be Callable or None')
|
|
477
|
+
|
|
478
|
+
self.name = str(name)
|
|
479
|
+
self.input_concepts = input_concept_instances
|
|
480
|
+
self.output_concept = output_concept_instance
|
|
481
|
+
self.implement_func = implement_func
|
|
482
|
+
self.comments = comments
|
|
483
|
+
|
|
484
|
+
def __new__( # noqa: PYI034 This conflicts with mypy.
|
|
485
|
+
cls,
|
|
486
|
+
name: HashableAndStringable,
|
|
487
|
+
input_concepts: Sequence[Concept],
|
|
488
|
+
output_concept: Concept,
|
|
489
|
+
implement_func: Callable[..., TERM_TYPE] | None = None,
|
|
490
|
+
comments: str = '',
|
|
491
|
+
) -> Operator:
|
|
492
|
+
"""
|
|
493
|
+
Use __new__ to control instantiation and avoid duplicate instances.
|
|
494
|
+
"""
|
|
495
|
+
key = str(name)
|
|
496
|
+
if key in cls.declared_operators:
|
|
497
|
+
return cls.declared_operators[key]
|
|
498
|
+
obj = super().__new__(cls)
|
|
499
|
+
cls.declared_operators[key] = obj
|
|
500
|
+
return obj
|
|
501
|
+
|
|
502
|
+
def __hash__(self) -> int:
|
|
503
|
+
return hash(self.name)
|
|
504
|
+
|
|
505
|
+
def __eq__(self, other: object) -> bool: # Operators are equal if their names match.
|
|
506
|
+
if not isinstance(other, Operator):
|
|
507
|
+
return False
|
|
508
|
+
return self.name == other.name
|
|
509
|
+
|
|
510
|
+
def __str__(self) -> str:
|
|
511
|
+
return self.name
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _normalize_concepts(concepts: Concept | str | Sequence[Concept | str]) -> tuple[Concept, ...]:
|
|
515
|
+
"""
|
|
516
|
+
Normalize the given concept(s).
|
|
517
|
+
|
|
518
|
+
If a concept or a sequence of concepts is provided, this method attempts to
|
|
519
|
+
retrieve or create them from the declared concepts.
|
|
520
|
+
|
|
521
|
+
:param concepts: A single Concept, a concept name (str), or a sequence of Concepts or names.
|
|
522
|
+
:type concepts: Concept | str | Sequence[Concept | str]
|
|
523
|
+
:return: A non-empty tuple of normalized Concept objects.
|
|
524
|
+
:rtype: tuple[Concept, ...]
|
|
525
|
+
|
|
526
|
+
:raises TypeError: If ``concepts`` is not a Concept, str, or a valid sequence of them.
|
|
527
|
+
:raises ValueError: If ``concepts`` is empty after normalization.
|
|
528
|
+
""" # noqa: DOC501
|
|
529
|
+
if not isinstance(concepts, (Concept, str, Sequence)):
|
|
530
|
+
raise TypeError(
|
|
531
|
+
f"input_concepts/output_concepts must be Concept, str or Sequence[Concept | str]; got {type(concepts)!s}."
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
concepts = Concept.normalize(concepts)
|
|
535
|
+
if not concepts:
|
|
536
|
+
raise ValueError("input_concepts/output_concepts must be nonempty.")
|
|
537
|
+
|
|
538
|
+
return concepts
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class CompoundTerm[T1: Constant | Variable | HashableAndStringable = Constant | Variable | HashableAndStringable]:
|
|
542
|
+
# hack: This generic is tricky; T1 is theoretically TERM_TYPE | HashableAndStringable, but CompoundTerm is removed
|
|
543
|
+
# to avoid circular references. Type checkers rely on CompoundTerm also satisfying HashableAndStringable, which
|
|
544
|
+
# is not the intended design. All syntax layers have str/hash, so it passes; rely on developer caution and review.
|
|
545
|
+
"""Structure like op(xxx). In theory Constant, concept, operator, and op(term) are all terms, but we only handle the last."""
|
|
546
|
+
def __init__(self, operator: Operator | str, arguments: Sequence[T1 | CompoundTerm]) -> None:
|
|
547
|
+
"""
|
|
548
|
+
:param operator: Operator of the term.
|
|
549
|
+
:param arguments: Term expression op(t1, ... , tn). Arguments must be TERM_TYPE.
|
|
550
|
+
Non-conforming inputs (e.g., HashableAndStringable) default to Constant.
|
|
551
|
+
risk: The first version does not accept concept | operator as parameters.
|
|
552
|
+
""" # noqa: DOC501
|
|
553
|
+
# Process operator.
|
|
554
|
+
declared = Operator.declared_operators
|
|
555
|
+
if isinstance(operator, str):
|
|
556
|
+
if operator in declared:
|
|
557
|
+
operator_instance = declared[operator]
|
|
558
|
+
else:
|
|
559
|
+
# Cannot auto-create operator without input/output concepts; Concept args don't define output.
|
|
560
|
+
raise ValueError(f"Operator '{operator}' not found in declared_operators")
|
|
561
|
+
elif isinstance(operator, Operator):
|
|
562
|
+
operator_instance = operator
|
|
563
|
+
else:
|
|
564
|
+
raise TypeError(f"operator must be a Operator object or its name,got {type(operator)}")
|
|
565
|
+
|
|
566
|
+
if not isinstance(arguments, Sequence):
|
|
567
|
+
raise TypeError("arguments must be a sequence")
|
|
568
|
+
|
|
569
|
+
if len(arguments) != len(operator_instance.input_concepts):
|
|
570
|
+
raise ValueError(
|
|
571
|
+
f"Input arguments {[str(a) for a in arguments]} (count {len(arguments)}); \n"
|
|
572
|
+
f"do not match operator {operator_instance} input count {len(operator_instance.input_concepts)}, "
|
|
573
|
+
f"expected {[str(c) for c in operator_instance.input_concepts]}")
|
|
574
|
+
|
|
575
|
+
self.operator = operator_instance # Assign early to satisfy mypy.
|
|
576
|
+
|
|
577
|
+
# Validate argument types based on input concepts.
|
|
578
|
+
argument_instances: list[TERM_TYPE] = []
|
|
579
|
+
for i, (expected_concept, arg) in enumerate(zip(operator_instance.input_concepts, arguments)): # noqa: B905
|
|
580
|
+
if not isinstance(arg, TERM_TYPE):
|
|
581
|
+
argument_instances.append(Constant(arg, expected_concept))
|
|
582
|
+
warnings.warn("non-term input will be transformed into Constant", stacklevel=2)
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
# TODO: No need to check concept and operator for now.
|
|
586
|
+
# Constants may belong to multiple Concepts; any matching concept is acceptable.
|
|
587
|
+
if isinstance(arg, Constant) and not Concept.is_compatible(arg.belong_concepts, expected_concept, fuzzy_match=False):
|
|
588
|
+
raise ConceptConstraintMismatchError(
|
|
589
|
+
"CompoundTerm",
|
|
590
|
+
f"argument_index={i}; argument={arg!s}; "
|
|
591
|
+
f"argument_concepts=[{arg.belong_concepts_str}]; expected={expected_concept!s}",
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
if isinstance(arg, CompoundTerm) and not Concept.is_compatible(arg.operator.output_concept, expected_concept, fuzzy_match=False):
|
|
595
|
+
raise ConceptConstraintMismatchError(
|
|
596
|
+
"CompoundTerm",
|
|
597
|
+
f"argument_index={i}; argument={arg!s}; "
|
|
598
|
+
f"argument_concepts=[{arg.operator.output_concept!s}]; expected={expected_concept!s}",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
argument_instances.append(arg)
|
|
602
|
+
|
|
603
|
+
self.arguments = tuple(argument_instances)
|
|
604
|
+
|
|
605
|
+
def __new__(cls, operator: Operator, arguments: Sequence[TERM_TYPE]) -> CompoundTerm: # noqa: PYI034 Conflicts with mypy.
|
|
606
|
+
"""
|
|
607
|
+
Use __new__ to control term creation and instantiate FlatCompoundTerm when eligible.
|
|
608
|
+
"""
|
|
609
|
+
if all(not isinstance(argument, CompoundTerm) for argument in arguments):
|
|
610
|
+
return super().__new__(FlatCompoundTerm)
|
|
611
|
+
return super().__new__(cls)
|
|
612
|
+
|
|
613
|
+
def __eq__(self, other: object) -> bool: # Terms are equal if operator and arguments match.
|
|
614
|
+
if not isinstance(other, CompoundTerm):
|
|
615
|
+
return False
|
|
616
|
+
return self.operator == other.operator and self.arguments == other.arguments
|
|
617
|
+
|
|
618
|
+
def __hash__(self) -> int: # Hash terms by operator and arguments.
|
|
619
|
+
return hash((self.operator, self.arguments))
|
|
620
|
+
|
|
621
|
+
def __str__(self) -> str: # Print terms using operator and arguments only.
|
|
622
|
+
return f'{self.operator.name}({", ".join(str(u) for u in self.arguments)})'
|
|
623
|
+
|
|
624
|
+
@functools.cached_property
|
|
625
|
+
def free_variables(self) -> tuple[Variable, ...]:
|
|
626
|
+
"""Return free variables contained within."""
|
|
627
|
+
return tuple(itertools.chain.from_iterable([v.free_variables for v in self.arguments]))
|
|
628
|
+
|
|
629
|
+
@classmethod
|
|
630
|
+
def from_parts(cls, operator: Operator, arguments: Sequence[TERM_TYPE]) -> CompoundTerm:
|
|
631
|
+
"""Lightweight construction: skip __init__ checks for trusted internal use (e.g., replace_variable)."""
|
|
632
|
+
if TYPE_CHECKING:
|
|
633
|
+
if _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
634
|
+
return cls(operator, cast("Sequence[T1 | CompoundTerm[Constant | Variable | HashableAndStringable]]", arguments))
|
|
635
|
+
# TODO: Investigate this unexpected mypy check failure.
|
|
636
|
+
elif _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
637
|
+
return cls(operator, arguments)
|
|
638
|
+
|
|
639
|
+
target_cls = FlatCompoundTerm if all(not isinstance(argument, CompoundTerm) for argument in arguments) else CompoundTerm
|
|
640
|
+
obj = object.__new__(target_cls)
|
|
641
|
+
# Set fields directly to avoid __init__ validation.
|
|
642
|
+
obj.operator = operator
|
|
643
|
+
obj.arguments = tuple(arguments)
|
|
644
|
+
return obj
|
|
645
|
+
|
|
646
|
+
@functools.cached_property
|
|
647
|
+
def is_action_term(self) -> bool:
|
|
648
|
+
"""
|
|
649
|
+
Determine whether the current term is an action term.
|
|
650
|
+
|
|
651
|
+
:return: Whether this is an action term.
|
|
652
|
+
:rtype: bool
|
|
653
|
+
:raises ValueError: If operator implements implement_func but is not FlatCompoundTerm.
|
|
654
|
+
""" # noqa: DOC501
|
|
655
|
+
if self.operator.implement_func is not None and (not isinstance(self, FlatCompoundTerm)):
|
|
656
|
+
raise ValueError(f"operator {self.operator} implements implement_func but is not FlatCompoundTerm")
|
|
657
|
+
return self.operator.implement_func is not None
|
|
658
|
+
|
|
659
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> CompoundTerm:
|
|
660
|
+
"""Return a grounded instance for the current object."""
|
|
661
|
+
# For CompoundTerm, recursively process arguments.
|
|
662
|
+
if not self.free_variables:
|
|
663
|
+
return self
|
|
664
|
+
|
|
665
|
+
new_arguments: list[TERM_TYPE] = []
|
|
666
|
+
for arg in self.arguments:
|
|
667
|
+
if type(arg) is Variable:
|
|
668
|
+
new_arguments.append(var_map[arg])
|
|
669
|
+
elif type(arg) is Constant: # hack: If TERM_TYPE changes, this else may add overhead.
|
|
670
|
+
# Similar checks appear elsewhere.
|
|
671
|
+
new_arguments.append(arg)
|
|
672
|
+
else:
|
|
673
|
+
new_arguments.append(arg.replace_variable(var_map))
|
|
674
|
+
|
|
675
|
+
return self.from_parts(self.operator, new_arguments)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
TERM_TYPE = Constant | CompoundTerm | Variable # risk: concept | operator are terms too, but not handled yet.
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _term_possible_concepts(term: TERM_TYPE) -> set[Concept]: # FIXME: Consider moving.
|
|
682
|
+
"""Return possible/declared Concepts for a term.
|
|
683
|
+
|
|
684
|
+
- Constant: return its belong_concepts (can be multiple).
|
|
685
|
+
- CompoundTerm: return operator.output_concept.
|
|
686
|
+
- Variable: has no direct concept binding in syntax; return empty (constraints inferred from Rule/Assertion).
|
|
687
|
+
"""
|
|
688
|
+
if isinstance(term, Constant):
|
|
689
|
+
return term.belong_concepts
|
|
690
|
+
if isinstance(term, CompoundTerm):
|
|
691
|
+
return {term.operator.output_concept}
|
|
692
|
+
return set() # FIXME: Consider whether Variables should be inferred here.
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class ConceptConstraintMismatchError(ValueError):
|
|
696
|
+
"""Raised when concept constraints are inconsistent."""
|
|
697
|
+
|
|
698
|
+
def __init__(self, kind: str, details: str) -> None:
|
|
699
|
+
message = f"Concept constraint mismatch [{kind}]: {details}"
|
|
700
|
+
super().__init__(message)
|
|
701
|
+
self.kind = kind
|
|
702
|
+
self.details = details
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class Assertion: # TODO: Consider a dedicated action op class requiring left-to-right, like "is".
|
|
706
|
+
# This would avoid repeated only_substitution checks on both sides.
|
|
707
|
+
"""Basic unit representing facts/knowledges in assertion logic."""
|
|
708
|
+
|
|
709
|
+
def __init__(self, lhs: TERM_TYPE | HashableAndStringable, rhs: TERM_TYPE | HashableAndStringable | None = None) -> None:
|
|
710
|
+
"""
|
|
711
|
+
An assertion is an expression of the form a = b, representing a piece of knowledge.
|
|
712
|
+
:param lhs: Must be TERM_TYPE. Non-conforming inputs (e.g., HashableAndStringable) default to Constant.
|
|
713
|
+
:param rhs: Must be TERM_TYPE. Non-conforming inputs (e.g., HashableAndStringable) default to Constant.
|
|
714
|
+
:raises TypeError: a and b must be terms in the theoretical sense, not necessarily the Term class (which only treats op(...) as Term).
|
|
715
|
+
""" # noqa: DOC501
|
|
716
|
+
if not isinstance(lhs, TERM_TYPE):
|
|
717
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import BOOL_CONCEPT # noqa: PLC0415
|
|
718
|
+
from kele.knowledge_bases.builtin_base.builtin_facts import true_const # noqa: PLC0415
|
|
719
|
+
# Avoid circular imports by importing at runtime; only when needed.
|
|
720
|
+
|
|
721
|
+
if not isinstance(rhs, CompoundTerm):
|
|
722
|
+
raise TypeError('one of lhs and rhs must be TERM_TYPE at least')
|
|
723
|
+
|
|
724
|
+
if rhs.operator.output_concept is BOOL_CONCEPT and 'true' in str(rhs).strip().lower():
|
|
725
|
+
# Normalize true representation when lhs was set to TrueConst earlier.
|
|
726
|
+
warnings.warn(f'replace {rhs} with builtin TrueConst', stacklevel=2)
|
|
727
|
+
lhs = true_const
|
|
728
|
+
else:
|
|
729
|
+
warnings.warn('non-term input will be transformed into Constant', stacklevel=2)
|
|
730
|
+
lhs = Constant(rhs, rhs.operator.output_concept)
|
|
731
|
+
|
|
732
|
+
if rhs is None:
|
|
733
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import BOOL_CONCEPT # noqa: PLC0415
|
|
734
|
+
from kele.knowledge_bases.builtin_base.builtin_facts import true_const # noqa: PLC0415
|
|
735
|
+
|
|
736
|
+
if isinstance(lhs, CompoundTerm) and lhs.operator.output_concept is BOOL_CONCEPT: # If RHS is True, it can be omitted.
|
|
737
|
+
rhs = true_const
|
|
738
|
+
else:
|
|
739
|
+
raise ValueError("only the boolean value True can be omitted.")
|
|
740
|
+
|
|
741
|
+
if not isinstance(rhs, TERM_TYPE):
|
|
742
|
+
from kele.knowledge_bases.builtin_base.builtin_concepts import BOOL_CONCEPT # noqa: PLC0415
|
|
743
|
+
from kele.knowledge_bases.builtin_base.builtin_facts import true_const # noqa: PLC0415
|
|
744
|
+
|
|
745
|
+
if not isinstance(lhs, CompoundTerm):
|
|
746
|
+
raise TypeError('one of lhs and rhs must be TERM_TYPE at least')
|
|
747
|
+
|
|
748
|
+
if lhs.operator.output_concept is BOOL_CONCEPT and 'true' in str(rhs).strip().lower():
|
|
749
|
+
# Normalize true representation when rhs was set to TrueConst earlier.
|
|
750
|
+
warnings.warn(f'replace {rhs} with builtin TrueConst', stacklevel=2)
|
|
751
|
+
rhs = true_const
|
|
752
|
+
else:
|
|
753
|
+
warnings.warn('non-term input will be transformed into Constant', stacklevel=2)
|
|
754
|
+
rhs = Constant(rhs, lhs.operator.output_concept)
|
|
755
|
+
|
|
756
|
+
self.lhs = lhs
|
|
757
|
+
self.rhs = rhs
|
|
758
|
+
|
|
759
|
+
# -------- Concept consistency checks --------
|
|
760
|
+
# Only validate when both sides have inferable concepts.
|
|
761
|
+
lhs_concepts = _term_possible_concepts(self.lhs)
|
|
762
|
+
rhs_concepts = _term_possible_concepts(self.rhs)
|
|
763
|
+
|
|
764
|
+
if not Concept.union_match(lhs_concepts, rhs_concepts): # TODO: Replace with inferred lhs | rhs concepts.
|
|
765
|
+
raise ConceptConstraintMismatchError(
|
|
766
|
+
"Assertion",
|
|
767
|
+
f"lhs={self.lhs!s}; rhs={self.rhs!s}; "
|
|
768
|
+
f"lhs_concepts={[str(c) for c in list(lhs_concepts)]}; "
|
|
769
|
+
f"rhs_concepts={[str(c) for c in list(rhs_concepts)]}; intersection=empty",
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
@classmethod
|
|
773
|
+
def from_parts(cls, lhs: TERM_TYPE, rhs: TERM_TYPE) -> Self:
|
|
774
|
+
"""Trusted internal construction: skip __init__ conversions and Concept validation."""
|
|
775
|
+
if _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
776
|
+
return cls(lhs, rhs)
|
|
777
|
+
obj = object.__new__(cls)
|
|
778
|
+
obj.lhs = lhs
|
|
779
|
+
obj.rhs = rhs
|
|
780
|
+
return obj
|
|
781
|
+
|
|
782
|
+
def __eq__(self, other: object) -> bool: # Assertions are equal if lhs and rhs match.
|
|
783
|
+
if not isinstance(other, Assertion):
|
|
784
|
+
return False
|
|
785
|
+
return self.lhs == other.lhs and self.rhs == other.rhs
|
|
786
|
+
|
|
787
|
+
def __hash__(self) -> int: # Hash assertions by lhs and rhs.
|
|
788
|
+
return hash((self.lhs, self.rhs))
|
|
789
|
+
|
|
790
|
+
def __str__(self) -> str:
|
|
791
|
+
return f'{self.lhs} = {self.rhs}'
|
|
792
|
+
|
|
793
|
+
@functools.cached_property
|
|
794
|
+
def free_variables(self) -> tuple[Variable, ...]:
|
|
795
|
+
"""Return free variables contained within."""
|
|
796
|
+
return self.lhs.free_variables + self.rhs.free_variables
|
|
797
|
+
|
|
798
|
+
@functools.cached_property
|
|
799
|
+
def is_action_assertion(self) -> bool:
|
|
800
|
+
"""
|
|
801
|
+
Determine whether this assertion is an action assertion.
|
|
802
|
+
"""
|
|
803
|
+
return self.lhs.is_action_term or self.rhs.is_action_term
|
|
804
|
+
|
|
805
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> Assertion:
|
|
806
|
+
"""
|
|
807
|
+
Return a grounded instance by replacing all Variables in the Assertion.
|
|
808
|
+
|
|
809
|
+
:param var_map: Mapping[Variable, Constant | CompoundTerm] mapping Variables to Constants.
|
|
810
|
+
:return: Grounded Assertion object.
|
|
811
|
+
"""
|
|
812
|
+
if not self.free_variables:
|
|
813
|
+
return self
|
|
814
|
+
|
|
815
|
+
new_lhs: TERM_TYPE
|
|
816
|
+
|
|
817
|
+
lhs = self.lhs
|
|
818
|
+
if isinstance(lhs, Variable):
|
|
819
|
+
new_lhs = var_map[lhs]
|
|
820
|
+
elif type(lhs) is Constant:
|
|
821
|
+
new_lhs = lhs
|
|
822
|
+
else:
|
|
823
|
+
new_lhs = lhs.replace_variable(var_map)
|
|
824
|
+
|
|
825
|
+
new_rhs: TERM_TYPE
|
|
826
|
+
|
|
827
|
+
rhs = self.rhs
|
|
828
|
+
if isinstance(rhs, Variable):
|
|
829
|
+
new_rhs = var_map[rhs]
|
|
830
|
+
elif type(rhs) is Constant:
|
|
831
|
+
new_rhs = rhs
|
|
832
|
+
else:
|
|
833
|
+
new_rhs = rhs.replace_variable(var_map)
|
|
834
|
+
|
|
835
|
+
return type(self).from_parts(new_lhs, new_rhs)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
class Intro(Assertion):
|
|
839
|
+
"""
|
|
840
|
+
Syntactic sugar for X=X. In an unsafe rule, this assertion can indicate X is a free variable to match,
|
|
841
|
+
making the rule safe. When generating facts, use Intro(term) to include a term in the fact base.
|
|
842
|
+
"""
|
|
843
|
+
def __init__(self, term: TERM_TYPE) -> None:
|
|
844
|
+
super().__init__(term, term)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class Formula:
|
|
848
|
+
"""Combination of multiple Assertions."""
|
|
849
|
+
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _normalize_connective(
|
|
852
|
+
connective: HashableAndStringable | LogicalConnective,
|
|
853
|
+
) -> LogicalConnective:
|
|
854
|
+
if isinstance(connective, Connective):
|
|
855
|
+
return connective
|
|
856
|
+
if isinstance(connective, str):
|
|
857
|
+
return Connective(connective)
|
|
858
|
+
|
|
859
|
+
raise ValueError(f"Unknown connective: {connective}")
|
|
860
|
+
|
|
861
|
+
def __init__(self,
|
|
862
|
+
formula_left: FACT_TYPE,
|
|
863
|
+
connective: Literal['AND', 'OR', 'NOT', 'IMPLIES', 'EQUAL'] | LogicalConnective,
|
|
864
|
+
formula_right: FACT_TYPE | None = None) -> None:
|
|
865
|
+
"""
|
|
866
|
+
Logical formula composed of left/right sub-formulas or assertions and a connective.
|
|
867
|
+
|
|
868
|
+
:param formula_left: Left term, Formula or Assertion.
|
|
869
|
+
:param connective: Logical connective, e.g., "AND", "OR", "IMPLIES".
|
|
870
|
+
:param formula_right: Right term, Formula, Assertion, or None for unary structure.
|
|
871
|
+
|
|
872
|
+
:raises TypeError: Raised when formula_left is not Formula/Assertion,
|
|
873
|
+
or formula_right is not Formula/Assertion/None.
|
|
874
|
+
|
|
875
|
+
""" # noqa: DOC501
|
|
876
|
+
if not isinstance(formula_left, (Formula, Assertion)):
|
|
877
|
+
raise TypeError('formula_left must be a Formula or Assertion')
|
|
878
|
+
if not isinstance(formula_right, (Formula, Assertion)) and formula_right is not None:
|
|
879
|
+
raise TypeError('formula_right must be a Formula or Assertion or None')
|
|
880
|
+
|
|
881
|
+
self.formula_left = formula_left
|
|
882
|
+
self.connective: LogicalConnective = self._normalize_connective(connective)
|
|
883
|
+
self.formula_right = formula_right
|
|
884
|
+
|
|
885
|
+
@classmethod
|
|
886
|
+
def from_parts(
|
|
887
|
+
cls,
|
|
888
|
+
formula_left: FACT_TYPE,
|
|
889
|
+
connective: LogicalConnective,
|
|
890
|
+
formula_right: FACT_TYPE | None = None,
|
|
891
|
+
) -> Formula:
|
|
892
|
+
"""Trusted internal construction: skip __init__ type checks."""
|
|
893
|
+
if _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
894
|
+
return cls(formula_left, connective, formula_right)
|
|
895
|
+
obj = object.__new__(cls)
|
|
896
|
+
obj.formula_left = formula_left
|
|
897
|
+
obj.connective = cls._normalize_connective(connective)
|
|
898
|
+
obj.formula_right = formula_right
|
|
899
|
+
return obj
|
|
900
|
+
|
|
901
|
+
def __eq__(self, other: object) -> bool:
|
|
902
|
+
if not isinstance(other, Formula):
|
|
903
|
+
return False
|
|
904
|
+
return (self.connective == other.connective and self.formula_left == other.formula_left
|
|
905
|
+
and self.formula_right == other.formula_right)
|
|
906
|
+
|
|
907
|
+
def __hash__(self) -> int:
|
|
908
|
+
return hash((self.formula_left, self.connective, self.formula_right))
|
|
909
|
+
|
|
910
|
+
def __str__(self) -> str:
|
|
911
|
+
if self.connective == NOT:
|
|
912
|
+
return f'{NOT}({self.formula_left})'
|
|
913
|
+
return f'({self.formula_left}) {self.connective} ({self.formula_right})'
|
|
914
|
+
|
|
915
|
+
@functools.cached_property
|
|
916
|
+
def free_variables(self) -> tuple[Variable, ...]:
|
|
917
|
+
"""Return free variables contained within."""
|
|
918
|
+
return (self.formula_left.free_variables + self.formula_right.free_variables) if self.formula_right is not None \
|
|
919
|
+
else self.formula_left.free_variables
|
|
920
|
+
|
|
921
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> Formula:
|
|
922
|
+
"""Return a grounded instance for the current object."""
|
|
923
|
+
if not self.free_variables:
|
|
924
|
+
return self
|
|
925
|
+
|
|
926
|
+
formula_left = self.formula_left
|
|
927
|
+
new_formula_left = formula_left.replace_variable(var_map) if formula_left.free_variables else formula_left
|
|
928
|
+
|
|
929
|
+
formula_right = self.formula_right
|
|
930
|
+
new_formula_right = formula_right.replace_variable(var_map) if formula_right is not None and formula_right.free_variables else formula_right
|
|
931
|
+
|
|
932
|
+
return type(self).from_parts(new_formula_left, connective=self.connective, formula_right=new_formula_right)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
FACT_TYPE = Formula | Assertion
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def And(formula_left: FACT_TYPE, formula_right: FACT_TYPE) -> Formula: # noqa: N802
|
|
939
|
+
"""Convenience constructor for `(left) AND (right)` formulas."""
|
|
940
|
+
return Formula(formula_left, AND, formula_right)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def Or(formula_left: FACT_TYPE, formula_right: FACT_TYPE) -> Formula: # noqa: N802
|
|
944
|
+
"""Convenience constructor for `(left) OR (right)` formulas."""
|
|
945
|
+
return Formula(formula_left, OR, formula_right)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def Not(formula_left: FACT_TYPE) -> Formula: # noqa: N802
|
|
949
|
+
"""Convenience constructor for `NOT(left)` formulas."""
|
|
950
|
+
return Formula(formula_left, NOT, None)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def Implies(formula_left: FACT_TYPE, formula_right: FACT_TYPE) -> Formula: # noqa: N802
|
|
954
|
+
"""Convenience constructor for `(left) IMPLIES (right)` formulas."""
|
|
955
|
+
return Formula(formula_left, IMPLIES, formula_right)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def Equiv(formula_left: FACT_TYPE, formula_right: FACT_TYPE) -> Formula: # noqa: N802
|
|
959
|
+
"""Convenience constructor for `(left) EQUAL (right)` formulas."""
|
|
960
|
+
return Formula(formula_left, EQUAL, formula_right)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
class Rule:
|
|
964
|
+
"""Logical rule a → b, where head is the conclusion and body is the premise, with priority and extensions."""
|
|
965
|
+
|
|
966
|
+
def __init__(self,
|
|
967
|
+
# NOTE:
|
|
968
|
+
# - For convenience, callers may pass `Sequence[Assertion]` as conjunction sugar.
|
|
969
|
+
# - Internally, Rule.head is always stored in canonical form as FACT_TYPE (Assertion | Formula).
|
|
970
|
+
# This prevents a type mismatch after standardization (e.g., Seq[Assertion] -> Formula).
|
|
971
|
+
head: Assertion | Sequence[Assertion],
|
|
972
|
+
body: FACT_TYPE | Sequence[FACT_TYPE],
|
|
973
|
+
priority: float = 0.0,
|
|
974
|
+
name: str = "",
|
|
975
|
+
) -> None:
|
|
976
|
+
"""
|
|
977
|
+
Construct a Rule object expressing conclusions derived from premises.
|
|
978
|
+
|
|
979
|
+
:param head: Rule conclusion. Accepts Assertion, Formula, or Sequence[Assertion] (conjunction sugar).
|
|
980
|
+
:param body: Rule premise, type FACT_TYPE.
|
|
981
|
+
:param priority: Rule priority (float) for conflict resolution.
|
|
982
|
+
|
|
983
|
+
:raises TypeError: Raised when:
|
|
984
|
+
- head is not a FACT_TYPE instance (Assertion | Formula)
|
|
985
|
+
- body is not a FACT_TYPE instance
|
|
986
|
+
- priority is not a float or int
|
|
987
|
+
:raises ValueError:
|
|
988
|
+
- body is empty (rules must have at least one premise)
|
|
989
|
+
- head is empty (rules must have at least one conclusion)
|
|
990
|
+
""" # noqa: DOC501
|
|
991
|
+
if not body:
|
|
992
|
+
raise ValueError(
|
|
993
|
+
"Rule body cannot be empty: a rule must have at least one premise. "
|
|
994
|
+
"If you want to express a fact, simply add an Assertion instead of creating a Rule with an empty body."
|
|
995
|
+
)
|
|
996
|
+
if not head:
|
|
997
|
+
raise ValueError(
|
|
998
|
+
"Rule head cannot be empty: a rule must have at least one conclusion. "
|
|
999
|
+
"KELE does not support constraint rules."
|
|
1000
|
+
)
|
|
1001
|
+
# Head may be a single Assertion/Formula, or a non-empty conjunction sugar as Sequence[Assertion].
|
|
1002
|
+
# Keep the Sequence input restricted to Assertion to avoid ambiguous semantics.
|
|
1003
|
+
if not isinstance(head, FACT_TYPE) and not (
|
|
1004
|
+
isinstance(head, Sequence) and all(isinstance(f, Assertion) for f in head)
|
|
1005
|
+
):
|
|
1006
|
+
raise TypeError('head must be FACT_TYPE (Assertion | Formula) or Sequence[Assertion]')
|
|
1007
|
+
merged_head = self._standardize(head)
|
|
1008
|
+
merged_body = self._standardize(body)
|
|
1009
|
+
|
|
1010
|
+
if not isinstance(priority, float) or not (0 <= float(priority) <= 1.0):
|
|
1011
|
+
raise TypeError('priority must be a float between 0 and 1')
|
|
1012
|
+
|
|
1013
|
+
self.head = merged_head # Splitting to smallest disjuncts may be better for fact storage and chaining, but
|
|
1014
|
+
# FACT_TYPE is more expressive than list[FACT_TYPE], so keep to_cnf_clauses as a property instead.
|
|
1015
|
+
self.body = merged_body # body may include multiple facts but represents f1 AND f2; it should be a Formula.
|
|
1016
|
+
self.priority = priority
|
|
1017
|
+
self.name = name
|
|
1018
|
+
|
|
1019
|
+
from ._cnf_converter import to_cnf_clauses # noqa: PLC0415 # No better approach yet.
|
|
1020
|
+
from ._sat_solver import get_models_for_rule # noqa: PLC0415
|
|
1021
|
+
self.to_cnf_clauses = to_cnf_clauses
|
|
1022
|
+
self._get_models_for_rule = get_models_for_rule
|
|
1023
|
+
|
|
1024
|
+
@classmethod
|
|
1025
|
+
def from_parts(
|
|
1026
|
+
cls,
|
|
1027
|
+
head: FACT_TYPE | Sequence[Assertion],
|
|
1028
|
+
body: FACT_TYPE | Sequence[FACT_TYPE],
|
|
1029
|
+
*,
|
|
1030
|
+
priority: float = 0.0,
|
|
1031
|
+
name: str = "",
|
|
1032
|
+
) -> Self:
|
|
1033
|
+
"""Trusted internal construction: skip __init__ non-empty/standardize/priority checks.
|
|
1034
|
+
|
|
1035
|
+
Raises:
|
|
1036
|
+
TypeError: Raised when head is not an Assertion or Sequence[Assertion].
|
|
1037
|
+
"""
|
|
1038
|
+
if _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
1039
|
+
return cls(cls._head_fact_to_input(head), body, priority=priority, name=name) # FIXME: Consider signature + _
|
|
1040
|
+
# _dict__ for extensibility.
|
|
1041
|
+
# 这里也用了_head_fact_to_input以解决mypy的检查,因为这个只针对测试阶段,也属于耗时不敏感的地方
|
|
1042
|
+
# Similar approaches elsewhere.
|
|
1043
|
+
obj = object.__new__(cls)
|
|
1044
|
+
if not isinstance(head, FACT_TYPE) and not (
|
|
1045
|
+
isinstance(head, Sequence) and all(isinstance(f, Assertion) for f in head)
|
|
1046
|
+
):
|
|
1047
|
+
raise TypeError('head must be Assertion, Sequence[Assertion] or AND Formula')
|
|
1048
|
+
obj.head = obj._standardize(head) # noqa: SLF001
|
|
1049
|
+
obj.body = obj._standardize(body) # noqa: SLF001
|
|
1050
|
+
obj.priority = priority
|
|
1051
|
+
obj.name = name
|
|
1052
|
+
|
|
1053
|
+
# Dependency injection consistent with __init__ (avoid circular imports).
|
|
1054
|
+
from ._cnf_converter import to_cnf_clauses # noqa: PLC0415
|
|
1055
|
+
from ._sat_solver import get_models_for_rule # noqa: PLC0415
|
|
1056
|
+
obj.to_cnf_clauses = to_cnf_clauses
|
|
1057
|
+
obj._get_models_for_rule = get_models_for_rule # noqa: SLF001
|
|
1058
|
+
return obj
|
|
1059
|
+
|
|
1060
|
+
@functools.cached_property
|
|
1061
|
+
def free_variables(self) -> tuple[Variable, ...]:
|
|
1062
|
+
"""Return free variables contained within."""
|
|
1063
|
+
return self.head.free_variables + self.body.free_variables # TODO: If rules must be safe, add a helper.
|
|
1064
|
+
|
|
1065
|
+
def replace_variable(self, var_map: Mapping[Variable, Constant | CompoundTerm]) -> Rule:
|
|
1066
|
+
"""Return a grounded instance for the current object."""
|
|
1067
|
+
head = self.head
|
|
1068
|
+
new_head = head.replace_variable(var_map) if head.free_variables else head
|
|
1069
|
+
|
|
1070
|
+
body = self.body
|
|
1071
|
+
new_body = body.replace_variable(var_map) if body.free_variables else body
|
|
1072
|
+
|
|
1073
|
+
return type(self).from_parts(
|
|
1074
|
+
self._head_fact_to_input(new_head), # FIXME: 这里是为了类型检查出现的一个冗余操作。由于进引擎后sequence[assertion]
|
|
1075
|
+
# 会被转为Formula,以后也会被认为是Formula 然后就和assertion的类型约束撞了。但考虑到这个函数只在特殊情况下(trace,debug等)。
|
|
1076
|
+
# 这些场景对耗时不敏感,所以可以忽略。
|
|
1077
|
+
new_body,
|
|
1078
|
+
priority=self.priority,
|
|
1079
|
+
name=self.name,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
def replace(self, **changes: Any) -> Self: # noqa: ANN401
|
|
1083
|
+
# HACK: Once converted to a dataclass, replace can be used directly.
|
|
1084
|
+
"""
|
|
1085
|
+
Create a new rule based on the current rule type, with partial attribute updates.
|
|
1086
|
+
|
|
1087
|
+
:param changes: Attributes to update and their new values.
|
|
1088
|
+
:type changes: dict[str, Any]
|
|
1089
|
+
:raises ValueError: If changes contain attributes not present in the original rule.
|
|
1090
|
+
:return: New rule instance.
|
|
1091
|
+
:rtype: Rule | _QuestionRule
|
|
1092
|
+
""" # noqa: DOC501
|
|
1093
|
+
cls = self.__class__
|
|
1094
|
+
params = [
|
|
1095
|
+
n for n in inspect.signature(cls.__init__).parameters
|
|
1096
|
+
if n != "self"
|
|
1097
|
+
]
|
|
1098
|
+
unknown = set(changes) - set(params)
|
|
1099
|
+
if unknown:
|
|
1100
|
+
raise ValueError(f"{cls.__name__}.__init__ does not accept: {sorted(unknown)}")
|
|
1101
|
+
|
|
1102
|
+
data = {n: changes.get(n, getattr(self, n)) for n in params}
|
|
1103
|
+
|
|
1104
|
+
return cls.from_parts(**data)
|
|
1105
|
+
|
|
1106
|
+
def is_concept_compatible_binding(
|
|
1107
|
+
self,
|
|
1108
|
+
var: Variable | str,
|
|
1109
|
+
value: Constant | CompoundTerm[Constant | CompoundTerm],
|
|
1110
|
+
*,
|
|
1111
|
+
fuzzy_match: bool = True,
|
|
1112
|
+
) -> bool:
|
|
1113
|
+
"""Check Rule-level variable Concept constraints while binding var -> value in unify."""
|
|
1114
|
+
constraints = self.get_variable_concept_constraints(var)
|
|
1115
|
+
if not constraints:
|
|
1116
|
+
return True
|
|
1117
|
+
|
|
1118
|
+
value_concepts = value.belong_concepts if isinstance(value, Constant) else {value.operator.output_concept}
|
|
1119
|
+
|
|
1120
|
+
return Concept.is_compatible(value_concepts, constraints, fuzzy_match=fuzzy_match)
|
|
1121
|
+
|
|
1122
|
+
def __eq__(self, other: object) -> bool: # We do not forbid duplicate rules; comparison isn't always syntactic.
|
|
1123
|
+
# e.g., p(x)=1→q(x)=1 and p(y)=1→q(y)=1. Leave equality to the user.
|
|
1124
|
+
if not isinstance(other, Rule):
|
|
1125
|
+
return False
|
|
1126
|
+
return self.head == other.head and self.body == other.body
|
|
1127
|
+
|
|
1128
|
+
def __hash__(self) -> int:
|
|
1129
|
+
return hash((self.head, self.body, self.priority))
|
|
1130
|
+
|
|
1131
|
+
def __str__(self) -> str:
|
|
1132
|
+
return f"{self.name}: {self.body} → {self.head} (priority: {self.priority})"
|
|
1133
|
+
|
|
1134
|
+
def _get_unsafe_variables(self) -> set[Variable]:
|
|
1135
|
+
"""
|
|
1136
|
+
Return unsafe variables in the rule.
|
|
1137
|
+
|
|
1138
|
+
:param rule: Rule to inspect.
|
|
1139
|
+
:type rule: Rule
|
|
1140
|
+
:return: Unsafe variables in the rule.
|
|
1141
|
+
:rtype: set[Variable]
|
|
1142
|
+
"""
|
|
1143
|
+
positive_non_action_assertion_vars: set[Variable] = set()
|
|
1144
|
+
action_assertion_vars: set[Variable] = set()
|
|
1145
|
+
negated_assertion_vars: set[Variable] = set()
|
|
1146
|
+
|
|
1147
|
+
for assertion, sat_result in self.get_models.items():
|
|
1148
|
+
if sat_result[0] and not assertion.is_action_assertion:
|
|
1149
|
+
# Only positive literals from non-action assertions contribute to real_grounding_variables.
|
|
1150
|
+
# IMPORTANT: positive_non_action_assertion_vars does not include all grounding variables.
|
|
1151
|
+
# Variables in action assertions (not in action terms) also ground, but are not included here.
|
|
1152
|
+
positive_non_action_assertion_vars.update(assertion.free_variables)
|
|
1153
|
+
|
|
1154
|
+
for assertion, sat_result in self.get_models.items():
|
|
1155
|
+
if sat_result[0]:
|
|
1156
|
+
for term in (assertion.lhs, assertion.rhs):
|
|
1157
|
+
if term.is_action_term:
|
|
1158
|
+
action_assertion_vars.update(set(term.free_variables))
|
|
1159
|
+
elif sat_result[1]:
|
|
1160
|
+
# Free variables in negated literals must appear in real_grounding_variables.
|
|
1161
|
+
negated_assertion_vars.update(set(assertion.free_variables))
|
|
1162
|
+
|
|
1163
|
+
return (set(self.head.free_variables) | negated_assertion_vars | action_assertion_vars) - positive_non_action_assertion_vars
|
|
1164
|
+
|
|
1165
|
+
@functools.cached_property
|
|
1166
|
+
def head_units(self) -> list[FACT_TYPE]:
|
|
1167
|
+
"""Return minimal disjunctive units of head. XXX: Not guaranteed minimal yet."""
|
|
1168
|
+
return self.to_cnf_clauses(self.head)
|
|
1169
|
+
|
|
1170
|
+
@functools.cached_property
|
|
1171
|
+
def body_units(self) -> list[FACT_TYPE]:
|
|
1172
|
+
"""Return minimal disjunctive units of body. XXX: Not guaranteed minimal yet."""
|
|
1173
|
+
return self.to_cnf_clauses(self.body)
|
|
1174
|
+
|
|
1175
|
+
@functools.cached_property
|
|
1176
|
+
def unsafe_variables(self) -> set[Variable]:
|
|
1177
|
+
"""Return unsafe variables in the rule."""
|
|
1178
|
+
return self._get_unsafe_variables()
|
|
1179
|
+
|
|
1180
|
+
@staticmethod
|
|
1181
|
+
def _standardize(body_or_head: FACT_TYPE | Sequence[FACT_TYPE]) -> FACT_TYPE:
|
|
1182
|
+
if isinstance(body_or_head, FACT_TYPE):
|
|
1183
|
+
merged = body_or_head
|
|
1184
|
+
elif isinstance(body_or_head, Sequence) and all(isinstance(f, FACT_TYPE) for f in body_or_head):
|
|
1185
|
+
merged = functools.reduce(lambda x, y: Formula(x, AND, y), body_or_head)
|
|
1186
|
+
else:
|
|
1187
|
+
raise TypeError('body_or_head must be FACT_TYPE (Formula | Assertion)')
|
|
1188
|
+
|
|
1189
|
+
return merged
|
|
1190
|
+
|
|
1191
|
+
@functools.cached_property
|
|
1192
|
+
def get_models(self) -> dict[Assertion, list[bool]]:
|
|
1193
|
+
"""
|
|
1194
|
+
For a Rule, find all possible models from a Boolean logic perspective,
|
|
1195
|
+
and analyze assignments to determine whether each assertion can be True or False.
|
|
1196
|
+
:return: Dict indicating whether each assertion can be T/F.
|
|
1197
|
+
"""
|
|
1198
|
+
return self._get_models_for_rule(self)
|
|
1199
|
+
|
|
1200
|
+
# ----------------------- Concept constraint collection and validation -----------------------
|
|
1201
|
+
|
|
1202
|
+
@functools.cached_property
|
|
1203
|
+
def _variable_concept_constraints(self) -> dict[str, set[Concept]]:
|
|
1204
|
+
"""Collect and validate Concept constraints for same-named variables in the Rule."""
|
|
1205
|
+
return self._validate_concepts_in_rule()
|
|
1206
|
+
|
|
1207
|
+
@staticmethod
|
|
1208
|
+
def _iter_assertions(fact: FACT_TYPE) -> list[Assertion]:
|
|
1209
|
+
"""Recursively expand Formula and collect all Assertions."""
|
|
1210
|
+
if isinstance(fact, Assertion):
|
|
1211
|
+
return [fact]
|
|
1212
|
+
# Formula
|
|
1213
|
+
left = Rule._iter_assertions(fact.formula_left)
|
|
1214
|
+
if fact.formula_right is None:
|
|
1215
|
+
return left
|
|
1216
|
+
return left + Rule._iter_assertions(fact.formula_right)
|
|
1217
|
+
|
|
1218
|
+
@staticmethod
|
|
1219
|
+
def _collect_var_constraints_from_term(
|
|
1220
|
+
term: TERM_TYPE,
|
|
1221
|
+
expected_concept: Concept | None,
|
|
1222
|
+
out: dict[str, set[Concept]],
|
|
1223
|
+
) -> None:
|
|
1224
|
+
"""Recursively collect variable Concept constraints from a term.
|
|
1225
|
+
|
|
1226
|
+
- When Variable appears in operator argument i, that argument's Concept constrains the Variable.
|
|
1227
|
+
- expected_concept is None when the context has no direct Concept constraint.
|
|
1228
|
+
"""
|
|
1229
|
+
if isinstance(term, Variable):
|
|
1230
|
+
if expected_concept is not None:
|
|
1231
|
+
out[term.symbol].add(expected_concept)
|
|
1232
|
+
return
|
|
1233
|
+
if isinstance(term, Constant):
|
|
1234
|
+
return
|
|
1235
|
+
# CompoundTerm
|
|
1236
|
+
for arg, exp_c in zip(term.arguments, term.operator.input_concepts, strict=False):
|
|
1237
|
+
Rule._collect_var_constraints_from_term(arg, exp_c, out)
|
|
1238
|
+
|
|
1239
|
+
@staticmethod
|
|
1240
|
+
def _union_find_build(links: list[tuple[str, str]]) -> dict[str, str]:
|
|
1241
|
+
"""Build a union-find parent map from equality constraints of the form (a, b)."""
|
|
1242
|
+
parent: dict[str, str] = {}
|
|
1243
|
+
|
|
1244
|
+
def find(x: str) -> str:
|
|
1245
|
+
parent.setdefault(x, x)
|
|
1246
|
+
if parent[x] != x:
|
|
1247
|
+
parent[x] = find(parent[x])
|
|
1248
|
+
return parent[x]
|
|
1249
|
+
|
|
1250
|
+
def union(a: str, b: str) -> None:
|
|
1251
|
+
ra, rb = find(a), find(b)
|
|
1252
|
+
if ra != rb:
|
|
1253
|
+
parent[rb] = ra
|
|
1254
|
+
|
|
1255
|
+
for a, b in links:
|
|
1256
|
+
union(a, b)
|
|
1257
|
+
|
|
1258
|
+
# Path compression.
|
|
1259
|
+
for k in list(parent.keys()):
|
|
1260
|
+
parent[k] = find(k)
|
|
1261
|
+
return parent
|
|
1262
|
+
|
|
1263
|
+
def _validate_concepts_in_rule(self) -> dict[str, set[Concept]]:
|
|
1264
|
+
"""Compute Rule-level Concept constraints.
|
|
1265
|
+
|
|
1266
|
+
1) Record Concept constraints for same-named variables from different positions (list indicates multiple constraints).
|
|
1267
|
+
2) TODO: Validate satisfiability of constraints based on mode (strict/loose).
|
|
1268
|
+
"""
|
|
1269
|
+
assertions = self._iter_assertions(self.body) + self._iter_assertions(self.head)
|
|
1270
|
+
# TODO: Potential optimization: compute head/body separately. If head constraints are absent in body,
|
|
1271
|
+
# warn about potential conflicts. For now, just record combined constraints.
|
|
1272
|
+
|
|
1273
|
+
# 1) Collect: constraints from operator argument positions; and variable/term constraints in a=b.
|
|
1274
|
+
var_constraints: dict[str, set[Concept]] = defaultdict(set) # Variables may have multiple same-symbol instances.
|
|
1275
|
+
equal_links: list[tuple[str, str]] = []
|
|
1276
|
+
|
|
1277
|
+
for a in assertions:
|
|
1278
|
+
# Same-symbol variables are equivalent in a = b (var1 = var2).
|
|
1279
|
+
if isinstance(a.lhs, Variable) and isinstance(a.rhs, Variable):
|
|
1280
|
+
equal_links.append((a.lhs.symbol, a.rhs.symbol))
|
|
1281
|
+
|
|
1282
|
+
# Collect operator-level concept constraints.
|
|
1283
|
+
self._collect_var_constraints_from_term(a.lhs, expected_concept=None, out=var_constraints)
|
|
1284
|
+
self._collect_var_constraints_from_term(a.rhs, expected_concept=None, out=var_constraints)
|
|
1285
|
+
|
|
1286
|
+
# Equality constraints: Variable vs. output concepts on the opposite term.
|
|
1287
|
+
if isinstance(a.lhs, Variable):
|
|
1288
|
+
var_constraints[a.lhs.symbol] |= _term_possible_concepts(a.rhs)
|
|
1289
|
+
if isinstance(a.rhs, Variable):
|
|
1290
|
+
var_constraints[a.rhs.symbol] |= _term_possible_concepts(a.lhs)
|
|
1291
|
+
|
|
1292
|
+
# 2) Merge: union constraints for var1=var2 equivalence classes.
|
|
1293
|
+
parent = self._union_find_build(equal_links)
|
|
1294
|
+
|
|
1295
|
+
merged: dict[str, set[Concept]] = defaultdict(set)
|
|
1296
|
+
for var_name, concepts in var_constraints.items():
|
|
1297
|
+
root = parent.get(var_name, var_name)
|
|
1298
|
+
merged[root] |= concepts
|
|
1299
|
+
|
|
1300
|
+
# NOTE:
|
|
1301
|
+
# - `merged` uses only the root variable symbol as key, so non-root vars cannot query constraints in unify.
|
|
1302
|
+
# - Grounder/Unify uses Variable.symbol (renamed to unique _vK in RuleBase), so use Variable.symbol as key,
|
|
1303
|
+
# not display_name.
|
|
1304
|
+
merged_by_root = merged
|
|
1305
|
+
|
|
1306
|
+
# Expand merged constraints to each variable symbol (including non-root names) for faster lookup.
|
|
1307
|
+
expanded: dict[str, set[Concept]] = defaultdict(set)
|
|
1308
|
+
all_var_names = set(var_constraints.keys()) | set(parent.keys())
|
|
1309
|
+
for var_name in all_var_names:
|
|
1310
|
+
root = parent.get(var_name, var_name)
|
|
1311
|
+
expanded[var_name] |= merged_by_root.get(root, set())
|
|
1312
|
+
|
|
1313
|
+
# TODO: Add a more detailed third validation step.
|
|
1314
|
+
# For example, if X is constrained by A∩B, require a declared concept C that belongs to both (strict),
|
|
1315
|
+
# or allow the engine to create one (loose).
|
|
1316
|
+
|
|
1317
|
+
return expanded
|
|
1318
|
+
|
|
1319
|
+
# ----------------------- Concept constraint access and checks -----------------------
|
|
1320
|
+
|
|
1321
|
+
def get_variable_concept_constraints(self, var: Variable | str) -> set[Concept]:
|
|
1322
|
+
"""Get Concept constraints for a variable symbol (Variable.symbol).
|
|
1323
|
+
|
|
1324
|
+
IMPORTANT:
|
|
1325
|
+
- Always use Variable.symbol as the key inside the engine (RuleBase renames to `_vK`); do not use `str(var)` (display_name).
|
|
1326
|
+
- Return empty set if the variable has no constraints.
|
|
1327
|
+
"""
|
|
1328
|
+
key = var.symbol if isinstance(var, Variable) else str(var)
|
|
1329
|
+
return self._variable_concept_constraints.get(key, set())
|
|
1330
|
+
|
|
1331
|
+
@staticmethod
|
|
1332
|
+
def _head_fact_to_input(head: FACT_TYPE | Sequence[Assertion]) -> Assertion | Sequence[Assertion]:
|
|
1333
|
+
"""Convert internal canonical head (Assertion/Formula) into allowed input type.
|
|
1334
|
+
|
|
1335
|
+
Public constructors keep Rule head restricted to `Assertion | Sequence[Assertion]` (conjunction sugar).
|
|
1336
|
+
Internally, head is stored as `Assertion | Formula` after standardization. When rebuilding a Rule via
|
|
1337
|
+
`from_parts` (e.g. grounding/replacement), we must pass head back in the allowed input type.
|
|
1338
|
+
|
|
1339
|
+
:raise: TypeError: Only conjunctions (AND-chains) of Assertions are convertible from (head) Formula.
|
|
1340
|
+
""" # noqa: DOC501
|
|
1341
|
+
# Pass-through for already-allowed inputs
|
|
1342
|
+
if isinstance(head, Assertion):
|
|
1343
|
+
return head
|
|
1344
|
+
if isinstance(head, Sequence) and all(isinstance(f, Assertion) for f in head):
|
|
1345
|
+
return head
|
|
1346
|
+
|
|
1347
|
+
# Convert a canonical conjunction Formula back into list[Assertion]
|
|
1348
|
+
if isinstance(head, Formula):
|
|
1349
|
+
def split_and(node: FACT_TYPE) -> list[Assertion]:
|
|
1350
|
+
if isinstance(node, Assertion):
|
|
1351
|
+
return [node]
|
|
1352
|
+
if isinstance(node, Formula) and node.connective == AND:
|
|
1353
|
+
if node.formula_right is None:
|
|
1354
|
+
raise TypeError("Head conjunction Formula missing right-hand side")
|
|
1355
|
+
return split_and(node.formula_left) + split_and(node.formula_right)
|
|
1356
|
+
raise TypeError("Head Formula must be a conjunction (AND) of Assertions")
|
|
1357
|
+
|
|
1358
|
+
ret = split_and(head)
|
|
1359
|
+
if not ret:
|
|
1360
|
+
raise TypeError("Head cannot be empty")
|
|
1361
|
+
return ret
|
|
1362
|
+
|
|
1363
|
+
raise TypeError("head must be Assertion or Sequence[Assertion]")
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
class Question:
|
|
1367
|
+
"""Problem to solve, including premises and variable-containing queries related to the problem description."""
|
|
1368
|
+
|
|
1369
|
+
def __init__(self, premises: Sequence[Assertion] | Assertion, question: Sequence[FACT_TYPE]) -> None:
|
|
1370
|
+
"""
|
|
1371
|
+
Construct a Question object with relevant premises and target formulas containing variables.
|
|
1372
|
+
|
|
1373
|
+
:param premises: Premises related to the question. Assertion or list of Assertion.
|
|
1374
|
+
If a single Assertion is provided, it is wrapped into a list.
|
|
1375
|
+
:param question: Query items to solve, as FACT_TYPE or list of FACT_TYPE.
|
|
1376
|
+
|
|
1377
|
+
:raises TypeError:
|
|
1378
|
+
- Raised when premises is not a list or valid Assertion.
|
|
1379
|
+
- Raised when question is not a list or valid Assertion/Formula.
|
|
1380
|
+
""" # noqa: DOC501
|
|
1381
|
+
if not isinstance(premises, Sequence):
|
|
1382
|
+
if isinstance(premises, Assertion):
|
|
1383
|
+
premises = [premises]
|
|
1384
|
+
else:
|
|
1385
|
+
raise TypeError('premises must be a list of Assertion')
|
|
1386
|
+
elif not all(isinstance(premise, Assertion) for premise in premises):
|
|
1387
|
+
raise TypeError('premises must be a list of Assertion')
|
|
1388
|
+
|
|
1389
|
+
if not isinstance(question, Sequence) or not all(isinstance(q, FACT_TYPE) for q in question):
|
|
1390
|
+
raise TypeError('question must be a list of FACT_TYPE')
|
|
1391
|
+
|
|
1392
|
+
self.premises = premises
|
|
1393
|
+
self.question = question
|
|
1394
|
+
|
|
1395
|
+
@property
|
|
1396
|
+
def description(self) -> str:
|
|
1397
|
+
"""Build a natural-language description combining premises and question."""
|
|
1398
|
+
question_str = ','.join(str(q) for q in self.question)
|
|
1399
|
+
return (f'Question : {question_str},\nincluding {len(self.premises)} premises '
|
|
1400
|
+
f'and {len(self.question)} target facts.')
|
|
1401
|
+
|
|
1402
|
+
def __str__(self) -> str:
|
|
1403
|
+
return (f'Premises: {self.premises}\n'
|
|
1404
|
+
f'Question: {','.join(str(q) for q in self.question)}')
|
|
1405
|
+
|
|
1406
|
+
def __eq__(self, other: object) -> bool:
|
|
1407
|
+
if not isinstance(other, Question):
|
|
1408
|
+
return False
|
|
1409
|
+
return self.question == other.question and self.premises == other.premises
|
|
1410
|
+
|
|
1411
|
+
def __hash__(self) -> int:
|
|
1412
|
+
return hash((tuple(self.premises), tuple(self.question)))
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
class FlatCompoundTerm(CompoundTerm):
|
|
1416
|
+
"""recursively defined to be a constant, or a variable, or an n-ary operator whose arguments are flat terms."""
|
|
1417
|
+
# atomize/atom keywords are scoped to this class. Also note: Variables are treated as free variables,
|
|
1418
|
+
# but introducing predicates adds complications, so isinstance(xxx, Variable) is not sufficient.
|
|
1419
|
+
|
|
1420
|
+
def __init__(self, operator: Operator, arguments: Sequence[Constant | Variable]) -> None:
|
|
1421
|
+
super().__init__(operator, arguments)
|
|
1422
|
+
self.arguments: tuple[Constant | Variable, ...]
|
|
1423
|
+
|
|
1424
|
+
def __str__(self) -> str:
|
|
1425
|
+
return f'{self.operator.name}({", ".join(str(u) for u in self.arguments)})'
|
|
1426
|
+
|
|
1427
|
+
@classmethod
|
|
1428
|
+
def from_parts(cls, operator: Operator, arguments: Sequence[TERM_TYPE]) -> FlatCompoundTerm:
|
|
1429
|
+
"""Lightweight construction: skip __init__ checks for trusted internal use (e.g., replace_variable)."""
|
|
1430
|
+
if TYPE_CHECKING:
|
|
1431
|
+
arguments = cast("Sequence[Constant | Variable]",
|
|
1432
|
+
arguments) # FlatCompoundTerm arguments are Constant | Variable.
|
|
1433
|
+
# But the types are still hard to annotate precisely.
|
|
1434
|
+
|
|
1435
|
+
if _RUN_INIT_VALIDATION_IN_FROM_PARTS:
|
|
1436
|
+
return cls(operator, arguments)
|
|
1437
|
+
|
|
1438
|
+
obj = object.__new__(cls)
|
|
1439
|
+
obj.operator = operator
|
|
1440
|
+
obj.arguments = tuple(arguments)
|
|
1441
|
+
return obj
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
FLATTERM_TYPE = Constant | FlatCompoundTerm | Variable # risk: concept | operator are terms too, but not handled yet.
|
|
1445
|
+
ATOM_TYPE = Constant | Variable
|
|
1446
|
+
GROUNDED_TYPE = Constant | FlatCompoundTerm # HACK: Strictly, FlatCompoundTerm should be variable-free.
|
|
1447
|
+
GROUNDED_TYPE_FOR_UNIFICATION = TERM_TYPE # risk: Consider whether term selector uses TERM_TYPE or GROUNDED_TYPE.
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
# This alias may change; we use a special alias for now. We still prefer TERM_TYPE for two reasons:
|
|
1451
|
+
# 1) Equality axioms operate on assertions or their related terms, not on nested terms (atom-level).
|
|
1452
|
+
# 2) Equivalence classes do not yet support FREEANY; op(1, op2(2)) records itself, not op(1, FREE).
|
|
1453
|
+
# Restricting to atoms would impact equivalence class extraction.
|
|
1454
|
+
|
|
1455
|
+
# ============= Gradually provide factory/helper functions to simplify writing and move toward frozen =============
|
|
1456
|
+
|
|
1457
|
+
# Variable entry
|
|
1458
|
+
class VariableFactory:
|
|
1459
|
+
"""Factory for Variable instances. Use vf.x or vf['x'] to create instances (same symbol still creates new instances)."""
|
|
1460
|
+
def __getattr__(self, name: HashableAndStringable) -> Variable:
|
|
1461
|
+
return Variable(name)
|
|
1462
|
+
|
|
1463
|
+
def __getitem__(self, item: HashableAndStringable) -> Variable:
|
|
1464
|
+
return Variable(item)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
vf = VariableFactory()
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
# ===========================Internal Class=====================================
|
|
1471
|
+
|
|
1472
|
+
class _QuestionRule(Rule):
|
|
1473
|
+
"""
|
|
1474
|
+
Internal Rule subclass used to carry Question variable mapping information.
|
|
1475
|
+
"""
|
|
1476
|
+
QUESTION_SOLVED_FLAG = Constant('QUESTION_SOLVED_FLAG', Concept("Bool")) # Marks whether the question is solved.
|
|
1477
|
+
QUESTIONRULE_NAME = "QUESTION_RULE"
|
|
1478
|
+
|
|
1479
|
+
def __init__(self, head: Assertion | Sequence[Assertion], body: FACT_TYPE | Sequence[FACT_TYPE],
|
|
1480
|
+
priority: float = 0.0, name: str | None = None):
|
|
1481
|
+
self.name = name if name is not None else self.QUESTIONRULE_NAME
|
|
1482
|
+
super().__init__(head, body, priority=priority, name=self.name)
|