dndwright 0.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,217 @@
1
+ """Computation graph evaluator.
2
+
3
+ Pure function: evaluate(ruleset, input_values) -> all_computed_values.
4
+ No I/O, no side effects, trivially testable.
5
+
6
+ 1. Topological sort on the graph (respecting node dependencies)
7
+ 2. Walk in order, resolve each node's formula from its resolved inputs
8
+ 3. Apply min/max constraints
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from .operations import OPERATIONS
16
+ from .schema import ComputationNode, NodeType, Ruleset
17
+
18
+
19
+ class EvaluationError(Exception):
20
+ """Raised when the evaluator encounters an unresolvable node."""
21
+
22
+
23
+ def _topological_sort(nodes: dict[str, ComputationNode]) -> list[str]:
24
+ """Kahn's algorithm for topological sort.
25
+
26
+ Returns node IDs in evaluation order (dependencies before dependents).
27
+ Raises EvaluationError if cycles are detected.
28
+ """
29
+ # Build adjacency and in-degree
30
+ in_degree: dict[str, int] = dict.fromkeys(nodes, 0)
31
+ dependents: dict[str, list[str]] = {nid: [] for nid in nodes}
32
+
33
+ for nid, node in nodes.items():
34
+ deps = _get_dependencies(node, nodes)
35
+ in_degree[nid] = len(deps)
36
+ for dep in deps:
37
+ if dep in dependents:
38
+ dependents[dep].append(nid)
39
+
40
+ # Start with nodes that have no dependencies
41
+ queue = [nid for nid, deg in in_degree.items() if deg == 0]
42
+ order: list[str] = []
43
+
44
+ while queue:
45
+ # Sort for deterministic order
46
+ queue.sort()
47
+ nid = queue.pop(0)
48
+ order.append(nid)
49
+ for dep_nid in dependents[nid]:
50
+ in_degree[dep_nid] -= 1
51
+ if in_degree[dep_nid] == 0:
52
+ queue.append(dep_nid)
53
+
54
+ if len(order) != len(nodes):
55
+ missing = set(nodes.keys()) - set(order)
56
+ raise EvaluationError(f"Cycle detected in computation graph. Unresolvable nodes: {missing}")
57
+
58
+ return order
59
+
60
+
61
+ def _get_dependencies(node: ComputationNode, all_nodes: dict[str, ComputationNode]) -> list[str]:
62
+ """Extract dependency node IDs from a node's formula args and explicit inputs.
63
+
64
+ Formula args that are strings matching a node ID are dependencies.
65
+ Non-string args (constants) are not dependencies.
66
+ """
67
+ deps = set()
68
+
69
+ # Explicit inputs
70
+ for inp in node.inputs:
71
+ if inp in all_nodes:
72
+ deps.add(inp)
73
+
74
+ # Formula args that reference other nodes
75
+ if node.formula:
76
+ for arg in node.formula.args:
77
+ if isinstance(arg, str) and arg in all_nodes:
78
+ deps.add(arg)
79
+
80
+ return list(deps)
81
+
82
+
83
+ def _resolve_args(
84
+ args: list[Any],
85
+ computed: dict[str, Any],
86
+ all_nodes: dict[str, ComputationNode],
87
+ ) -> list[Any]:
88
+ """Resolve formula arguments.
89
+
90
+ - If an arg is a string matching a computed node ID, substitute the computed value.
91
+ - Otherwise, pass the arg through as a literal constant.
92
+ """
93
+ resolved = []
94
+ for arg in args:
95
+ if isinstance(arg, str) and arg in all_nodes:
96
+ resolved.append(computed.get(arg))
97
+ else:
98
+ resolved.append(arg)
99
+ return resolved
100
+
101
+
102
+ def evaluate(
103
+ ruleset: Ruleset,
104
+ input_values: dict[str, Any],
105
+ ) -> dict[str, Any]:
106
+ """Evaluate the computation graph with given inputs.
107
+
108
+ Args:
109
+ ruleset: The graph definition (nodes + lookup tables).
110
+ input_values: Values for INPUT nodes, keyed by node ID.
111
+
112
+ Returns:
113
+ Dict of all computed values, keyed by node ID.
114
+ """
115
+ nodes = ruleset.nodes
116
+ tables = ruleset.lookup_tables
117
+
118
+ # Sort nodes in dependency order
119
+ order = _topological_sort(nodes)
120
+
121
+ computed: dict[str, Any] = {}
122
+
123
+ for nid in order:
124
+ node = nodes[nid]
125
+
126
+ if node.node_type == NodeType.INPUT:
127
+ # Use provided value, fall back to default
128
+ value = input_values.get(nid, node.default_value)
129
+ computed[nid] = value
130
+ continue
131
+
132
+ if node.formula is None:
133
+ # No formula — use default or None
134
+ computed[nid] = node.default_value
135
+ continue
136
+
137
+ # Resolve formula arguments
138
+ resolved_args = _resolve_args(node.formula.args, computed, nodes)
139
+
140
+ # Look up operation
141
+ op_fn = OPERATIONS.get(node.formula.op)
142
+ if op_fn is None:
143
+ raise EvaluationError(f"Unknown operation '{node.formula.op}' in node '{nid}'")
144
+
145
+ # Execute operation
146
+ try:
147
+ value = op_fn(resolved_args, tables)
148
+ except Exception as e:
149
+ raise EvaluationError(
150
+ f"Error evaluating node '{nid}' (op={node.formula.op}, args={resolved_args}): {e}"
151
+ ) from e
152
+
153
+ # Apply constraints
154
+ if value is not None and isinstance(value, (int, float)):
155
+ if node.min_value is not None:
156
+ value = max(value, node.min_value)
157
+ if node.max_value is not None:
158
+ value = min(value, node.max_value)
159
+
160
+ computed[nid] = value
161
+
162
+ return computed
163
+
164
+
165
+ def get_evaluation_order(ruleset: Ruleset) -> list[str]:
166
+ """Return the topological evaluation order for debugging/visualization."""
167
+ return _topological_sort(ruleset.nodes)
168
+
169
+
170
+ def get_node_dependencies(ruleset: Ruleset, node_id: str) -> list[str]:
171
+ """Return all upstream dependencies of a node (transitive closure)."""
172
+ nodes = ruleset.nodes
173
+ if node_id not in nodes:
174
+ return []
175
+
176
+ visited = set()
177
+ stack = [node_id]
178
+
179
+ while stack:
180
+ nid = stack.pop()
181
+ if nid in visited:
182
+ continue
183
+ visited.add(nid)
184
+ node = nodes[nid]
185
+ deps = _get_dependencies(node, nodes)
186
+ stack.extend(deps)
187
+
188
+ visited.discard(node_id)
189
+ return sorted(visited)
190
+
191
+
192
+ def get_downstream_nodes(ruleset: Ruleset, node_id: str) -> list[str]:
193
+ """Return all downstream nodes that depend on a given node."""
194
+ nodes = ruleset.nodes
195
+ if node_id not in nodes:
196
+ return []
197
+
198
+ # Build reverse adjacency
199
+ dependents: dict[str, set[str]] = {nid: set() for nid in nodes}
200
+ for nid, node in nodes.items():
201
+ deps = _get_dependencies(node, nodes)
202
+ for dep in deps:
203
+ if dep in dependents:
204
+ dependents[dep].add(nid)
205
+
206
+ visited = set()
207
+ stack = [node_id]
208
+
209
+ while stack:
210
+ nid = stack.pop()
211
+ if nid in visited:
212
+ continue
213
+ visited.add(nid)
214
+ stack.extend(dependents.get(nid, set()))
215
+
216
+ visited.discard(node_id)
217
+ return sorted(visited)