pylitmus 1.0.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 CHANGED
@@ -26,9 +26,9 @@ from .storage import (
26
26
  RuleRepository,
27
27
  )
28
28
  from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
29
- from .types import AssessmentResult, Operator, Rule, RuleResult, Severity
29
+ from .types import AssessmentResult, DecisionTier, Operator, Rule, RuleResult, Severity
30
30
 
31
- __version__ = "1.0.0"
31
+ __version__ = "1.2.0"
32
32
 
33
33
  __all__ = [
34
34
  # Main
@@ -40,6 +40,7 @@ __all__ = [
40
40
  "Rule",
41
41
  "RuleResult",
42
42
  "AssessmentResult",
43
+ "DecisionTier",
43
44
  "Operator",
44
45
  "Severity",
45
46
  # Conditions
pylitmus/engine.py CHANGED
@@ -7,9 +7,10 @@ import time
7
7
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
8
8
 
9
9
  from .exceptions import EvaluationError
10
- from .types import AssessmentResult, Rule, RuleResult
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
 
@@ -21,25 +22,43 @@ class RuleEngine:
21
22
  Main rules engine for evaluating data against configured rules.
22
23
 
23
24
  Usage:
25
+ # Without decision tiers (returns None for decision)
24
26
  engine = RuleEngine(
25
27
  repository=DatabaseRuleRepository(db_url),
26
28
  scoring_strategy=WeightedStrategy()
27
29
  )
28
30
 
31
+ # With user-defined decision tiers
32
+ engine = RuleEngine(
33
+ repository=DatabaseRuleRepository(db_url),
34
+ scoring_strategy=WeightedStrategy(),
35
+ decision_tiers=[
36
+ DecisionTier("AUTO_APPROVE", 0, 20, "Very low risk"),
37
+ DecisionTier("APPROVE", 20, 40, "Low risk"),
38
+ DecisionTier("SOFT_REVIEW", 40, 60, "Medium risk"),
39
+ DecisionTier("HARD_REVIEW", 60, 80, "Higher risk"),
40
+ DecisionTier("FLAG", 80, 95, "High risk"),
41
+ DecisionTier("AUTO_REJECT", 95, 101, "Very high risk"),
42
+ ]
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
+
29
52
  result = engine.evaluate(claim, context)
30
53
  """
31
54
 
32
- DEFAULT_THRESHOLDS = {
33
- "approve": 30,
34
- "review": 70,
35
- }
36
-
37
55
  def __init__(
38
56
  self,
39
57
  repository: "RuleRepository",
40
58
  scoring_strategy: Optional["ScoringStrategy"] = None,
41
- decision_thresholds: Optional[Dict[str, int]] = None,
59
+ decision_tiers: Optional[List[DecisionTier]] = None,
42
60
  condition_builder: Optional[Any] = None,
61
+ use_rete: bool = False,
43
62
  ):
44
63
  """
45
64
  Initialize the rule engine.
@@ -47,16 +66,39 @@ class RuleEngine:
47
66
  Args:
48
67
  repository: Rule storage backend
49
68
  scoring_strategy: Strategy for calculating scores
50
- decision_thresholds: Custom thresholds for decisions
69
+ decision_tiers: User-defined decision tiers with score ranges.
70
+ If not provided, decision will be None.
51
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).
52
75
  """
53
76
  self.repository = repository
54
77
  self._scoring_strategy = scoring_strategy
55
- self._thresholds = decision_thresholds or self.DEFAULT_THRESHOLDS.copy()
78
+ self._decision_tiers = decision_tiers
56
79
  self._condition_builder = condition_builder
57
80
  self._rules_cache: Optional[List[Rule]] = None
58
81
 
59
- logger.info("RuleEngine initialized")
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'}")
60
102
 
61
103
  @property
62
104
  def scoring_strategy(self) -> "ScoringStrategy":
@@ -76,6 +118,30 @@ class RuleEngine:
76
118
  self._condition_builder = ConditionBuilder()
77
119
  return self._condition_builder
78
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
+
79
145
  def evaluate(
80
146
  self,
81
147
  data: Dict[str, Any],
@@ -85,6 +151,104 @@ class RuleEngine:
85
151
  """
86
152
  Evaluate data against all applicable rules.
87
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
+
88
252
  Args:
89
253
  data: Data to evaluate
90
254
  context: Additional context for evaluation
@@ -96,7 +260,7 @@ class RuleEngine:
96
260
  start_time = time.time()
97
261
  context = context or {}
98
262
 
99
- logger.debug(f"Starting evaluation with {len(data)} data fields")
263
+ logger.debug(f"Starting standard evaluation with {len(data)} data fields")
100
264
 
101
265
  # Get applicable rules
102
266
  rules = self.repository.get_enabled(filters)
@@ -135,11 +299,15 @@ class RuleEngine:
135
299
  triggered_rules=triggered_results,
136
300
  all_rules_evaluated=len(rules),
137
301
  processing_time_ms=processing_time,
138
- metadata={"context": context, "filters": filters},
302
+ metadata={
303
+ "context": context,
304
+ "filters": filters,
305
+ "evaluation_method": "standard",
306
+ },
139
307
  )
140
308
 
141
309
  logger.info(
142
- f"Evaluation complete: score={total_score}, decision={decision}, "
310
+ f"Standard evaluation complete: score={total_score}, decision={decision}, "
143
311
  f"triggered={len(triggered_results)}/{len(rules)}, time={processing_time:.2f}ms"
144
312
  )
145
313
 
@@ -189,30 +357,34 @@ class RuleEngine:
189
357
  explanation=rule.description if triggered else "Condition not met",
190
358
  )
