vulcan-core 1.1.2__tar.gz → 1.1.4__tar.gz
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-1.1.2 → vulcan_core-1.1.4}/PKG-INFO +2 -2
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/README.md +1 -1
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/pyproject.toml +1 -1
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/ast_utils.py +134 -34
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/LICENSE +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/NOTICE +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/__init__.py +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/actions.py +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/conditions.py +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/engine.py +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/models.py +0 -0
- {vulcan_core-1.1.2 → vulcan_core-1.1.4}/src/vulcan_core/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: vulcan-core
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.4
|
|
4
4
|
Summary: AI-Hybrid Rules Engine for Logical Reasoning.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Keywords: rules,logic,reasoning,ai,artificial intelligence,RAG,LLM
|
|
@@ -65,7 +65,7 @@ engine.rule(
|
|
|
65
65
|
|
|
66
66
|
# Use computed logic for operations that must be correct:
|
|
67
67
|
engine.rule(
|
|
68
|
-
when=condition(Apple.baking
|
|
68
|
+
when=condition(lambda: Apple.baking and Inventory.apples < 10),
|
|
69
69
|
then=action(Order(apples=10)),
|
|
70
70
|
)
|
|
71
71
|
|
|
@@ -42,7 +42,7 @@ engine.rule(
|
|
|
42
42
|
|
|
43
43
|
# Use computed logic for operations that must be correct:
|
|
44
44
|
engine.rule(
|
|
45
|
-
when=condition(Apple.baking
|
|
45
|
+
when=condition(lambda: Apple.baking and Inventory.apples < 10),
|
|
46
46
|
then=action(Order(apples=10)),
|
|
47
47
|
)
|
|
48
48
|
|
|
@@ -20,7 +20,7 @@ requires = ["poetry-core"]
|
|
|
20
20
|
build-backend = "poetry.core.masonry.api"
|
|
21
21
|
|
|
22
22
|
[tool.poetry]
|
|
23
|
-
version = "1.1.
|
|
23
|
+
version = "1.1.4" # Update manually, or use plugin
|
|
24
24
|
packages = [{ include = "vulcan_core", from="src" }]
|
|
25
25
|
requires-poetry = "~2.1.1"
|
|
26
26
|
classifiers = [
|
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
import ast
|
|
5
5
|
import inspect
|
|
6
|
+
import logging
|
|
6
7
|
import re
|
|
7
8
|
import textwrap
|
|
8
9
|
from ast import Attribute, Module, Name, NodeTransformer, NodeVisitor
|
|
10
|
+
from collections import OrderedDict
|
|
9
11
|
from collections.abc import Callable
|
|
10
12
|
from dataclasses import dataclass, field
|
|
11
13
|
from functools import cached_property
|
|
12
14
|
from types import MappingProxyType
|
|
13
|
-
from typing import Any, TypeAliasType, get_type_hints
|
|
15
|
+
from typing import Any, ClassVar, TypeAliasType, get_type_hints
|
|
14
16
|
|
|
15
17
|
from vulcan_core.models import Fact, HasSource
|
|
16
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
class ASTProcessingError(RuntimeError):
|
|
19
23
|
"""Internal error encountered while processing AST."""
|
|
@@ -83,15 +87,63 @@ class AttributeTransformer(NodeTransformer):
|
|
|
83
87
|
return node
|
|
84
88
|
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class LambdaSource:
|
|
92
|
+
"""Index entry for tracking the parsing position of lambda functions in source lines.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
source (str): The source code string containing lambda functions
|
|
96
|
+
count (int): The number of lambda functions found in the source string.
|
|
97
|
+
pos (int): The current parsing position within the source string.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
source: str
|
|
101
|
+
count: int
|
|
102
|
+
pos: int = field(default=0)
|
|
103
|
+
in_use: bool = field(default=True)
|
|
91
104
|
|
|
92
105
|
|
|
93
106
|
@dataclass
|
|
94
107
|
class ASTProcessor[T: Callable]:
|
|
108
|
+
"""
|
|
109
|
+
This class extracts source code from functions or lambda expressions, parses them into
|
|
110
|
+
Abstract Syntax Trees (AST), and performs various validations and transformations.
|
|
111
|
+
|
|
112
|
+
The processor validates that:
|
|
113
|
+
- Functions have proper type hints for parameters and return types
|
|
114
|
+
- All parameters are subclasses of Fact
|
|
115
|
+
- No nested attribute access (e.g., X.y.z) is used
|
|
116
|
+
- No async functions are processed
|
|
117
|
+
- Lambda expressions do not contain parameters
|
|
118
|
+
- No duplicate parameter types in function signatures
|
|
119
|
+
|
|
120
|
+
For lambda expressions, it automatically transforms attribute access patterns
|
|
121
|
+
(e.g., ClassName.attribute) into parameterized functions for easier execution.
|
|
122
|
+
|
|
123
|
+
Note: This class is not thread-safe and should not be used concurrently across multiple threads.
|
|
124
|
+
|
|
125
|
+
Type Parameters:
|
|
126
|
+
T: The type signature the processor is working with, this varies based on a condition or action being processed.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
func: The callable to process, a lambda or a function
|
|
130
|
+
decorator: The decorator type that initiated the processing (e.g., `condition` or `action`)
|
|
131
|
+
return_type: Expected return type for the callable
|
|
132
|
+
source: Extracted source code of func (set during post-init)
|
|
133
|
+
tree: Parsed AST of the source code (set during post-init)
|
|
134
|
+
facts: Tuple of fact strings discovered in the callable (set during post-init)
|
|
135
|
+
|
|
136
|
+
Properties:
|
|
137
|
+
is_lambda: True if the callable is a lambda expression
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
OSError: When source code cannot be extracted
|
|
141
|
+
ScopeAccessError: When accessing undefined classes or using nested attributes
|
|
142
|
+
CallableSignatureError: When function signature doesn't meet requirements
|
|
143
|
+
NotAFactError: When parameter types are not Fact subclasses
|
|
144
|
+
ASTProcessingError: When AST processing encounters internal errors
|
|
145
|
+
"""
|
|
146
|
+
|
|
95
147
|
func: T
|
|
96
148
|
decorator: Callable
|
|
97
149
|
return_type: type | TypeAliasType
|
|
@@ -99,6 +151,10 @@ class ASTProcessor[T: Callable]:
|
|
|
99
151
|
tree: Module = field(init=False)
|
|
100
152
|
facts: tuple[str, ...] = field(init=False)
|
|
101
153
|
|
|
154
|
+
# Class-level tracking of lambdas across parsing calls to handle multiple lambdas on the same line
|
|
155
|
+
_lambda_cache: ClassVar[OrderedDict[str, LambdaSource]] = OrderedDict()
|
|
156
|
+
_MAX_LAMBDA_CACHE_SIZE: ClassVar[int] = 1024
|
|
157
|
+
|
|
102
158
|
@cached_property
|
|
103
159
|
def is_lambda(self) -> bool:
|
|
104
160
|
return isinstance(self.func, type(lambda: None)) and self.func.__name__ == "<lambda>"
|
|
@@ -111,24 +167,34 @@ class ASTProcessor[T: Callable]:
|
|
|
111
167
|
try:
|
|
112
168
|
if self.is_lambda:
|
|
113
169
|
# As of Python 3.12, there is no way to determine to which lambda self.func refers in an
|
|
114
|
-
# expression containing multiple lambdas. Therefore we use a
|
|
170
|
+
# expression containing multiple lambdas. Therefore we use a dict to track the index of each
|
|
115
171
|
# lambda function encountered, as the order will correspond to the order of ASTProcessor
|
|
116
172
|
# invocations for that line. An additional benefit is that we can also use this as a cache to
|
|
117
173
|
# avoid re-reading the source code for lambda functions sharing the same line.
|
|
118
|
-
|
|
174
|
+
source_line = f"{self.func.__code__.co_filename}:{self.func.__code__.co_firstlineno}"
|
|
175
|
+
lambda_src = self._lambda_cache.get(source_line)
|
|
119
176
|
|
|
120
|
-
|
|
121
|
-
if index is None or index[1] is None:
|
|
177
|
+
if lambda_src is None:
|
|
122
178
|
self.source = self._get_lambda_source()
|
|
123
|
-
|
|
124
|
-
|
|
179
|
+
lambda_count = self._count_lambdas(self.source)
|
|
180
|
+
lambda_src = LambdaSource(self.source, lambda_count)
|
|
181
|
+
self._lambda_cache[source_line] = lambda_src
|
|
182
|
+
self._trim_lambda_cache()
|
|
125
183
|
else:
|
|
126
|
-
self.source =
|
|
127
|
-
|
|
128
|
-
|
|
184
|
+
self.source = lambda_src.source
|
|
185
|
+
lambda_src.pos += 1
|
|
186
|
+
|
|
187
|
+
# Reset the position if it exceeds the count of lambda expressions
|
|
188
|
+
if lambda_src.pos >= lambda_src.count:
|
|
189
|
+
lambda_src.pos = 0
|
|
129
190
|
|
|
130
191
|
# Normalize the lambda source and extract the next lambda expression from the last index
|
|
131
|
-
self.source = self._normalize_lambda_source(self.source,
|
|
192
|
+
self.source = self._normalize_lambda_source(self.source, lambda_src.pos)
|
|
193
|
+
|
|
194
|
+
# If done processing lambdas in the source, mark as not processing anymore
|
|
195
|
+
if lambda_src.pos >= lambda_src.count - 1:
|
|
196
|
+
lambda_src.in_use = False
|
|
197
|
+
|
|
132
198
|
else:
|
|
133
199
|
self.source = textwrap.dedent(inspect.getsource(self.func))
|
|
134
200
|
except OSError as e:
|
|
@@ -172,19 +238,48 @@ class ASTProcessor[T: Callable]:
|
|
|
172
238
|
|
|
173
239
|
self.facts = tuple(facts)
|
|
174
240
|
|
|
241
|
+
def _trim_lambda_cache(self) -> None:
|
|
242
|
+
"""Clean up lambda cache by removing oldest unused entries when cache size exceeds limit."""
|
|
243
|
+
if len(self._lambda_cache) <= self._MAX_LAMBDA_CACHE_SIZE:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Calculate how many entries to remove (excess + 20% buffer to avoid thrashing)
|
|
247
|
+
excess_count = len(self._lambda_cache) - self._MAX_LAMBDA_CACHE_SIZE
|
|
248
|
+
buffer_count = int(self._MAX_LAMBDA_CACHE_SIZE * 0.2)
|
|
249
|
+
target_count = excess_count + buffer_count
|
|
250
|
+
|
|
251
|
+
# Find and remove unused entries
|
|
252
|
+
removed_count = 0
|
|
253
|
+
for key in list(self._lambda_cache):
|
|
254
|
+
if removed_count >= target_count:
|
|
255
|
+
break
|
|
256
|
+
if not self._lambda_cache[key].in_use:
|
|
257
|
+
del self._lambda_cache[key]
|
|
258
|
+
removed_count += 1
|
|
259
|
+
|
|
260
|
+
def _count_lambdas(self, source: str) -> int:
|
|
261
|
+
"""Count lambda expressions in source code using AST parsing."""
|
|
262
|
+
tree = ast.parse(source)
|
|
263
|
+
|
|
264
|
+
class LambdaCounter(ast.NodeVisitor):
|
|
265
|
+
def __init__(self):
|
|
266
|
+
self.count = 0
|
|
267
|
+
|
|
268
|
+
def visit_Lambda(self, node): # noqa: N802 - Case sensitive for AST
|
|
269
|
+
self.count += 1
|
|
270
|
+
self.generic_visit(node)
|
|
271
|
+
|
|
272
|
+
counter = LambdaCounter()
|
|
273
|
+
counter.visit(tree)
|
|
274
|
+
return counter.count
|
|
275
|
+
|
|
175
276
|
def _get_lambda_source(self) -> str:
|
|
176
277
|
"""Get single and multiline lambda source using AST parsing of the source file."""
|
|
177
|
-
|
|
178
|
-
# Get caller frame to find the source file
|
|
179
|
-
frame = inspect.currentframe()
|
|
180
|
-
while frame and frame.f_code.co_name != self.decorator.__name__:
|
|
181
|
-
frame = frame.f_back
|
|
278
|
+
source = None
|
|
182
279
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
caller_frame = frame.f_back
|
|
187
|
-
filename = caller_frame.f_code.co_filename
|
|
280
|
+
try:
|
|
281
|
+
# Get the source file and line number
|
|
282
|
+
filename = self.func.__code__.co_filename
|
|
188
283
|
lambda_lineno = self.func.__code__.co_firstlineno
|
|
189
284
|
|
|
190
285
|
# Read the source file
|
|
@@ -227,20 +322,25 @@ class ASTProcessor[T: Callable]:
|
|
|
227
322
|
end_line = i
|
|
228
323
|
break
|
|
229
324
|
|
|
230
|
-
|
|
325
|
+
source = "\n".join(lines[start_line : end_line + 1])
|
|
231
326
|
|
|
232
327
|
except (OSError, SyntaxError, AttributeError):
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# Fallback to regular inspect.getsource
|
|
236
|
-
return textwrap.dedent(inspect.getsource(self.func))
|
|
328
|
+
logger.exception("Failed to extract lambda source, attempting fallback.")
|
|
329
|
+
source = inspect.getsource(self.func).strip()
|
|
237
330
|
|
|
238
|
-
|
|
239
|
-
|
|
331
|
+
if source is None or source == "":
|
|
332
|
+
msg = "Could not extract lambda source code"
|
|
333
|
+
raise ASTProcessingError(msg)
|
|
240
334
|
|
|
241
|
-
#
|
|
335
|
+
# Normalize the source: convert line breaks to spaces, collapse whitespace, and dedent
|
|
242
336
|
source = re.sub(r"\r\n|\r|\n", " ", source)
|
|
243
337
|
source = re.sub(r"\s+", " ", source)
|
|
338
|
+
source = textwrap.dedent(source)
|
|
339
|
+
|
|
340
|
+
return source
|
|
341
|
+
|
|
342
|
+
def _normalize_lambda_source(self, source: str, index: int) -> str:
|
|
343
|
+
"""Extracts just the lambda expression from source code."""
|
|
244
344
|
|
|
245
345
|
# Find the Nth lambda occurrence using generator expression
|
|
246
346
|
positions = [i for i in range(len(source) - 5) if source[i : i + 6] == "lambda"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|