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/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) -> MappingProxyType[str, Fact]:
83
+ def facts(self) -> Mapping[str, Fact]:
76
84
  return MappingProxyType(self._facts)
77
85
 
78
86
  @cached_property
79
- def rules(self) -> MappingProxyType[str, list[Rule]]:
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](self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None) -> None:
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 [self._facts[key.split(".")[0]] for key in keys]
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
- fired_rules: set[UUID] = set()
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 we already evaluated the rule this iteration
219
- if rule.id in fired_rules:
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
- fired_rules.add(rule.id)
245
+ evaluated_rules.add(rule.id)
222
246
 
223
- # Evaluate the rule's 'when' and determine which action to invoke
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
- if rule.when(*self._resolve_facts(rule.when)):
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
- # Update the facts and track consequences to fire subsequent rules
232
- result = action(*self._resolve_facts(action))
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
- # If rules updated some facts, prepare for the next iteration
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
- fired_rules.clear()
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 __call__(self, *args: Fact) -> R: ...
164
+ def _evaluate(self, *args: Fact) -> R: ...
165
165
 
166
166
 
167
167
  @runtime_checkable