191
359
 
192
- def _make_decision(self, score: int) -> str:
360
+ def _make_decision(self, score: int) -> Optional[str]:
193
361
  """
194
- Make a decision based on the total score.
362
+ Make a decision based on the total score and configured decision tiers.
195
363
 
196
364
  Args:
197
365
  score: Total calculated score
198
366
 
199
367
  Returns:
200
- Decision string: 'APPROVE', 'REVIEW', or 'FLAG'
368
+ Decision string if a matching tier is found, None otherwise.
369
+ If no decision tiers are configured, returns None.
201
370
  """
202
- approve_threshold = self._thresholds.get("approve", 30)
203
- review_threshold = self._thresholds.get("review", 70)
371
+ if not self._decision_tiers:
372
+ return None
204
373
 
205
- if score < approve_threshold:
206
- return "APPROVE"
207
- elif score < review_threshold:
208
- return "REVIEW"
209
- else:
210
- return "FLAG"
374
+ for tier in self._decision_tiers:
375
+ if tier.matches(score):
376
+ return tier.name
377
+
378
+ return None
211
379
 
212
380
  def reload_rules(self) -> None:
213
381
  """Force reload rules from repository."""
214
382
  self._rules_cache = None
215
383
 
384
+ # Invalidate RETE network to force recompilation
385
+ self._rete_network = None
386
+ self._rete_compiled = False
387
+
216
388
  # If repository has cache, invalidate it
217
389
  if hasattr(self.repository, "invalidate"):
218
390
  self.repository.invalidate()
@@ -242,3 +414,14 @@ class RuleEngine:
242
414
  Rule if found, None otherwise
243
415
  """
244
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
@@ -4,7 +4,7 @@ Factory functions for creating RuleEngine instances with sensible defaults.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import TYPE_CHECKING, Dict, List, Optional, Union
7
+ from typing import TYPE_CHECKING, List, Optional, Union
8
8
 
9
9
  from .engine import RuleEngine
10
10
  from .storage import (
@@ -15,6 +15,7 @@ from .storage import (
15
15
  RuleRepository,
16
16
  )
17
17
  from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
18
+ from .types import DecisionTier
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from .types import Rule
@@ -34,7 +35,9 @@ def create_engine(
34
35
  # Scoring
35
36
  scoring_strategy: Union[str, ScoringStrategy] = "sum",
36
37
  # Decision
37
- decision_thresholds: Optional[Dict[str, int]] = None,
38
+ decision_tiers: Optional[List[DecisionTier]] = None,
39
+ # RETE optimization
40
+ use_rete: bool = False,
38
41
  ) -> RuleEngine:
39
42
  """
40
43
  Create a configured RuleEngine instance.
@@ -52,13 +55,17 @@ def create_engine(
52
55
  cache_url: Redis URL (for 'redis' cache)
53
56
  cache_ttl: Cache TTL in seconds
54
57
  scoring_strategy: 'sum', 'weighted', 'max', or ScoringStrategy instance
55
- decision_thresholds: Custom thresholds {'approve': 30, 'review': 70}
58
+ decision_tiers: User-defined decision tiers with score ranges.
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.
56
63
 
57
64
  Returns:
58
65
  Configured RuleEngine instance
59
66
 
60
67
  Examples:
61
- # Simple in-memory engine
68
+ # Simple in-memory engine (no decision tiers - decision will be None)
62
69
  engine = create_engine()
63
70
 
64
71
  # In-memory with predefined rules
@@ -78,10 +85,24 @@ def create_engine(
78
85
  rules_file='./rules/fraud_rules.yaml'
79
86
  )
80
87
 
81
- # With weighted scoring
88
+ # With user-defined decision tiers
82
89
  engine = create_engine(
83
90
  scoring_strategy='weighted',
84
- decision_thresholds={'approve': 25, 'review': 60}
91
+ decision_tiers=[
92
+ DecisionTier("AUTO_APPROVE", 0, 20, "Very low risk"),
93
+ DecisionTier("APPROVE", 20, 40, "Low risk"),
94
+ DecisionTier("SOFT_REVIEW", 40, 60, "Medium risk"),
95
+ DecisionTier("HARD_REVIEW", 60, 80, "Higher risk"),
96
+ DecisionTier("FLAG", 80, 95, "High risk"),
97
+ DecisionTier("AUTO_REJECT", 95, 101, "Very high risk"),
98
+ ]
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
85
106
  )
86
107
  """
87
108
 
@@ -112,7 +133,8 @@ def create_engine(
112
133
  return RuleEngine(
113
134
  repository=repo,
114
135
  scoring_strategy=strategy,
115
- decision_thresholds=decision_thresholds,
136
+ decision_tiers=decision_tiers,
137
+ use_rete=use_rete,
116
138
  )
117
139
 
118
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 cmap-rules-engine[flask]"
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["cmap_rules_engine"] = self
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
- # Get thresholds
185
- thresholds = config.get("CMAP_RULES_THRESHOLDS")
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
- decision_thresholds=thresholds,
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 cmap_rules_engine.integrations.flask import get_engine
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 "cmap_rules_engine" not in current_app.extensions:
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["cmap_rules_engine"].engine
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"]