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.
Files changed (74) hide show
  1. kele/__init__.py +38 -0
  2. kele/_version.py +1 -0
  3. kele/config.py +243 -0
  4. kele/control/README_metrics.md +102 -0
  5. kele/control/__init__.py +20 -0
  6. kele/control/callback.py +255 -0
  7. kele/control/grounding_selector/__init__.py +5 -0
  8. kele/control/grounding_selector/_rule_strategies/README.md +13 -0
  9. kele/control/grounding_selector/_rule_strategies/__init__.py +24 -0
  10. kele/control/grounding_selector/_rule_strategies/_sequential_strategy.py +42 -0
  11. kele/control/grounding_selector/_rule_strategies/strategy_protocol.py +51 -0
  12. kele/control/grounding_selector/_selector_utils.py +123 -0
  13. kele/control/grounding_selector/_term_strategies/__init__.py +24 -0
  14. kele/control/grounding_selector/_term_strategies/_exhausted_strategy.py +34 -0
  15. kele/control/grounding_selector/_term_strategies/strategy_protocol.py +50 -0
  16. kele/control/grounding_selector/rule_selector.py +98 -0
  17. kele/control/grounding_selector/term_selector.py +89 -0
  18. kele/control/infer_path.py +306 -0
  19. kele/control/metrics.py +357 -0
  20. kele/control/status.py +286 -0
  21. kele/egg_equiv.pyd +0 -0
  22. kele/egg_equiv.pyi +11 -0
  23. kele/equality/README.md +8 -0
  24. kele/equality/__init__.py +4 -0
  25. kele/equality/_egg_equiv/src/lib.rs +267 -0
  26. kele/equality/_equiv_elem.py +67 -0
  27. kele/equality/_utils.py +36 -0
  28. kele/equality/equivalence.py +141 -0
  29. kele/executer/__init__.py +4 -0
  30. kele/executer/executing.py +139 -0
  31. kele/grounder/README.md +83 -0
  32. kele/grounder/__init__.py +17 -0
  33. kele/grounder/grounded_rule_ds/__init__.py +6 -0
  34. kele/grounder/grounded_rule_ds/_nodes/__init__.py +24 -0
  35. kele/grounder/grounded_rule_ds/_nodes/_assertion.py +353 -0
  36. kele/grounder/grounded_rule_ds/_nodes/_conn.py +116 -0
  37. kele/grounder/grounded_rule_ds/_nodes/_op.py +57 -0
  38. kele/grounder/grounded_rule_ds/_nodes/_root.py +71 -0
  39. kele/grounder/grounded_rule_ds/_nodes/_rule.py +119 -0
  40. kele/grounder/grounded_rule_ds/_nodes/_term.py +390 -0
  41. kele/grounder/grounded_rule_ds/_nodes/_tftable.py +15 -0
  42. kele/grounder/grounded_rule_ds/_nodes/_tupletable.py +444 -0
  43. kele/grounder/grounded_rule_ds/_nodes/_typing_polars.py +26 -0
  44. kele/grounder/grounded_rule_ds/grounded_class.py +461 -0
  45. kele/grounder/grounded_rule_ds/grounded_ds_utils.py +91 -0
  46. kele/grounder/grounded_rule_ds/rule_check.py +373 -0
  47. kele/grounder/grounding.py +118 -0
  48. kele/knowledge_bases/README.md +112 -0
  49. kele/knowledge_bases/__init__.py +6 -0
  50. kele/knowledge_bases/builtin_base/__init__.py +1 -0
  51. kele/knowledge_bases/builtin_base/builtin_concepts.py +13 -0
  52. kele/knowledge_bases/builtin_base/builtin_facts.py +43 -0
  53. kele/knowledge_bases/builtin_base/builtin_operators.py +105 -0
  54. kele/knowledge_bases/builtin_base/builtin_rules.py +14 -0
  55. kele/knowledge_bases/fact_base.py +158 -0
  56. kele/knowledge_bases/ontology_base.py +67 -0
  57. kele/knowledge_bases/rule_base.py +194 -0
  58. kele/main.py +464 -0
  59. kele/py.typed +0 -0
  60. kele/syntax/CONCEPT_README.md +117 -0
  61. kele/syntax/__init__.py +40 -0
  62. kele/syntax/_cnf_converter.py +161 -0
  63. kele/syntax/_sat_solver.py +116 -0
  64. kele/syntax/base_classes.py +1482 -0
  65. kele/syntax/connectives.py +20 -0
  66. kele/syntax/dnf_converter.py +145 -0
  67. kele/syntax/external.py +17 -0
  68. kele/syntax/sub_concept.py +87 -0
  69. kele/syntax/syntacticsugar.py +201 -0
  70. kele-0.0.1a1.dist-info/METADATA +166 -0
  71. kele-0.0.1a1.dist-info/RECORD +74 -0
  72. kele-0.0.1a1.dist-info/WHEEL +4 -0
  73. kele-0.0.1a1.dist-info/licenses/LICENSE +28 -0
  74. 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)