vulcan-core 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of vulcan-core might be problematic. Click here for more details.

vulcan_core/engine.py ADDED
@@ -0,0 +1,232 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2025 Latchfield Technologies http://latchfield.com
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from functools import cached_property, partial
8
+ from types import MappingProxyType
9
+ from typing import TYPE_CHECKING
10
+ from uuid import UUID, uuid4
11
+
12
+ from vulcan_core.models import DeclaresFacts, Fact
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - not used at runtime
15
+ from vulcan_core.actions import Action
16
+ from vulcan_core.conditions import Expression
17
+
18
+
19
+ class InternalStateError(RuntimeError):
20
+ """Raised when the internal state of the RuleEngine is invalid."""
21
+
22
+
23
+ class RecursionLimitError(RuntimeError):
24
+ """Raised when the recursion limit is reached during rule evaluation."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Rule:
29
+ """
30
+ Represents a rule with a condition and corresponding actions.
31
+
32
+ Attributes:
33
+ - id (UUID): A unique identifier for the rule, automatically generated.
34
+ - name (Optional[str]): The name of the rule.
35
+ - when (Expression): The condition that triggers the rule.
36
+ - then (Action): The action to be executed when the condition is met.
37
+ - inverse (Optional[Action]): An optional action to be executed when the condition is not met.
38
+ """
39
+
40
+ id: UUID = field(default_factory=uuid4, init=False)
41
+ name: str | None
42
+ when: Expression
43
+ then: Action
44
+ inverse: Action | None
45
+
46
+
47
+ # TODO: Look into support for langchain operators and lang graph integration
48
+
49
+
50
+ @dataclass(kw_only=True)
51
+ class RuleEngine:
52
+ """
53
+ RuleEngine is a class that manages the evaluation of rules based on a set of facts. It allows for the addition of rules,
54
+ updating of facts, and cascading evaluation of rules.
55
+
56
+ Attributes:
57
+ enabled (bool): Indicates whether the rule engine is enabled.
58
+ recusion_limit (int): The maximum number of recursive evaluations allowed.
59
+ facts (dict[type[Fact], Fact]): A dictionary to store facts with their types as keys.
60
+ rules (dict[str, list[Rule]]): A dictionary to store rules associated with fact strings.
61
+
62
+ Methods:
63
+ rule(self, *, name: str | None = None, when: LogicEvaluator, then: BaseAction, inverse: BaseAction | None = None): Adds a rule to the rule engine.
64
+ update_facts(self, fact: tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact) -> Iterator[str]: Updates the facts in the working memory.
65
+ evaluate(self): Evaluates the rules based on the current facts in working memory.
66
+ """
67
+
68
+ enabled: bool = False
69
+ recusion_limit: int = 10
70
+ _facts: dict[str, Fact] = field(default_factory=dict, init=False)
71
+ _rules: dict[str, list[Rule]] = field(default_factory=dict, init=False)
72
+
73
+ @cached_property
74
+ def facts(self) -> MappingProxyType[str, Fact]:
75
+ return MappingProxyType(self._facts)
76
+
77
+ @cached_property
78
+ def rules(self) -> MappingProxyType[str, list[Rule]]:
79
+ return MappingProxyType(self._rules)
80
+
81
+ def __getitem__[T: Fact](self, key: type[T]) -> T:
82
+ """
83
+ Retrieves a fact from the working memory.
84
+
85
+ Args:
86
+ key (type[Fact]): The type of the fact to retrieve.
87
+
88
+ Returns:
89
+ T: The fact instance of the specified type.
90
+ """
91
+ return self._facts[key.__name__] # type: ignore
92
+
93
+ def fact(self, fact: Fact | partial[Fact]):
94
+ """
95
+ Updates the working memory with a new fact or merges a partial fact.
96
+
97
+ Args:
98
+ fact (Union[Fact, partial[Fact]]): The fact instance or partial fact to update the working memory with.
99
+
100
+ Raises:
101
+ InternalStateError: If a partial fact cannot be instantiated due to missing required fields
102
+ """
103
+ # TODO: Figure out how to track only fact attributes that have changed, and fire on affected rules
104
+
105
+ if isinstance(fact, partial):
106
+ fact_name = fact.func.__name__
107
+ if fact_name in self._facts:
108
+ self._facts[fact_name] |= fact
109
+ else:
110
+ try:
111
+ self._facts[fact_name] = fact()
112
+ except TypeError as err:
113
+ msg = f"Fact '{fact_name}' is missing and lacks sufficient defaults to create from partial: {fact}"
114
+ raise InternalStateError(msg) from err
115
+ else:
116
+ self._facts[type(fact).__name__] = fact
117
+
118
+ def rule[T: Fact](self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None):
119
+ """
120
+ Convenience method for adding a rule to the rule engine.
121
+
122
+ Args:
123
+ name (Optional[str]): The name of the rule. Defaults to None.
124
+ when (Expression): The condition that triggers the rule.
125
+ then (Action): The action to be executed when the condition is met.
126
+ inverse (Optional[Action]): The action to be executed when the condition is not met. Defaults to None.
127
+
128
+ Returns: None
129
+ """
130
+ rule = Rule(name, when, then, inverse)
131
+
132
+ # TODO: Add automatic inverse option?
133
+
134
+ # Update the facts to rule mapping
135
+ for fact_str in when.facts:
136
+ if fact_str in self._rules:
137
+ self._rules[fact_str].append(rule)
138
+ else:
139
+ self._rules[fact_str] = [rule]
140
+
141
+ def _update_facts(self, fact: tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact) -> list[str]:
142
+ """
143
+ Updates the fact in the facts dictionary. If the provided fact is an instance of Fact, it updates the dictionary
144
+ with the type of the fact as the key. If the provided fact is a partial function, it updates the dictionary with
145
+ the function of the partial as the key.
146
+
147
+ Args:
148
+ fact (tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact): The fact(s) to be updated, either as an instance of Fact, a partial function, or a tuple of either.
149
+
150
+ Returns:
151
+ Iterator[str]: An iterator over the fact strings of the updated facts.
152
+ """
153
+ facts = fact if isinstance(fact, tuple) else (fact,)
154
+ updated = []
155
+
156
+ for f in facts:
157
+ self.fact(f)
158
+
159
+ # Track which attributes were updated
160
+ if isinstance(f, partial):
161
+ fact_name = f.func.__name__
162
+ attrs = f.keywords
163
+ else:
164
+ fact_name = f.__class__.__name__
165
+ attrs = vars(f)
166
+
167
+ updated.extend([f"{fact_name}.{attr}" for attr in attrs])
168
+
169
+ return updated
170
+
171
+ def _resolve_facts(self, declared: DeclaresFacts) -> list[Fact]:
172
+ # Deduplicate the fact strings and retrieve unique fact instances
173
+ keys = {key.split(".")[0]: key for key in declared.facts}.values()
174
+ return [self._facts[key.split(".")[0]] for key in keys]
175
+
176
+ def evaluate(self, fact: Fact | partial[Fact] | None = None):
177
+ """
178
+ Cascading evaluation of rules based on the facts in working memory.
179
+
180
+ If provided a fact, will update and evaluate immediately. Otherwise all rules will be evaluated.
181
+ """
182
+ fired_rules: set[UUID] = set()
183
+ consequence: set[str] = set()
184
+
185
+ # TODO: Create an internal consistency check to determine if all referenced Facts are present?
186
+
187
+ # TODO: detect cycles in graph before executing
188
+ # Move to a separate lifecycle step?
189
+ # Provide option for handling
190
+
191
+ # TODO: Check whether fact attributes have actually changed, and only fire rules that are affected
192
+ if fact:
193
+ scope = self._update_facts(fact)
194
+ else:
195
+ # By default, evaluate all facts
196
+ fact_list = self._facts.values()
197
+ scope = {f"{fact.__class__.__name__}.{attr}" for fact in fact_list for attr in vars(fact)}
198
+
199
+ # Iterate over the rules until the recusion limit is reached or no new rules are fired
200
+ for iteration in range(self.recusion_limit + 1):
201
+ if iteration == self.recusion_limit:
202
+ msg = f"Recursion limit of {self.recusion_limit} reached"
203
+ raise RecursionLimitError(msg)
204
+
205
+ for fact_str, rules in self._rules.items():
206
+ if fact_str in scope:
207
+ for rule in rules:
208
+ # Skip if we already evaluated the rule this iteration
209
+ if rule.id in fired_rules:
210
+ continue
211
+ fired_rules.add(rule.id)
212
+
213
+ # Evaluate the rule's 'when' and determine which action to invoke
214
+ action = None
215
+ if rule.when(*self._resolve_facts(rule.when)):
216
+ action = rule.then
217
+ elif rule.inverse:
218
+ action = rule.inverse
219
+
220
+ if action:
221
+ # Update the facts and track consequences to fire subsequent rules
222
+ result = action(*self._resolve_facts(action))
223
+ facts = self._update_facts(result)
224
+ consequence.update(facts)
225
+
226
+ # If rules updated some facts, prepare for the next iteration
227
+ if consequence:
228
+ scope = consequence
229
+ consequence = set()
230
+ fired_rules.clear()
231
+ else:
232
+ break
vulcan_core/models.py ADDED
@@ -0,0 +1,260 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2025 Latchfield Technologies http://latchfield.com
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Callable, Iterator, Mapping
8
+ from copy import copy
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum, auto
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Protocol,
15
+ Self,
16
+ dataclass_transform,
17
+ runtime_checkable,
18
+ )
19
+
20
+ from langchain.schema import Document
21
+
22
+ from vulcan_core.util import is_private
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - not used at runtime
25
+ from functools import partial
26
+
27
+ from langchain_core.vectorstores import VectorStoreRetriever
28
+
29
+ type ActionReturn = tuple[partial[Fact] | Fact, ...] | partial[Fact] | Fact
30
+ type ActionCallable = Callable[..., ActionReturn]
31
+ type ConditionCallable = Callable[..., bool]
32
+
33
+
34
+ # TODO: Consolidate with AttrDict, and/or figure out how to extende from Mapping
35
+ class ImmutableAttrAsDict:
36
+ """
37
+ ImmutableAttrAsDict is an abstract base class that provides dictionary-like access to its attributes.
38
+ """
39
+
40
+ def __getitem__(self, key: str) -> Any:
41
+ try:
42
+ return getattr(self, self.validate(key))
43
+ except KeyError:
44
+ if hasattr(self, "__missing__"):
45
+ return self.__missing__(key) # type: ignore
46
+ else:
47
+ raise
48
+
49
+ def __contains__(self, key: str) -> bool:
50
+ return hasattr(self, self.validate(key))
51
+
52
+ def __iter__(self) -> Iterator[str]:
53
+ return (key for key in self.__annotations__ if not is_private(key))
54
+
55
+ def __len__(self) -> int:
56
+ return sum(1 for _ in self)
57
+
58
+ def validate(self, key: str) -> str:
59
+ if is_private(key):
60
+ msg = f"Access denied to private attribute: {key}"
61
+ raise KeyError(msg)
62
+
63
+ if key not in self.__annotations__:
64
+ raise KeyError(key)
65
+
66
+ return key
67
+
68
+ def __init__(self):
69
+ if type(self) is ImmutableAttrAsDict:
70
+ msg = f"{ImmutableAttrAsDict.__name__} is an abstract class that can not be directly instantiated."
71
+ raise TypeError(msg)
72
+
73
+ def __reversed__(self) -> Iterator[str]:
74
+ return reversed(list(self))
75
+
76
+ def __or__(self, other: dict) -> dict:
77
+ return dict(self) | other
78
+
79
+ def keys(self) -> list[str]:
80
+ return list(self)
81
+
82
+ def values(self) -> list[Any]:
83
+ return [getattr(self, key) for key in self]
84
+
85
+ def items(self) -> list[tuple[str, Any]]:
86
+ return [(key, getattr(self, key)) for key in self]
87
+
88
+ def get(self, key: str, default: Any = None):
89
+ return getattr(self, self.validate(key), default)
90
+
91
+
92
+ @dataclass_transform(kw_only_default=True, frozen_default=True)
93
+ class FactMetaclass(type):
94
+ """
95
+ FactMetaclass is a metaclass that modifies the creation of new classes to automatically
96
+ apply the `dataclass` decorator with `kw_only=True` and `frozen=True` options.
97
+ """
98
+
99
+ def __new__(cls, name: str, bases: tuple[type], class_dict: dict[str, Any], **kwargs: Any):
100
+ self = super().__new__(cls, name, bases, class_dict, **kwargs)
101
+ return dataclass(kw_only=True, frozen=True)(self)
102
+
103
+ def _is_dataclass_instance(cls) -> bool:
104
+ """Determine if this is a dataclass instance by looking for __dataclass_fields__"""
105
+ return "__dataclass_fields__" not in super().__getattribute__("__dict__")
106
+
107
+ # TODO: Implement a context manager to allow access to the default class values
108
+ # BUG: This causes pylance to not report missing attributes, we need a different way to handle f strings... maybe
109
+ # the __format__ method?
110
+ def __getattribute__(cls, name):
111
+ """
112
+ Returns a {templated} representation of the Fact's public attributes for deferred use in fstrings. This is
113
+ useful in rule clauses so that IDE autocomplete can be used in fstrings while deferring evaluation of
114
+ the content."""
115
+ if name.startswith("_") or cls._is_dataclass_instance():
116
+ return super().__getattribute__(name)
117
+ else:
118
+ return f"{{{cls.__name__}.{name}}}"
119
+
120
+
121
+ class Fact(ImmutableAttrAsDict, metaclass=FactMetaclass):
122
+ """
123
+ An abstract class that must be used define rule engine fact schemas and instantiate data into working memory. Facts
124
+ may be combined with partial facts of the same type using the `|` operator. This is useful for Actions that only
125
+ need to update a portion of working memory.
126
+
127
+ Example: `new_fact = Inventory(apples=1) | partial(Inventory, oranges=2)`
128
+ """
129
+
130
+ def __or__(self, other: partial[Self] | Self) -> Self:
131
+ """
132
+ If the right hand operand is a Fact, it is returned as-is. However, if it is a partial Fact, a copy of the
133
+ lefthand operand is created with the partial Fact's keywords applied.
134
+ """
135
+ if isinstance(other, Fact):
136
+ return other # type: ignore
137
+ else:
138
+ new_fact = copy(self)
139
+ for kw, value in other.keywords.items():
140
+ object.__setattr__(new_fact, kw, value)
141
+ return new_fact # type: ignore
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class DeclaresFacts(ABC):
146
+ facts: tuple[str, ...]
147
+ # TODO differentiate bettwen facts consumed vs produced for better tracking/diagnostics
148
+ # Will probably be needed to detecte cycles in the graph
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class FactHandler[T: Callable, R: Any](ABC):
153
+ func: T
154
+
155
+ @abstractmethod
156
+ def __call__(self, *args: Fact) -> R: ...
157
+
158
+
159
+ @runtime_checkable
160
+ class HasSource(Protocol):
161
+ __source__: str
162
+
163
+
164
+ class ChunkingStrategy(StrEnum):
165
+ SENTENCE = auto()
166
+ PARAGRAPH = auto()
167
+ PAGE = auto()
168
+
169
+
170
+ @dataclass(kw_only=True, slots=True)
171
+ class Similarity(Mapping[str, list[tuple[str, float]]]):
172
+ # TODO: Figure out how to cache vectors / and results?
173
+
174
+ @abstractmethod
175
+ def __getitem__(self, key: str) -> list[str]:
176
+ """Vectorizes key and performs similarity search returning a list of matching."""
177
+ raise NotImplementedError
178
+
179
+ @abstractmethod
180
+ def __contains__(self, key: str) -> bool:
181
+ """Vectorizes key and performs similarity search returning a boolean if there is at least one match."""
182
+ raise NotImplementedError
183
+
184
+ @abstractmethod
185
+ def __iadd__(self, value: str) -> Self:
186
+ raise NotImplementedError
187
+
188
+ @abstractmethod
189
+ def __iter__(self) -> str:
190
+ raise NotImplementedError
191
+
192
+ @abstractmethod
193
+ def __len__(self) -> int:
194
+ raise NotImplementedError
195
+
196
+
197
+ class ProxyInitializationError(Exception):
198
+ """Raised when a Proxy class is used without the proxy being initialized."""
199
+
200
+
201
+ @dataclass(kw_only=True, slots=True)
202
+ class ProxyLazyLookup(Similarity):
203
+ _proxy: Similarity | None = None
204
+
205
+ @property
206
+ def proxy(self) -> Similarity:
207
+ if self._proxy:
208
+ return self
209
+ else:
210
+ msg = "The `proxy` attribute must be set before the class instance can be used."
211
+ raise ProxyInitializationError(msg)
212
+
213
+ @proxy.setter
214
+ def proxy(self, value: Similarity) -> None:
215
+ if not self._proxy:
216
+ self._proxy = value
217
+ else:
218
+ msg = "The `proxy` attribute can only be initialized once."
219
+ raise ProxyInitializationError(msg)
220
+
221
+ def __getitem__(self, key: str) -> list[str]:
222
+ return self.proxy[key]
223
+
224
+ def __contains__(self, key: str) -> bool:
225
+ raise NotImplementedError
226
+
227
+ def __iadd__(self, value: str) -> Self:
228
+ self.proxy += value
229
+ return self
230
+
231
+ def __iter__(self) -> str:
232
+ raise NotImplementedError
233
+
234
+ def __len__(self) -> int:
235
+ raise NotImplementedError
236
+
237
+
238
+ @dataclass(kw_only=True, slots=True)
239
+ class RetrieverAdapter(Similarity):
240
+ """A lazy lookup that uses the Chroma vector store to perform similarity searches using OpenAI embeddings."""
241
+
242
+ store: VectorStoreRetriever
243
+
244
+ def __getitem__(self, key: str) -> list[str]:
245
+ """Vectorizes key and performs similarity search returning a list of matching content."""
246
+ return [doc.page_content for doc in self.store.invoke(key)]
247
+
248
+ def __contains__(self, key: str) -> bool:
249
+ """Vectorizes key and performs similarity search returning a boolean if there is at least one match."""
250
+ raise NotImplementedError
251
+
252
+ def __iadd__(self, value: str) -> Self:
253
+ self.store.add_documents([Document(value)])
254
+ return self
255
+
256
+ def __iter__(self) -> str:
257
+ raise NotImplementedError
258
+
259
+ def __len__(self) -> int:
260
+ raise NotImplementedError
vulcan_core/util.py ADDED
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2025 Latchfield Technologies http://latchfield.com
3
+
4
+ import functools
5
+ from collections.abc import Callable, Iterator
6
+ from contextlib import AbstractContextManager
7
+ from dataclasses import dataclass
8
+ from functools import wraps
9
+ from typing import Any, NoReturn
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class WithContext:
14
+ """Applys a context manager as a decorator.
15
+
16
+ @WithContext(suppress(Exception))
17
+ def foo():
18
+ raise Exception("Some Exception")
19
+ """
20
+
21
+ context: AbstractContextManager
22
+
23
+ def __call__(self, func):
24
+ @wraps(func)
25
+ def wrapper(*args, **kwargs):
26
+ with self.context:
27
+ return func(*args, **kwargs)
28
+
29
+ return wrapper
30
+
31
+
32
+ def not_implemented(func) -> Callable:
33
+ @functools.wraps(func)
34
+ def wrapper(*args, **kwargs) -> NoReturn:
35
+ msg = f"{func.__name__} is not implemented."
36
+ raise NotImplementedError(msg)
37
+
38
+ return wrapper
39
+
40
+
41
+ def is_private(key: str) -> bool:
42
+ return key.startswith("_")
43
+
44
+
45
+ class AttrDict(dict):
46
+ def validate(self, key: str) -> str:
47
+ if is_private(key):
48
+ msg = f"Access denied to private attribute: {key}"
49
+ raise KeyError(msg)
50
+
51
+ if key not in self.__annotations__:
52
+ raise KeyError(key)
53
+
54
+ return key
55
+
56
+ def __init__(self):
57
+ if type(self) is AttrDict:
58
+ msg = f"{AttrDict.__name__} is an abstract class that can not be directly instantiated."
59
+ raise TypeError(msg)
60
+
61
+ def __getitem__(self, key: str) -> Any:
62
+ try:
63
+ return getattr(self, self.validate(key))
64
+ except KeyError:
65
+ if hasattr(self, "__missing__"):
66
+ return self.__missing__(key) # type: ignore
67
+ else:
68
+ raise
69
+
70
+ def __setitem__(self, key: str, value: Any) -> None:
71
+ setattr(self, self.validate(key), value)
72
+
73
+ def __iter__(self) -> Iterator[str]:
74
+ return (key for key in self.__annotations__ if not is_private(key))
75
+
76
+ def __reversed__(self) -> Iterator[str]:
77
+ return reversed(list(self))
78
+
79
+ def __len__(self) -> int:
80
+ return sum(1 for _ in self)
81
+
82
+ def __contains__(self, key: str) -> bool:
83
+ return hasattr(self, self.validate(key))
84
+
85
+ def __or__(self, other: dict) -> dict:
86
+ return dict(self) | other
87
+
88
+ def __repr__(self) -> str:
89
+ return repr(dict(self))
90
+
91
+ def keys(self) -> list[str]:
92
+ return list(self)
93
+
94
+ def values(self) -> list[Any]:
95
+ return [getattr(self, key) for key in self]
96
+
97
+ def items(self) -> list[tuple[str, Any]]:
98
+ return [(key, getattr(self, key)) for key in self]
99
+
100
+ def get(self, key: str, default: Any = None):
101
+ return getattr(self, self.validate(key), default)
102
+
103
+ def setdefault(self, key: str, default: Any = None) -> Any:
104
+ if key not in self:
105
+ self[key] = default
106
+ return self[key]
107
+
108
+ @not_implemented
109
+ def __delitem__(self, key: str) -> NoReturn: ...
110
+
111
+ @not_implemented
112
+ def __ior__(self, other: dict[str, Any]) -> NoReturn: ...
113
+
114
+ @not_implemented
115
+ def clear(self) -> NoReturn: ...
116
+
117
+ @not_implemented
118
+ def copy(self) -> NoReturn: ...
119
+
120
+ @not_implemented
121
+ def pop(self, key: str, defaul: Any = None) -> NoReturn: ...
122
+
123
+ @not_implemented
124
+ def popitem(self) -> NoReturn: ...
125
+
126
+ @not_implemented
127
+ def update(self, *args, **kwargs) -> NoReturn: ...