vulcan-core 1.1.4__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of vulcan-core might be problematic. Click here for more details.
- vulcan_core/actions.py +3 -0
- vulcan_core/ast_utils.py +42 -49
- vulcan_core/conditions.py +134 -67
- vulcan_core/engine.py +63 -18
- vulcan_core/models.py +2 -2
- vulcan_core/reporting.py +595 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/METADATA +4 -3
- vulcan_core-1.2.0.dist-info/RECORD +13 -0
- vulcan_core-1.1.4.dist-info/RECORD +0 -12
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/LICENSE +0 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/NOTICE +0 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/WHEEL +0 -0
vulcan_core/engine.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import logging
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from functools import cached_property, partial
|
|
8
9
|
from types import MappingProxyType
|
|
@@ -11,11 +12,16 @@ from uuid import UUID, uuid4
|
|
|
11
12
|
|
|
12
13
|
from vulcan_core.ast_utils import NotAFactError
|
|
13
14
|
from vulcan_core.models import DeclaresFacts, Fact
|
|
15
|
+
from vulcan_core.reporting import Auditor
|
|
14
16
|
|
|
15
17
|
if TYPE_CHECKING: # pragma: no cover - not used at runtime
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
16
20
|
from vulcan_core.actions import Action
|
|
17
21
|
from vulcan_core.conditions import Expression
|
|
18
22
|
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
19
25
|
|
|
20
26
|
class InternalStateError(RuntimeError):
|
|
21
27
|
"""Raised when the internal state of the RuleEngine is invalid."""
|
|
@@ -63,20 +69,22 @@ class RuleEngine:
|
|
|
63
69
|
Methods:
|
|
64
70
|
rule(self, *, name: str | None = None, when: LogicEvaluator, then: BaseAction, inverse: BaseAction | None = None): Adds a rule to the rule engine.
|
|
65
71
|
update_facts(self, fact: tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact) -> Iterator[str]: Updates the facts in the working memory.
|
|
66
|
-
evaluate(self): Evaluates the rules based on the current facts in working memory.
|
|
72
|
+
evaluate(self, trace: bool = False): Evaluates the rules based on the current facts in working memory.
|
|
73
|
+
yaml_report(self): Returns the YAML report of the last evaluation (if tracing was enabled).
|
|
67
74
|
"""
|
|
68
75
|
|
|
69
76
|
enabled: bool = False
|
|
70
77
|
recusion_limit: int = 10
|
|
71
78
|
_facts: dict[str, Fact] = field(default_factory=dict, init=False)
|
|
72
79
|
_rules: dict[str, list[Rule]] = field(default_factory=dict, init=False)
|
|
80
|
+
_audit: Auditor = field(default_factory=Auditor, init=False)
|
|
73
81
|
|
|
74
82
|
@cached_property
|
|
75
|
-
def facts(self) ->
|
|
83
|
+
def facts(self) -> Mapping[str, Fact]:
|
|
76
84
|
return MappingProxyType(self._facts)
|
|
77
85
|
|
|
78
86
|
@cached_property
|
|
79
|
-
def rules(self) ->
|
|
87
|
+
def rules(self) -> Mapping[str, list[Rule]]:
|
|
80
88
|
return MappingProxyType(self._rules)
|
|
81
89
|
|
|
82
90
|
def __getitem__[T: Fact](self, key: type[T]) -> T:
|
|
@@ -124,7 +132,9 @@ class RuleEngine:
|
|
|
124
132
|
|
|
125
133
|
self._facts[type(fact).__name__] = fact
|
|
126
134
|
|
|
127
|
-
def rule[T: Fact](
|
|
135
|
+
def rule[T: Fact](
|
|
136
|
+
self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None
|
|
137
|
+
) -> None:
|
|
128
138
|
"""
|
|
129
139
|
Convenience method for adding a rule to the rule engine.
|
|
130
140
|
|
|
@@ -178,18 +188,22 @@ class RuleEngine:
|
|
|
178
188
|
|
|
179
189
|
return updated
|
|
180
190
|
|
|
181
|
-
def _resolve_facts(self, declared: DeclaresFacts) -> list[Fact]:
|
|
191
|
+
def _resolve_facts(self, declared: DeclaresFacts, facts: dict[str, Fact]) -> list[Fact]:
|
|
182
192
|
# Deduplicate the fact strings and retrieve unique fact instances
|
|
183
193
|
keys = {key.split(".")[0]: key for key in declared.facts}.values()
|
|
184
|
-
return [
|
|
194
|
+
return [facts[key.split(".")[0]] for key in keys]
|
|
185
195
|
|
|
186
|
-
def evaluate(self, fact: Fact | partial[Fact] | None = None):
|
|
196
|
+
def evaluate(self, fact: Fact | partial[Fact] | None = None, *, audit: bool = False):
|
|
187
197
|
"""
|
|
188
198
|
Cascading evaluation of rules based on the facts in working memory.
|
|
189
199
|
|
|
190
200
|
If provided a fact, will update and evaluate immediately. Otherwise all rules will be evaluated.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
fact: Optional fact to update and evaluate immediately
|
|
204
|
+
audit: Enables tracing for explanbility report generation
|
|
191
205
|
"""
|
|
192
|
-
|
|
206
|
+
evaluated_rules: set[UUID] = set()
|
|
193
207
|
consequence: set[str] = set()
|
|
194
208
|
|
|
195
209
|
# TODO: Create an internal consistency check to determine if all referenced Facts are present?
|
|
@@ -206,37 +220,68 @@ class RuleEngine:
|
|
|
206
220
|
fact_list = self._facts.values()
|
|
207
221
|
scope = {f"{fact.__class__.__name__}.{attr}" for fact in fact_list for attr in vars(fact)}
|
|
208
222
|
|
|
223
|
+
if audit:
|
|
224
|
+
self._audit.evaluation_reset()
|
|
225
|
+
|
|
209
226
|
# Iterate over the rules until the recusion limit is reached or no new rules are fired
|
|
210
227
|
for iteration in range(self.recusion_limit + 1):
|
|
211
228
|
if iteration == self.recusion_limit:
|
|
212
229
|
msg = f"Recursion limit of {self.recusion_limit} reached"
|
|
213
230
|
raise RecursionLimitError(msg)
|
|
214
231
|
|
|
232
|
+
# Ensure that rules do not interfere with one another in the same iteration
|
|
233
|
+
facts_snapshot = self._facts.copy()
|
|
234
|
+
|
|
235
|
+
if audit:
|
|
236
|
+
self._audit.iteration_start()
|
|
237
|
+
|
|
238
|
+
# Evaluate matching rules
|
|
215
239
|
for fact_str, rules in self._rules.items():
|
|
216
240
|
if fact_str in scope:
|
|
217
241
|
for rule in rules:
|
|
218
|
-
# Skip if
|
|
219
|
-
if rule.id in
|
|
242
|
+
# Skip the rule if it was already evaluated in this iteration (due to matching on another Fact)
|
|
243
|
+
if rule.id in evaluated_rules:
|
|
220
244
|
continue
|
|
221
|
-
|
|
245
|
+
evaluated_rules.add(rule.id)
|
|
222
246
|
|
|
223
|
-
#
|
|
247
|
+
# Skip if not all facts required by the rule are present
|
|
248
|
+
try:
|
|
249
|
+
resolved_facts = self._resolve_facts(rule.when, facts_snapshot)
|
|
250
|
+
except KeyError as e:
|
|
251
|
+
logger.debug("Rule %s (%s) skipped due to missing fact: %s", rule.name, rule.id, str(e))
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if audit:
|
|
255
|
+
self._audit.rule_start()
|
|
256
|
+
|
|
257
|
+
# Evaluate the rule and prepare the aciton
|
|
224
258
|
action = None
|
|
225
|
-
|
|
259
|
+
condition_result = rule.when(*resolved_facts)
|
|
260
|
+
if condition_result:
|
|
226
261
|
action = rule.then
|
|
227
262
|
elif rule.inverse:
|
|
228
263
|
action = rule.inverse
|
|
229
264
|
|
|
265
|
+
# Evaluate the action and update the consequences
|
|
266
|
+
action_result = None
|
|
230
267
|
if action:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
facts = self._update_facts(result)
|
|
268
|
+
action_result = action(*self._resolve_facts(action, facts_snapshot))
|
|
269
|
+
facts = self._update_facts(action_result)
|
|
234
270
|
consequence.update(facts)
|
|
235
271
|
|
|
236
|
-
|
|
272
|
+
if audit:
|
|
273
|
+
self._audit.rule_end(rule, action_result, facts_snapshot, condition_result=condition_result)
|
|
274
|
+
|
|
275
|
+
if audit:
|
|
276
|
+
self._audit.iteration_end()
|
|
277
|
+
|
|
278
|
+
# Check for next iteration
|
|
237
279
|
if consequence:
|
|
238
280
|
scope = consequence
|
|
239
281
|
consequence = set()
|
|
240
|
-
|
|
282
|
+
evaluated_rules.clear()
|
|
241
283
|
else:
|
|
242
284
|
break
|
|
285
|
+
|
|
286
|
+
def yaml_report(self) -> str:
|
|
287
|
+
return self._audit.generate_yaml_report()
|
vulcan_core/models.py
CHANGED
|
@@ -28,7 +28,7 @@ if TYPE_CHECKING: # pragma: no cover - not used at runtime
|
|
|
28
28
|
|
|
29
29
|
type ActionReturn = tuple[partial[Fact] | Fact, ...] | partial[Fact] | Fact
|
|
30
30
|
type ActionCallable = Callable[..., ActionReturn]
|
|
31
|
-
type ConditionCallable = Callable[..., bool]
|
|
31
|
+
type ConditionCallable = Callable[..., bool | None]
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
# TODO: Consolidate with AttrDict, and/or figure out how to extende from Mapping
|
|
@@ -161,7 +161,7 @@ class FactHandler[T: Callable, R: Any](ABC):
|
|
|
161
161
|
func: T
|
|
162
162
|
|
|
163
163
|
@abstractmethod
|
|
164
|
-
def
|
|
164
|
+
def _evaluate(self, *args: Fact) -> R: ...
|
|
165
165
|
|
|
166
166
|
|
|
167
167
|
@runtime_checkable
|