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.
@@ -0,0 +1,356 @@
1
+ """
2
+ Rule Compiler for RETE Network.
3
+
4
+ The RuleCompiler transforms PyLitmus Rule definitions into a RETE network
5
+ structure, handling condition extraction, sharing detection, and network
6
+ construction.
7
+ """
8
+
9
+ import logging
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
11
+
12
+ from ..types import Rule
13
+ from .network import RETENetwork
14
+ from .nodes import AlphaNode, BetaNode, OrNode, TerminalNode
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class RuleCompiler:
20
+ """
21
+ Compiles PyLitmus rules into a RETE network.
22
+
23
+ The compiler:
24
+ 1. Extracts conditions from rule definitions
25
+ 2. Identifies opportunities for condition sharing
26
+ 3. Builds alpha nodes for simple conditions
27
+ 4. Builds beta nodes for AND combinations
28
+ 5. Builds OR nodes for ANY combinations
29
+ 6. Connects terminal nodes for rule activation
30
+
31
+ Usage:
32
+ compiler = RuleCompiler()
33
+ network = compiler.compile(rules)
34
+ results = network.evaluate(data)
35
+ """
36
+
37
+ def __init__(self):
38
+ """Initialize the compiler."""
39
+ self._network: Optional[RETENetwork] = None
40
+
41
+ def compile(self, rules: List[Rule]) -> RETENetwork:
42
+ """
43
+ Compile a list of rules into a RETE network.
44
+
45
+ Args:
46
+ rules: List of PyLitmus Rule objects
47
+
48
+ Returns:
49
+ Compiled RETENetwork ready for evaluation
50
+ """
51
+ self._network = RETENetwork()
52
+
53
+ for rule in rules:
54
+ if not rule.is_effective():
55
+ logger.debug(f"Skipping ineffective rule: {rule.code}")
56
+ continue
57
+
58
+ self._compile_rule(rule)
59
+
60
+ logger.info(
61
+ f"Compiled {len(rules)} rules into RETE network: "
62
+ f"{self._network.get_stats()}"
63
+ )
64
+
65
+ return self._network
66
+
67
+ def _compile_rule(self, rule: Rule) -> None:
68
+ """
69
+ Compile a single rule into the network.
70
+
71
+ Args:
72
+ rule: The rule to compile
73
+ """
74
+ # Create terminal node for this rule
75
+ terminal = self._network.create_terminal(rule)
76
+
77
+ # Parse and compile the conditions
78
+ conditions = rule.conditions
79
+
80
+ if not conditions:
81
+ # Rule with no conditions always triggers
82
+ terminal.activated = True
83
+ return
84
+
85
+ # Compile the condition tree and get the root nodes
86
+ root_nodes = self._compile_conditions(conditions, terminal)
87
+
88
+ logger.debug(
89
+ f"Compiled rule {rule.code} with {len(root_nodes)} root conditions"
90
+ )
91
+
92
+ def _compile_conditions(
93
+ self, conditions: Dict[str, Any], terminal: TerminalNode
94
+ ) -> List[Union[AlphaNode, BetaNode, OrNode]]:
95
+ """
96
+ Compile a conditions dictionary into network nodes.
97
+
98
+ Handles:
99
+ - Simple conditions: {"field": "x", "operator": "equals", "value": 1}
100
+ - AND conditions: {"all": [...]}
101
+ - OR conditions: {"any": [...]}
102
+
103
+ Args:
104
+ conditions: The conditions dictionary
105
+ terminal: The terminal node to connect to
106
+
107
+ Returns:
108
+ List of root nodes created
109
+ """
110
+ # Check for composite conditions
111
+ if "all" in conditions:
112
+ return self._compile_all(conditions["all"], terminal)
113
+ elif "any" in conditions:
114
+ return self._compile_any(conditions["any"], terminal)
115
+ else:
116
+ # Simple condition
117
+ return self._compile_simple(conditions, terminal)
118
+
119
+ def _compile_simple(
120
+ self, condition: Dict[str, Any], terminal: TerminalNode
121
+ ) -> List[AlphaNode]:
122
+ """
123
+ Compile a simple condition.
124
+
125
+ Args:
126
+ condition: Simple condition dict with field, operator, value
127
+ terminal: Terminal node to connect to
128
+
129
+ Returns:
130
+ List containing the alpha node
131
+ """
132
+ field = condition.get("field")
133
+ operator = condition.get("operator")
134
+ value = condition.get("value")
135
+
136
+ if not all([field, operator]):
137
+ logger.warning(f"Invalid simple condition: {condition}")
138
+ return []
139
+
140
+ # Get or create alpha node (enables sharing)
141
+ alpha = self._network.get_or_create_alpha(field, operator, value)
142
+
143
+ # For a single condition, create a simple beta that just wraps it
144
+ beta = self._network.create_beta([alpha])
145
+ beta.children.append(terminal)
146
+
147
+ return [alpha]
148
+
149
+ def _compile_all(
150
+ self, conditions: List[Dict[str, Any]], terminal: TerminalNode
151
+ ) -> List[Union[AlphaNode, BetaNode]]:
152
+ """
153
+ Compile an ALL (AND) composite condition.
154
+
155
+ All conditions must pass for the rule to trigger.
156
+
157
+ Args:
158
+ conditions: List of condition dictionaries
159
+ terminal: Terminal node to connect to
160
+
161
+ Returns:
162
+ List of nodes created
163
+ """
164
+ all_alphas: List[AlphaNode] = []
165
+ nested_nodes: List[Union[BetaNode, OrNode]] = []
166
+
167
+ for cond in conditions:
168
+ if "all" in cond:
169
+ # Nested ALL - flatten it
170
+ nested = self._compile_all_without_terminal(cond["all"])
171
+ all_alphas.extend(nested)
172
+ elif "any" in cond:
173
+ # Nested ANY - create OR node
174
+ or_node = self._compile_any_as_or_node(cond["any"])
175
+ if or_node:
176
+ nested_nodes.append(or_node)
177
+ else:
178
+ # Simple condition
179
+ field = cond.get("field")
180
+ operator = cond.get("operator")
181
+ value = cond.get("value")
182
+
183
+ if field and operator:
184
+ alpha = self._network.get_or_create_alpha(field, operator, value)
185
+ all_alphas.append(alpha)
186
+
187
+ # Create beta node joining all alpha conditions
188
+ if all_alphas:
189
+ beta = self._network.create_beta(all_alphas)
190
+
191
+ # If there are nested OR nodes, we need special handling
192
+ if nested_nodes:
193
+ # Create a combined check that requires both beta AND or_nodes
194
+ # For now, connect terminal to both
195
+ beta.children.append(terminal)
196
+ for node in nested_nodes:
197
+ node.children.append(terminal)
198
+ else:
199
+ beta.children.append(terminal)
200
+ elif nested_nodes:
201
+ # Only OR nodes, no direct alphas
202
+ for node in nested_nodes:
203
+ node.children.append(terminal)
204
+
205
+ return all_alphas
206
+
207
+ def _compile_all_without_terminal(
208
+ self, conditions: List[Dict[str, Any]]
209
+ ) -> List[AlphaNode]:
210
+ """
211
+ Compile ALL conditions without connecting to a terminal.
212
+ Used for nested ALL conditions.
213
+
214
+ Args:
215
+ conditions: List of condition dictionaries
216
+
217
+ Returns:
218
+ List of alpha nodes
219
+ """
220
+ alphas = []
221
+
222
+ for cond in conditions:
223
+ if "all" in cond:
224
+ # Recursively flatten
225
+ alphas.extend(self._compile_all_without_terminal(cond["all"]))
226
+ elif "any" in cond:
227
+ # Skip OR in nested ALL for now (complex case)
228
+ logger.warning("Nested ANY inside ALL not fully supported in RETE")
229
+ else:
230
+ field = cond.get("field")
231
+ operator = cond.get("operator")
232
+ value = cond.get("value")
233
+
234
+ if field and operator:
235
+ alpha = self._network.get_or_create_alpha(field, operator, value)
236
+ alphas.append(alpha)
237
+
238
+ return alphas
239
+
240
+ def _compile_any(
241
+ self, conditions: List[Dict[str, Any]], terminal: TerminalNode
242
+ ) -> List[Union[AlphaNode, OrNode]]:
243
+ """
244
+ Compile an ANY (OR) composite condition.
245
+
246
+ Any one condition passing triggers the rule.
247
+
248
+ Args:
249
+ conditions: List of condition dictionaries
250
+ terminal: Terminal node to connect to
251
+
252
+ Returns:
253
+ List of nodes created
254
+ """
255
+ # For OR, each branch independently can trigger the terminal
256
+ branches: List[List[AlphaNode]] = []
257
+
258
+ for cond in conditions:
259
+ if "all" in cond:
260
+ # Branch is an AND - collect its alphas
261
+ branch_alphas = self._compile_all_without_terminal(cond["all"])
262
+ if branch_alphas:
263
+ branches.append(branch_alphas)
264
+ elif "any" in cond:
265
+ # Nested OR - flatten by adding each as separate branch
266
+ for nested_cond in cond["any"]:
267
+ nested_alphas = self._extract_alphas_from_condition(nested_cond)
268
+ if nested_alphas:
269
+ branches.append(nested_alphas)
270
+ else:
271
+ # Simple condition is its own branch
272
+ field = cond.get("field")
273
+ operator = cond.get("operator")
274
+ value = cond.get("value")
275
+
276
+ if field and operator:
277
+ alpha = self._network.get_or_create_alpha(field, operator, value)
278
+ branches.append([alpha])
279
+
280
+ if branches:
281
+ # Create OR node
282
+ or_node = self._network.create_or_node(branches)
283
+ or_node.children.append(terminal)
284
+ return [or_node]
285
+
286
+ return []
287
+
288
+ def _compile_any_as_or_node(
289
+ self, conditions: List[Dict[str, Any]]
290
+ ) -> Optional[OrNode]:
291
+ """
292
+ Compile ANY conditions into an OR node without terminal.
293
+
294
+ Args:
295
+ conditions: List of condition dictionaries
296
+
297
+ Returns:
298
+ OrNode or None
299
+ """
300
+ branches: List[List[AlphaNode]] = []
301
+
302
+ for cond in conditions:
303
+ alphas = self._extract_alphas_from_condition(cond)
304
+ if alphas:
305
+ branches.append(alphas)
306
+
307
+ if branches:
308
+ return self._network.create_or_node(branches)
309
+
310
+ return None
311
+
312
+ def _extract_alphas_from_condition(
313
+ self, condition: Dict[str, Any]
314
+ ) -> List[AlphaNode]:
315
+ """
316
+ Extract alpha nodes from a condition dict.
317
+
318
+ Args:
319
+ condition: Condition dictionary
320
+
321
+ Returns:
322
+ List of alpha nodes
323
+ """
324
+ if "all" in condition:
325
+ return self._compile_all_without_terminal(condition["all"])
326
+ elif "any" in condition:
327
+ # For extraction, just get the first branch
328
+ # (full OR handling is done elsewhere)
329
+ alphas = []
330
+ for cond in condition["any"]:
331
+ alphas.extend(self._extract_alphas_from_condition(cond))
332
+ return alphas
333
+ else:
334
+ field = condition.get("field")
335
+ operator = condition.get("operator")
336
+ value = condition.get("value")
337
+
338
+ if field and operator:
339
+ alpha = self._network.get_or_create_alpha(field, operator, value)
340
+ return [alpha]
341
+
342
+ return []
343
+
344
+ def recompile(self, rules: List[Rule]) -> RETENetwork:
345
+ """
346
+ Recompile rules into a fresh network.
347
+
348
+ Use this when rules have changed.
349
+
350
+ Args:
351
+ rules: Updated list of rules
352
+
353
+ Returns:
354
+ New RETENetwork
355
+ """
356
+ return self.compile(rules)
@@ -0,0 +1,269 @@
1
+ """
2
+ RETE Network Implementation.
3
+
4
+ The RETENetwork class is the main entry point for RETE-based rule evaluation.
5
+ It manages the alpha and beta networks and coordinates rule evaluation.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, List, Optional, Set
10
+
11
+ from ..types import Rule, RuleResult
12
+ from .nodes import AlphaNode, BetaNode, ConditionKey, OrNode, TerminalNode
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class RETENetwork:
18
+ """
19
+ Main RETE network for rule evaluation.
20
+
21
+ The network consists of:
22
+ - Alpha network: Single-condition tests with sharing
23
+ - Beta network: Join nodes for combining conditions
24
+ - Terminal nodes: Rule activation points
25
+
26
+ Usage:
27
+ network = RETENetwork()
28
+ network.add_rule(rule) # Add rules one by one
29
+ # Or use RuleCompiler.compile(rules) to build the network
30
+
31
+ results = network.evaluate(data)
32
+ """
33
+
34
+ def __init__(self):
35
+ """Initialize an empty RETE network."""
36
+ # Alpha nodes indexed by condition key for sharing
37
+ self._alpha_nodes: Dict[ConditionKey, AlphaNode] = {}
38
+
39
+ # All alpha nodes in order of creation
40
+ self._alpha_list: List[AlphaNode] = []
41
+
42
+ # Beta nodes (AND joins)
43
+ self._beta_nodes: List[BetaNode] = []
44
+
45
+ # OR nodes
46
+ self._or_nodes: List[OrNode] = []
47
+
48
+ # Terminal nodes (one per rule)
49
+ self._terminal_nodes: List[TerminalNode] = []
50
+
51
+ # Rule code to terminal node mapping
52
+ self._rule_terminals: Dict[str, TerminalNode] = {}
53
+
54
+ # Track if network has been compiled
55
+ self._compiled = False
56
+
57
+ # Statistics
58
+ self._stats = {
59
+ "alpha_nodes": 0,
60
+ "shared_conditions": 0,
61
+ "beta_nodes": 0,
62
+ "or_nodes": 0,
63
+ "terminal_nodes": 0,
64
+ }
65
+
66
+ def get_or_create_alpha(self, field: str, operator: str, value: Any) -> AlphaNode:
67
+ """
68
+ Get an existing alpha node or create a new one.
69
+
70
+ This is the key to condition sharing - if an identical condition
71
+ already exists, we reuse its alpha node.
72
+
73
+ Args:
74
+ field: Field path to test
75
+ operator: Comparison operator
76
+ value: Value to compare against
77
+
78
+ Returns:
79
+ Alpha node for this condition
80
+ """
81
+ key = ConditionKey(field, operator, value)
82
+
83
+ if key in self._alpha_nodes:
84
+ self._stats["shared_conditions"] += 1
85
+ logger.debug(f"Reusing alpha node for {field} {operator} {value}")
86
+ return self._alpha_nodes[key]
87
+
88
+ # Create new alpha node
89
+ alpha = AlphaNode(field, operator, value, key)
90
+ self._alpha_nodes[key] = alpha
91
+ self._alpha_list.append(alpha)
92
+ self._stats["alpha_nodes"] += 1
93
+
94
+ logger.debug(f"Created alpha node for {field} {operator} {value}")
95
+ return alpha
96
+
97
+ def create_beta(self, alphas: List[AlphaNode]) -> BetaNode:
98
+ """
99
+ Create a beta node joining multiple alpha nodes.
100
+
101
+ Args:
102
+ alphas: List of alpha nodes to join (AND logic)
103
+
104
+ Returns:
105
+ New beta node
106
+ """
107
+ beta = BetaNode(alphas)
108
+
109
+ # Register beta with its alpha nodes
110
+ for alpha in alphas:
111
+ alpha.children.append(beta)
112
+
113
+ self._beta_nodes.append(beta)
114
+ self._stats["beta_nodes"] += 1
115
+
116
+ return beta
117
+
118
+ def create_or_node(self, branches: List[List[AlphaNode]]) -> OrNode:
119
+ """
120
+ Create an OR node with multiple branches.
121
+
122
+ Args:
123
+ branches: List of branches, each branch is a list of alpha nodes
124
+
125
+ Returns:
126
+ New OR node
127
+ """
128
+ or_node = OrNode(branches)
129
+ self._or_nodes.append(or_node)
130
+ self._stats["or_nodes"] += 1
131
+ return or_node
132
+
133
+ def create_terminal(self, rule: Rule) -> TerminalNode:
134
+ """
135
+ Create a terminal node for a rule.
136
+
137
+ Args:
138
+ rule: The rule this terminal represents
139
+
140
+ Returns:
141
+ New terminal node
142
+ """
143
+ terminal = TerminalNode(
144
+ rule_code=rule.code,
145
+ rule_name=rule.name,
146
+ score=rule.score,
147
+ severity=rule.severity.value
148
+ if hasattr(rule.severity, "value")
149
+ else str(rule.severity),
150
+ category=rule.category,
151
+ description=rule.description,
152
+ )
153
+
154
+ self._terminal_nodes.append(terminal)
155
+ self._rule_terminals[rule.code] = terminal
156
+ self._stats["terminal_nodes"] += 1
157
+
158
+ return terminal
159
+
160
+ def evaluate(self, data: Dict[str, Any]) -> List[RuleResult]:
161
+ """
162
+ Evaluate data through the RETE network.
163
+
164
+ Args:
165
+ data: The fact/data to evaluate
166
+
167
+ Returns:
168
+ List of RuleResult for triggered rules
169
+ """
170
+ # Clear previous evaluation state
171
+ self._clear_evaluation_state()
172
+
173
+ # Phase 1: Activate alpha network
174
+ for alpha in self._alpha_list:
175
+ alpha.activate(data)
176
+
177
+ # Phase 2: Check beta nodes (AND conditions)
178
+ for beta in self._beta_nodes:
179
+ beta.activate(data)
180
+
181
+ # Phase 3: Check OR nodes
182
+ for or_node in self._or_nodes:
183
+ # Check each branch
184
+ for i, branch in enumerate(or_node.branches):
185
+ # A branch is satisfied if all its alphas passed
186
+ branch_satisfied = all(alpha._last_result for alpha in branch)
187
+ if branch_satisfied:
188
+ or_node.branch_satisfied(i)
189
+
190
+ or_node.activate(data)
191
+
192
+ # Phase 4: Collect triggered rules
193
+ results = []
194
+ for terminal in self._terminal_nodes:
195
+ if terminal.activated:
196
+ results.append(
197
+ RuleResult(
198
+ rule_code=terminal.rule_code,
199
+ rule_name=terminal.rule_name,
200
+ triggered=True,
201
+ score=terminal.score,
202
+ severity=terminal.severity,
203
+ category=terminal.category,
204
+ explanation=terminal.description,
205
+ )
206
+ )
207
+
208
+ return results
209
+
210
+ def _clear_evaluation_state(self) -> None:
211
+ """Clear all evaluation state for a fresh evaluation."""
212
+ # Clear alpha memories and last results
213
+ for alpha in self._alpha_list:
214
+ alpha.clear_memory()
215
+
216
+ # Clear beta nodes
217
+ for beta in self._beta_nodes:
218
+ beta.clear()
219
+
220
+ # Clear OR nodes
221
+ for or_node in self._or_nodes:
222
+ or_node.clear()
223
+
224
+ # Clear terminal nodes
225
+ for terminal in self._terminal_nodes:
226
+ terminal.clear()
227
+
228
+ def get_stats(self) -> Dict[str, int]:
229
+ """Get network statistics."""
230
+ return self._stats.copy()
231
+
232
+ def dump(self) -> str:
233
+ """
234
+ Dump network structure for debugging.
235
+
236
+ Returns:
237
+ String representation of the network
238
+ """
239
+ lines = ["RETE Network Structure:", "=" * 40]
240
+
241
+ lines.append(f"\nAlpha Nodes ({len(self._alpha_list)}):")
242
+ for alpha in self._alpha_list:
243
+ shared = "SHARED" if self._stats["shared_conditions"] > 0 else ""
244
+ lines.append(f" - {alpha} {shared}")
245
+
246
+ lines.append(f"\nBeta Nodes ({len(self._beta_nodes)}):")
247
+ for beta in self._beta_nodes:
248
+ lines.append(f" - {beta}")
249
+
250
+ lines.append(f"\nOR Nodes ({len(self._or_nodes)}):")
251
+ for or_node in self._or_nodes:
252
+ lines.append(f" - {or_node}")
253
+
254
+ lines.append(f"\nTerminal Nodes ({len(self._terminal_nodes)}):")
255
+ for terminal in self._terminal_nodes:
256
+ lines.append(f" - {terminal}")
257
+
258
+ lines.append("\nStatistics:")
259
+ for key, value in self._stats.items():
260
+ lines.append(f" {key}: {value}")
261
+
262
+ return "\n".join(lines)
263
+
264
+ def __repr__(self) -> str:
265
+ return (
266
+ f"RETENetwork(alphas={len(self._alpha_list)}, "
267
+ f"betas={len(self._beta_nodes)}, "
268
+ f"terminals={len(self._terminal_nodes)})"
269
+ )