pylitmus 1.1.0__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.
- pylitmus/__init__.py +1 -1
- pylitmus/engine.py +176 -4
- pylitmus/factory.py +13 -0
- pylitmus/integrations/flask/extension.py +51 -9
- pylitmus/rete/__init__.py +26 -0
- pylitmus/rete/compiler.py +356 -0
- pylitmus/rete/network.py +269 -0
- pylitmus/rete/nodes.py +318 -0
- {pylitmus-1.1.0.dist-info → pylitmus-1.2.0.dist-info}/METADATA +1 -1
- {pylitmus-1.1.0.dist-info → pylitmus-1.2.0.dist-info}/RECORD +12 -8
- {pylitmus-1.1.0.dist-info → pylitmus-1.2.0.dist-info}/WHEEL +0 -0
- {pylitmus-1.1.0.dist-info → pylitmus-1.2.0.dist-info}/licenses/LICENSE +0 -0
pylitmus/__init__.py
CHANGED
|
@@ -28,7 +28,7 @@ from .storage import (
|
|
|
28
28
|
from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
|
|
29
29
|
from .types import AssessmentResult, DecisionTier, Operator, Rule, RuleResult, Severity
|
|
30
30
|
|
|
31
|
-
__version__ = "1.
|
|
31
|
+
__version__ = "1.2.0"
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
# Main
|
pylitmus/engine.py
CHANGED
|
@@ -10,6 +10,7 @@ from .exceptions import EvaluationError
|
|
|
10
10
|
from .types import AssessmentResult, DecisionTier, Rule, RuleResult
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
|
+
from .rete import RETENetwork, RuleCompiler
|
|
13
14
|
from .storage.base import RuleRepository
|
|
14
15
|
from .strategies.base import ScoringStrategy
|
|
15
16
|
|
|
@@ -41,6 +42,13 @@ class RuleEngine:
|
|
|
41
42
|
]
|
|
42
43
|
)
|
|
43
44
|
|
|
45
|
+
# With RETE algorithm for optimized evaluation (recommended for 50+ rules)
|
|
46
|
+
engine = RuleEngine(
|
|
47
|
+
repository=repo,
|
|
48
|
+
scoring_strategy=WeightedStrategy(),
|
|
49
|
+
use_rete=True # Enable RETE optimization
|
|
50
|
+
)
|
|
51
|
+
|
|
44
52
|
result = engine.evaluate(claim, context)
|
|
45
53
|
"""
|
|
46
54
|
|
|
@@ -50,6 +58,7 @@ class RuleEngine:
|
|
|
50
58
|
scoring_strategy: Optional["ScoringStrategy"] = None,
|
|
51
59
|
decision_tiers: Optional[List[DecisionTier]] = None,
|
|
52
60
|
condition_builder: Optional[Any] = None,
|
|
61
|
+
use_rete: bool = False,
|
|
53
62
|
):
|
|
54
63
|
"""
|
|
55
64
|
Initialize the rule engine.
|
|
@@ -60,6 +69,9 @@ class RuleEngine:
|
|
|
60
69
|
decision_tiers: User-defined decision tiers with score ranges.
|
|
61
70
|
If not provided, decision will be None.
|
|
62
71
|
condition_builder: Builder for creating conditions from dicts
|
|
72
|
+
use_rete: Enable RETE algorithm for optimized rule evaluation.
|
|
73
|
+
Recommended for rule sets with 50+ rules or significant
|
|
74
|
+
condition sharing. Default is False (use standard evaluation).
|
|
63
75
|
"""
|
|
64
76
|
self.repository = repository
|
|
65
77
|
self._scoring_strategy = scoring_strategy
|
|
@@ -67,7 +79,26 @@ class RuleEngine:
|
|
|
67
79
|
self._condition_builder = condition_builder
|
|
68
80
|
self._rules_cache: Optional[List[Rule]] = None
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
# RETE configuration
|
|
83
|
+
self._use_rete = use_rete
|
|
84
|
+
self._rete_network: Optional["RETENetwork"] = None
|
|
85
|
+
self._rete_compiled = False
|
|
86
|
+
|
|
87
|
+
logger.info(f"RuleEngine initialized (use_rete={use_rete})")
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def use_rete(self) -> bool:
|
|
91
|
+
"""Check if RETE algorithm is enabled."""
|
|
92
|
+
return self._use_rete
|
|
93
|
+
|
|
94
|
+
@use_rete.setter
|
|
95
|
+
def use_rete(self, value: bool) -> None:
|
|
96
|
+
"""Enable or disable RETE algorithm."""
|
|
97
|
+
if value != self._use_rete:
|
|
98
|
+
self._use_rete = value
|
|
99
|
+
self._rete_network = None
|
|
100
|
+
self._rete_compiled = False
|
|
101
|
+
logger.info(f"RETE algorithm {'enabled' if value else 'disabled'}")
|
|
71
102
|
|
|
72
103
|
@property
|
|
73
104
|
def scoring_strategy(self) -> "ScoringStrategy":
|
|
@@ -87,6 +118,30 @@ class RuleEngine:
|
|
|
87
118
|
self._condition_builder = ConditionBuilder()
|
|
88
119
|
return self._condition_builder
|
|
89
120
|
|
|
121
|
+
def _ensure_rete_compiled(self, filters: Optional[Dict[str, Any]] = None) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Ensure the RETE network is compiled with current rules.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
filters: Filters to apply when fetching rules
|
|
127
|
+
"""
|
|
128
|
+
if self._rete_compiled and self._rete_network is not None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
from .rete import RuleCompiler
|
|
132
|
+
|
|
133
|
+
rules = self.repository.get_enabled(filters)
|
|
134
|
+
compiler = RuleCompiler()
|
|
135
|
+
self._rete_network = compiler.compile(rules)
|
|
136
|
+
self._rete_compiled = True
|
|
137
|
+
|
|
138
|
+
stats = self._rete_network.get_stats()
|
|
139
|
+
logger.info(
|
|
140
|
+
f"RETE network compiled: {stats['alpha_nodes']} alpha nodes, "
|
|
141
|
+
f"{stats['shared_conditions']} shared conditions, "
|
|
142
|
+
f"{stats['terminal_nodes']} rules"
|
|
143
|
+
)
|
|
144
|
+
|
|
90
145
|
def evaluate(
|
|
91
146
|
self,
|
|
92
147
|
data: Dict[str, Any],
|
|
@@ -96,6 +151,104 @@ class RuleEngine:
|
|
|
96
151
|
"""
|
|
97
152
|
Evaluate data against all applicable rules.
|
|
98
153
|
|
|
154
|
+
Args:
|
|
155
|
+
data: Data to evaluate
|
|
156
|
+
context: Additional context for evaluation
|
|
157
|
+
filters: Filters to apply when fetching rules
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
AssessmentResult with score, decision, and triggered rules
|
|
161
|
+
"""
|
|
162
|
+
if self._use_rete:
|
|
163
|
+
return self._evaluate_with_rete(data, context, filters)
|
|
164
|
+
else:
|
|
165
|
+
return self._evaluate_standard(data, context, filters)
|
|
166
|
+
|
|
167
|
+
def _evaluate_with_rete(
|
|
168
|
+
self,
|
|
169
|
+
data: Dict[str, Any],
|
|
170
|
+
context: Optional[Dict[str, Any]] = None,
|
|
171
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
172
|
+
) -> AssessmentResult:
|
|
173
|
+
"""
|
|
174
|
+
Evaluate data using the RETE algorithm.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
data: Data to evaluate
|
|
178
|
+
context: Additional context for evaluation
|
|
179
|
+
filters: Filters to apply when fetching rules
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
AssessmentResult with score, decision, and triggered rules
|
|
183
|
+
"""
|
|
184
|
+
start_time = time.time()
|
|
185
|
+
context = context or {}
|
|
186
|
+
|
|
187
|
+
logger.debug(f"Starting RETE evaluation with {len(data)} data fields")
|
|
188
|
+
|
|
189
|
+
# Ensure RETE network is compiled
|
|
190
|
+
self._ensure_rete_compiled(filters)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Evaluate through RETE network
|
|
194
|
+
triggered_results = self._rete_network.evaluate(data)
|
|
195
|
+
|
|
196
|
+
# Convert severity strings back to Severity enum for consistency
|
|
197
|
+
from .types import Severity
|
|
198
|
+
|
|
199
|
+
for result in triggered_results:
|
|
200
|
+
if isinstance(result.severity, str):
|
|
201
|
+
try:
|
|
202
|
+
result.severity = Severity(result.severity)
|
|
203
|
+
except ValueError:
|
|
204
|
+
pass # Keep as string if not a valid Severity
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error in RETE evaluation: {e}")
|
|
208
|
+
raise EvaluationError(f"RETE evaluation failed: {e}") from e
|
|
209
|
+
|
|
210
|
+
# Calculate total score using scoring strategy
|
|
211
|
+
total_score = self.scoring_strategy.calculate(triggered_results)
|
|
212
|
+
|
|
213
|
+
# Determine decision
|
|
214
|
+
decision = self._make_decision(total_score)
|
|
215
|
+
|
|
216
|
+
processing_time = (time.time() - start_time) * 1000
|
|
217
|
+
|
|
218
|
+
# Get total rules count from network stats
|
|
219
|
+
stats = self._rete_network.get_stats()
|
|
220
|
+
total_rules = stats["terminal_nodes"]
|
|
221
|
+
|
|
222
|
+
result = AssessmentResult(
|
|
223
|
+
total_score=total_score,
|
|
224
|
+
decision=decision,
|
|
225
|
+
triggered_rules=triggered_results,
|
|
226
|
+
all_rules_evaluated=total_rules,
|
|
227
|
+
processing_time_ms=processing_time,
|
|
228
|
+
metadata={
|
|
229
|
+
"context": context,
|
|
230
|
+
"filters": filters,
|
|
231
|
+
"evaluation_method": "rete",
|
|
232
|
+
"rete_stats": stats,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
logger.info(
|
|
237
|
+
f"RETE evaluation complete: score={total_score}, decision={decision}, "
|
|
238
|
+
f"triggered={len(triggered_results)}/{total_rules}, time={processing_time:.2f}ms"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def _evaluate_standard(
|
|
244
|
+
self,
|
|
245
|
+
data: Dict[str, Any],
|
|
246
|
+
context: Optional[Dict[str, Any]] = None,
|
|
247
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
248
|
+
) -> AssessmentResult:
|
|
249
|
+
"""
|
|
250
|
+
Evaluate data using standard (non-RETE) algorithm.
|
|
251
|
+
|
|
99
252
|
Args:
|
|
100
253
|
data: Data to evaluate
|
|
101
254
|
context: Additional context for evaluation
|
|
@@ -107,7 +260,7 @@ class RuleEngine:
|
|
|
107
260
|
start_time = time.time()
|
|
108
261
|
context = context or {}
|
|
109
262
|
|
|
110
|
-
logger.debug(f"Starting evaluation with {len(data)} data fields")
|
|
263
|
+
logger.debug(f"Starting standard evaluation with {len(data)} data fields")
|
|
111
264
|
|
|
112
265
|
# Get applicable rules
|
|
113
266
|
rules = self.repository.get_enabled(filters)
|
|
@@ -146,11 +299,15 @@ class RuleEngine:
|
|
|
146
299
|
triggered_rules=triggered_results,
|
|
147
300
|
all_rules_evaluated=len(rules),
|
|
148
301
|
processing_time_ms=processing_time,
|
|
149
|
-
metadata={
|
|
302
|
+
metadata={
|
|
303
|
+
"context": context,
|
|
304
|
+
"filters": filters,
|
|
305
|
+
"evaluation_method": "standard",
|
|
306
|
+
},
|
|
150
307
|
)
|
|
151
308
|
|
|
152
309
|
logger.info(
|
|
153
|
-
f"
|
|
310
|
+
f"Standard evaluation complete: score={total_score}, decision={decision}, "
|
|
154
311
|
f"triggered={len(triggered_results)}/{len(rules)}, time={processing_time:.2f}ms"
|
|
155
312
|
)
|
|
156
313
|
|
|
@@ -224,6 +381,10 @@ class RuleEngine:
|
|
|
224
381
|
"""Force reload rules from repository."""
|
|
225
382
|
self._rules_cache = None
|
|
226
383
|
|
|
384
|
+
# Invalidate RETE network to force recompilation
|
|
385
|
+
self._rete_network = None
|
|
386
|
+
self._rete_compiled = False
|
|
387
|
+
|
|
227
388
|
# If repository has cache, invalidate it
|
|
228
389
|
if hasattr(self.repository, "invalidate"):
|
|
229
390
|
self.repository.invalidate()
|
|
@@ -253,3 +414,14 @@ class RuleEngine:
|
|
|
253
414
|
Rule if found, None otherwise
|
|
254
415
|
"""
|
|
255
416
|
return self.repository.get_by_code(code)
|
|
417
|
+
|
|
418
|
+
def get_rete_stats(self) -> Optional[Dict[str, int]]:
|
|
419
|
+
"""
|
|
420
|
+
Get RETE network statistics.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Dictionary with stats if RETE is enabled and compiled, None otherwise
|
|
424
|
+
"""
|
|
425
|
+
if self._rete_network is not None:
|
|
426
|
+
return self._rete_network.get_stats()
|
|
427
|
+
return None
|
pylitmus/factory.py
CHANGED
|
@@ -36,6 +36,8 @@ def create_engine(
|
|
|
36
36
|
scoring_strategy: Union[str, ScoringStrategy] = "sum",
|
|
37
37
|
# Decision
|
|
38
38
|
decision_tiers: Optional[List[DecisionTier]] = None,
|
|
39
|
+
# RETE optimization
|
|
40
|
+
use_rete: bool = False,
|
|
39
41
|
) -> RuleEngine:
|
|
40
42
|
"""
|
|
41
43
|
Create a configured RuleEngine instance.
|
|
@@ -55,6 +57,9 @@ def create_engine(
|
|
|
55
57
|
scoring_strategy: 'sum', 'weighted', 'max', or ScoringStrategy instance
|
|
56
58
|
decision_tiers: User-defined decision tiers with score ranges.
|
|
57
59
|
If not provided, decision will be None in results.
|
|
60
|
+
use_rete: Enable RETE algorithm for optimized rule evaluation.
|
|
61
|
+
Recommended for rule sets with 50+ rules or significant
|
|
62
|
+
condition sharing across rules. Default is False.
|
|
58
63
|
|
|
59
64
|
Returns:
|
|
60
65
|
Configured RuleEngine instance
|
|
@@ -92,6 +97,13 @@ def create_engine(
|
|
|
92
97
|
DecisionTier("AUTO_REJECT", 95, 101, "Very high risk"),
|
|
93
98
|
]
|
|
94
99
|
)
|
|
100
|
+
|
|
101
|
+
# With RETE algorithm optimization (for large rule sets)
|
|
102
|
+
engine = create_engine(
|
|
103
|
+
storage_backend='database',
|
|
104
|
+
database_url='postgresql://localhost/mydb',
|
|
105
|
+
use_rete=True # Enable RETE for performance
|
|
106
|
+
)
|
|
95
107
|
"""
|
|
96
108
|
|
|
97
109
|
# Build or use provided repository
|
|
@@ -122,6 +134,7 @@ def create_engine(
|
|
|
122
134
|
repository=repo,
|
|
123
135
|
scoring_strategy=strategy,
|
|
124
136
|
decision_tiers=decision_tiers,
|
|
137
|
+
use_rete=use_rete,
|
|
125
138
|
)
|
|
126
139
|
|
|
127
140
|
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
Flask extension for cmap-rules-engine.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import Any, Dict, Optional
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
from ...engine import RuleEngine
|
|
8
8
|
from ...storage import CachedRuleRepository, InMemoryRuleRepository
|
|
9
9
|
from ...strategies import MaxStrategy, SumStrategy, WeightedStrategy
|
|
10
|
+
from ...types import DecisionTier
|
|
10
11
|
|
|
11
12
|
try:
|
|
12
13
|
from flask import Flask, current_app
|
|
@@ -83,6 +84,47 @@ def create_strategy(config: Dict[str, Any]) -> Any:
|
|
|
83
84
|
return strategies[strategy_name]()
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
def create_decision_tiers(config: Dict[str, Any]) -> Optional[List[DecisionTier]]:
|
|
88
|
+
"""
|
|
89
|
+
Create decision tiers from Flask config.
|
|
90
|
+
|
|
91
|
+
Supports two formats:
|
|
92
|
+
1. Legacy dict format: {"approve": 30, "review": 70}
|
|
93
|
+
- Creates APPROVE (0-30), REVIEW (30-70), FLAG (70-101) tiers
|
|
94
|
+
2. List format: [DecisionTier(...), ...]
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
config: Flask app configuration
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of DecisionTier objects or None
|
|
101
|
+
"""
|
|
102
|
+
thresholds = config.get("CMAP_RULES_THRESHOLDS")
|
|
103
|
+
decision_tiers = config.get("CMAP_RULES_DECISION_TIERS")
|
|
104
|
+
|
|
105
|
+
# If explicit decision tiers are provided, use them
|
|
106
|
+
if decision_tiers is not None:
|
|
107
|
+
return decision_tiers
|
|
108
|
+
|
|
109
|
+
# Convert legacy thresholds format to decision tiers
|
|
110
|
+
if thresholds is not None:
|
|
111
|
+
approve_threshold = thresholds.get("approve", 30)
|
|
112
|
+
review_threshold = thresholds.get("review", 70)
|
|
113
|
+
|
|
114
|
+
return [
|
|
115
|
+
DecisionTier("APPROVE", 0, approve_threshold),
|
|
116
|
+
DecisionTier("REVIEW", approve_threshold, review_threshold),
|
|
117
|
+
DecisionTier("FLAG", review_threshold, 101),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Default decision tiers
|
|
121
|
+
return [
|
|
122
|
+
DecisionTier("APPROVE", 0, 30),
|
|
123
|
+
DecisionTier("REVIEW", 30, 70),
|
|
124
|
+
DecisionTier("FLAG", 70, 101),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
86
128
|
class CmapRulesEngine:
|
|
87
129
|
"""
|
|
88
130
|
Flask extension for cmap-rules-engine.
|
|
@@ -119,7 +161,7 @@ class CmapRulesEngine:
|
|
|
119
161
|
if not HAS_FLASK:
|
|
120
162
|
raise ImportError(
|
|
121
163
|
"Flask is required for the Flask integration. "
|
|
122
|
-
"Install it with: pip install
|
|
164
|
+
"Install it with: pip install pylitmus[flask]"
|
|
123
165
|
)
|
|
124
166
|
|
|
125
167
|
self._engine: Optional[RuleEngine] = None
|
|
@@ -137,7 +179,7 @@ class CmapRulesEngine:
|
|
|
137
179
|
# Store reference in extensions
|
|
138
180
|
if not hasattr(app, "extensions"):
|
|
139
181
|
app.extensions = {}
|
|
140
|
-
app.extensions["
|
|
182
|
+
app.extensions["pylitmus"] = self
|
|
141
183
|
|
|
142
184
|
# Set default configuration
|
|
143
185
|
app.config.setdefault("CMAP_RULES_STORAGE", "memory")
|
|
@@ -181,13 +223,13 @@ class CmapRulesEngine:
|
|
|
181
223
|
# Create strategy
|
|
182
224
|
strategy = create_strategy(config)
|
|
183
225
|
|
|
184
|
-
#
|
|
185
|
-
|
|
226
|
+
# Create decision tiers from config
|
|
227
|
+
decision_tiers = create_decision_tiers(config)
|
|
186
228
|
|
|
187
229
|
return RuleEngine(
|
|
188
230
|
repository=repository,
|
|
189
231
|
scoring_strategy=strategy,
|
|
190
|
-
|
|
232
|
+
decision_tiers=decision_tiers,
|
|
191
233
|
)
|
|
192
234
|
|
|
193
235
|
@property
|
|
@@ -209,7 +251,7 @@ def get_engine() -> RuleEngine:
|
|
|
209
251
|
Get rule engine from current Flask app.
|
|
210
252
|
|
|
211
253
|
Usage:
|
|
212
|
-
from
|
|
254
|
+
from pylitmus.integrations.flask import get_engine
|
|
213
255
|
|
|
214
256
|
@app.route('/assess')
|
|
215
257
|
def assess():
|
|
@@ -226,9 +268,9 @@ def get_engine() -> RuleEngine:
|
|
|
226
268
|
if not HAS_FLASK:
|
|
227
269
|
raise ImportError("Flask is required for the Flask integration")
|
|
228
270
|
|
|
229
|
-
if "
|
|
271
|
+
if "pylitmus" not in current_app.extensions:
|
|
230
272
|
raise RuntimeError(
|
|
231
273
|
"CmapRulesEngine not initialized for this app. Did you call init_app()?"
|
|
232
274
|
)
|
|
233
275
|
|
|
234
|
-
return current_app.extensions["
|
|
276
|
+
return current_app.extensions["pylitmus"].engine
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RETE Algorithm Implementation for PyLitmus.
|
|
3
|
+
|
|
4
|
+
This module provides a lightweight RETE network implementation optimized
|
|
5
|
+
for the PyLitmus rule engine. It maintains the same user-facing API while
|
|
6
|
+
providing performance benefits for large rule sets through:
|
|
7
|
+
|
|
8
|
+
1. Condition sharing - identical conditions across rules are evaluated once
|
|
9
|
+
2. Alpha network - single-condition tests with memory
|
|
10
|
+
3. Beta network - join nodes for combining conditions (AND/OR logic)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from pylitmus.rete import RETENetwork, RuleCompiler
|
|
14
|
+
|
|
15
|
+
# Compile rules into RETE network
|
|
16
|
+
compiler = RuleCompiler()
|
|
17
|
+
network = compiler.compile(rules)
|
|
18
|
+
|
|
19
|
+
# Evaluate data
|
|
20
|
+
triggered_rules = network.evaluate(data)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .compiler import RuleCompiler
|
|
24
|
+
from .network import RETENetwork
|
|
25
|
+
|
|
26
|
+
__all__ = ["RETENetwork", "RuleCompiler"]
|