redroot 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.
- redroot/__init__.py +71 -0
- redroot/_core.py +556 -0
- redroot/cli.py +98 -0
- redroot/functions.py +240 -0
- redroot/instrument.py +614 -0
- redroot/ops.py +332 -0
- redroot/paths.py +162 -0
- redroot/propagation.py +447 -0
- redroot/py.typed +0 -0
- redroot/serialization.py +245 -0
- redroot/trace.py +474 -0
- redroot/types.py +385 -0
- redroot/validation.py +221 -0
- redroot/visualizer/__init__.py +11 -0
- redroot/visualizer/data.py +63 -0
- redroot/visualizer/graphviz.py +44 -0
- redroot/visualizer/networkx.py +36 -0
- redroot/visualizer/web/__init__.py +1 -0
- redroot/visualizer/web/server.py +52 -0
- redroot/visualizer/web/static/app.js +125 -0
- redroot/visualizer/web/static/index.html +38 -0
- redroot/visualizer/web/static/style.css +49 -0
- redroot-0.2.0.dist-info/METADATA +309 -0
- redroot-0.2.0.dist-info/RECORD +27 -0
- redroot-0.2.0.dist-info/WHEEL +4 -0
- redroot-0.2.0.dist-info/entry_points.txt +2 -0
- redroot-0.2.0.dist-info/licenses/LICENSE +21 -0
redroot/propagation.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Forward propagation: what happens to every output when an input changes.
|
|
2
|
+
|
|
3
|
+
:func:`propagate` re-evaluates only the nodes downstream of the edited inputs,
|
|
4
|
+
in recording order, without running the workflow again. It also re-checks
|
|
5
|
+
every guard on the way. The result says, for each output, whether its new
|
|
6
|
+
value is known exactly or whether the workflow must be re-executed.
|
|
7
|
+
|
|
8
|
+
Soundness rule: an output is only reported ``UPDATED``/``UNCHANGED`` when the
|
|
9
|
+
graph is guaranteed to agree with a re-execution. If anything that steered
|
|
10
|
+
execution changed (a comparison, a conversion, a container's shape, an
|
|
11
|
+
exception), execution may have taken another path, and a trace only records
|
|
12
|
+
the path that was taken. Writes on the other path are invisible to it, so
|
|
13
|
+
*every* output is then reported ``STALE``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import decimal
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from redroot._core import (
|
|
25
|
+
GUARD,
|
|
26
|
+
LEAF,
|
|
27
|
+
NOT_REPLAYABLE,
|
|
28
|
+
Node,
|
|
29
|
+
Raised,
|
|
30
|
+
rebuild_tuple,
|
|
31
|
+
same_value,
|
|
32
|
+
unwrap,
|
|
33
|
+
)
|
|
34
|
+
from redroot.ops import Opaque, get_op
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from redroot.trace import Trace
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Mismatch",
|
|
41
|
+
"NotReplayableError",
|
|
42
|
+
"OutputChange",
|
|
43
|
+
"Propagation",
|
|
44
|
+
"Reason",
|
|
45
|
+
"ReasonKind",
|
|
46
|
+
"Status",
|
|
47
|
+
"Verification",
|
|
48
|
+
"evaluate_node",
|
|
49
|
+
"evaluate_outcome",
|
|
50
|
+
"node_function",
|
|
51
|
+
"propagate",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Status(str, Enum):
|
|
56
|
+
"""What propagation knows about one output."""
|
|
57
|
+
|
|
58
|
+
UNCHANGED = "unchanged"
|
|
59
|
+
"""The edit does not affect the output."""
|
|
60
|
+
UPDATED = "updated"
|
|
61
|
+
"""The output has a new value, computed exactly from the graph."""
|
|
62
|
+
STALE = "stale"
|
|
63
|
+
"""The new value is unknown: re-execute the workflow to obtain it."""
|
|
64
|
+
UNLINKED = "unlinked"
|
|
65
|
+
"""The output is not linked to any traced input ("not linked to extraction")."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReasonKind(str, Enum):
|
|
69
|
+
"""Why the graph alone could not produce an exact result."""
|
|
70
|
+
|
|
71
|
+
GUARD_FLIPPED = "guard_flipped"
|
|
72
|
+
"""A recorded decision or conversion now has a different outcome."""
|
|
73
|
+
GUARD_UNKNOWN = "guard_unknown"
|
|
74
|
+
"""A guard depends on a value that could not be recomputed."""
|
|
75
|
+
EVALUATION_ERROR = "evaluation_error"
|
|
76
|
+
"""Re-evaluating an operation raised; the workflow would have too."""
|
|
77
|
+
NOT_REPLAYABLE = "not_replayable"
|
|
78
|
+
"""An operation that cannot be re-run (e.g. an LLM call) has new inputs."""
|
|
79
|
+
UNTRACKED_INPUT = "untracked_input"
|
|
80
|
+
"""An edited input was not traced (``None``, ``bool``...)."""
|
|
81
|
+
TYPE_CHANGED = "type_changed"
|
|
82
|
+
"""An input or intermediate value changed type, which code may branch on unseen."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Reasons that invalidate the whole run rather than individual nodes.
|
|
86
|
+
RUN_LEVEL = frozenset(
|
|
87
|
+
{
|
|
88
|
+
ReasonKind.GUARD_FLIPPED,
|
|
89
|
+
ReasonKind.GUARD_UNKNOWN,
|
|
90
|
+
ReasonKind.EVALUATION_ERROR,
|
|
91
|
+
ReasonKind.UNTRACKED_INPUT,
|
|
92
|
+
ReasonKind.TYPE_CHANGED,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Reason:
|
|
99
|
+
"""One explanation of why re-execution is needed."""
|
|
100
|
+
|
|
101
|
+
kind: ReasonKind
|
|
102
|
+
message: str
|
|
103
|
+
key: str | None = None
|
|
104
|
+
node: Node | None = None
|
|
105
|
+
before: Any = None
|
|
106
|
+
after: Any = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class OutputChange:
|
|
111
|
+
"""The effect of an edit on one output.
|
|
112
|
+
|
|
113
|
+
``new`` is ``None`` when ``status`` is :attr:`Status.STALE`.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
key: str
|
|
117
|
+
status: Status
|
|
118
|
+
old: Any
|
|
119
|
+
new: Any
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class Mismatch:
|
|
124
|
+
"""An output whose propagated value disagrees with re-execution."""
|
|
125
|
+
|
|
126
|
+
key: str
|
|
127
|
+
status: Status
|
|
128
|
+
expected: Any
|
|
129
|
+
actual: Any
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class Verification:
|
|
134
|
+
"""Propagated values compared with the outputs of a real re-execution.
|
|
135
|
+
|
|
136
|
+
A mismatch on an output that propagation claimed to know exactly is a
|
|
137
|
+
tracing gap: something influenced the output without being recorded.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
mismatches: tuple[Mismatch, ...]
|
|
141
|
+
missing: tuple[str, ...] = ()
|
|
142
|
+
"""Outputs the trace has but the re-execution did not produce."""
|
|
143
|
+
unexpected: tuple[str, ...] = ()
|
|
144
|
+
"""Outputs the re-execution produced that the trace does not have."""
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def ok(self) -> bool:
|
|
148
|
+
"""Whether re-execution confirmed every claim propagation made."""
|
|
149
|
+
return not (self.mismatches or self.missing or self.unexpected)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Propagation:
|
|
154
|
+
"""The result of :func:`propagate`."""
|
|
155
|
+
|
|
156
|
+
trace: Trace
|
|
157
|
+
edits: Mapping[str, Any]
|
|
158
|
+
outputs: Mapping[str, OutputChange]
|
|
159
|
+
reasons: tuple[Reason, ...]
|
|
160
|
+
_values: Mapping[int, Any] = field(repr=False)
|
|
161
|
+
_stale: frozenset[int] = field(repr=False)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def needs_reexecution(self) -> bool:
|
|
165
|
+
"""Whether some output's new value can only come from re-execution."""
|
|
166
|
+
return any(r.kind in RUN_LEVEL for r in self.reasons) or any(
|
|
167
|
+
change.status is Status.STALE for change in self.outputs.values()
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def exact(self) -> bool:
|
|
172
|
+
"""Whether every output's new value is known from the graph alone."""
|
|
173
|
+
return not self.needs_reexecution
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def flipped_guards(self) -> list[Reason]:
|
|
177
|
+
"""The reasons that are guard flips."""
|
|
178
|
+
return [r for r in self.reasons if r.kind is ReasonKind.GUARD_FLIPPED]
|
|
179
|
+
|
|
180
|
+
def with_status(self, status: Status) -> dict[str, OutputChange]:
|
|
181
|
+
"""Outputs with the given status, by key."""
|
|
182
|
+
return {k: c for k, c in self.outputs.items() if c.status is status}
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def updated(self) -> dict[str, OutputChange]:
|
|
186
|
+
"""Outputs with a new, exactly known value."""
|
|
187
|
+
return self.with_status(Status.UPDATED)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def stale(self) -> dict[str, OutputChange]:
|
|
191
|
+
"""Outputs whose new value needs re-execution."""
|
|
192
|
+
return self.with_status(Status.STALE)
|
|
193
|
+
|
|
194
|
+
def value(self, ref: Any) -> Any:
|
|
195
|
+
"""The new value of any node (by key, traced value or node).
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
LookupError: if the node's new value is unknown.
|
|
199
|
+
"""
|
|
200
|
+
node = self.trace.node(ref)
|
|
201
|
+
if node.id in self._stale or any(r.kind in RUN_LEVEL for r in self.reasons):
|
|
202
|
+
raise LookupError(f"the new value of node #{node.id} is unknown; re-execute")
|
|
203
|
+
return self._values.get(node.id, node.value)
|
|
204
|
+
|
|
205
|
+
def verify(self, actual: Mapping[str, Any]) -> Verification:
|
|
206
|
+
"""Compare with the outputs of a re-execution on the edited inputs.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
actual: Output values by key, e.g. ``retrace.output_values()``.
|
|
210
|
+
"""
|
|
211
|
+
actual = {key: unwrap(value) for key, value in actual.items()}
|
|
212
|
+
mismatches = tuple(
|
|
213
|
+
Mismatch(key, change.status, change.new, actual[key])
|
|
214
|
+
for key, change in self.outputs.items()
|
|
215
|
+
if change.status is not Status.STALE
|
|
216
|
+
and key in actual
|
|
217
|
+
and not same_value(change.new, actual[key])
|
|
218
|
+
)
|
|
219
|
+
if any(r.kind in RUN_LEVEL for r in self.reasons):
|
|
220
|
+
# Execution may legitimately take another path and emit other keys.
|
|
221
|
+
return Verification(mismatches)
|
|
222
|
+
return Verification(
|
|
223
|
+
mismatches,
|
|
224
|
+
missing=tuple(k for k in self.outputs if k not in actual),
|
|
225
|
+
unexpected=tuple(k for k in actual if k not in self.outputs),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _resolve(arg: Any, values: Mapping[int, Any]) -> Any:
|
|
230
|
+
kind = type(arg)
|
|
231
|
+
if kind is Node:
|
|
232
|
+
arg = values.get(arg.id, arg.value)
|
|
233
|
+
kind = type(arg)
|
|
234
|
+
if kind is not Opaque:
|
|
235
|
+
return arg
|
|
236
|
+
if kind is Opaque:
|
|
237
|
+
raise ValueError(f"{arg!r} was not serializable and cannot be re-used")
|
|
238
|
+
if kind is list:
|
|
239
|
+
return [_resolve(a, values) for a in arg]
|
|
240
|
+
if isinstance(arg, tuple):
|
|
241
|
+
return rebuild_tuple(arg, [_resolve(a, values) for a in arg])
|
|
242
|
+
if kind is dict:
|
|
243
|
+
return {k: _resolve(v, values) for k, v in arg.items()}
|
|
244
|
+
return arg
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class NotReplayableError(LookupError):
|
|
248
|
+
"""Raised when evaluating a node whose operation cannot be re-run."""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def node_function(node: Node) -> Callable[..., Any] | None:
|
|
252
|
+
"""The function that re-evaluates ``node``, or ``None`` if it cannot be re-run.
|
|
253
|
+
|
|
254
|
+
A node's own function (set for ``@traced`` calls) takes precedence over
|
|
255
|
+
the registry, so equally named functions never get mixed up.
|
|
256
|
+
"""
|
|
257
|
+
if node.fn is NOT_REPLAYABLE:
|
|
258
|
+
return None
|
|
259
|
+
if node.fn is not None:
|
|
260
|
+
return node.fn
|
|
261
|
+
spec = get_op(node.op)
|
|
262
|
+
return spec.fn if spec is not None and spec.replayable else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def evaluate_node(node: Node, values: Mapping[int, Any] | None = None) -> Any:
|
|
266
|
+
"""Recompute ``node`` from its operands.
|
|
267
|
+
|
|
268
|
+
Operands take their value from ``values`` (by node id) when present, and
|
|
269
|
+
their recorded value otherwise. ``Decimal`` operations run under the
|
|
270
|
+
context they were recorded with.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
NotReplayableError: if the operation is unknown or not replayable.
|
|
274
|
+
"""
|
|
275
|
+
fn = node_function(node)
|
|
276
|
+
if fn is None:
|
|
277
|
+
raise NotReplayableError(f"operation {node.op!r} cannot be re-evaluated")
|
|
278
|
+
values = values or {}
|
|
279
|
+
args = _resolve(node.args, values)
|
|
280
|
+
kwargs = _resolve(node.kwargs, values) if node.kwargs else {}
|
|
281
|
+
if node.ctx is not None and node.trace is not None:
|
|
282
|
+
with decimal.localcontext(node.trace._contexts[node.ctx]):
|
|
283
|
+
return fn(*args, **kwargs)
|
|
284
|
+
return fn(*args, **kwargs)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def evaluate_outcome(node: Node, values: Mapping[int, Any] | None = None) -> Any:
|
|
288
|
+
"""Like :func:`evaluate_node`, but a guard that raises evaluates to :class:`Raised`.
|
|
289
|
+
|
|
290
|
+
For a guard, raising *is* the outcome to compare with the recorded one.
|
|
291
|
+
"""
|
|
292
|
+
if node.kind != GUARD:
|
|
293
|
+
return evaluate_node(node, values)
|
|
294
|
+
if node_function(node) is None:
|
|
295
|
+
raise NotReplayableError(f"operation {node.op!r} cannot be re-evaluated")
|
|
296
|
+
try:
|
|
297
|
+
return evaluate_node(node, values)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
return Raised.of(exc)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _describe(node: Node) -> str:
|
|
303
|
+
from redroot.trace import _render
|
|
304
|
+
|
|
305
|
+
return _render(node, 6, top=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def propagate(trace: Trace, edits: Mapping[str, Any]) -> Propagation:
|
|
309
|
+
"""Recompute ``trace``'s outputs as if its inputs had the edited values.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
trace: A recorded trace.
|
|
313
|
+
edits: New values by input key, e.g. ``{"ext:bank.balance": 900}``.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
KeyError: if an edit names an input the trace never saw.
|
|
317
|
+
"""
|
|
318
|
+
reasons: list[Reason] = []
|
|
319
|
+
changed: dict[int, Any] = {}
|
|
320
|
+
for key, raw in edits.items():
|
|
321
|
+
new = unwrap(raw)
|
|
322
|
+
node = trace.inputs.get(key)
|
|
323
|
+
if node is None:
|
|
324
|
+
untracked = trace.untracked_inputs
|
|
325
|
+
if key not in untracked:
|
|
326
|
+
raise KeyError(f"unknown input {key!r}")
|
|
327
|
+
if not same_value(new, untracked[key]):
|
|
328
|
+
reasons.append(
|
|
329
|
+
Reason(
|
|
330
|
+
ReasonKind.UNTRACKED_INPUT,
|
|
331
|
+
f"input {key!r} is not traced; only re-execution can apply its new value",
|
|
332
|
+
key=key,
|
|
333
|
+
before=untracked[key],
|
|
334
|
+
after=new,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
if same_value(new, node.value):
|
|
339
|
+
continue
|
|
340
|
+
if type(new) is not type(node.value):
|
|
341
|
+
reasons.append(
|
|
342
|
+
Reason(
|
|
343
|
+
ReasonKind.TYPE_CHANGED,
|
|
344
|
+
f"input {key!r} changed type from {type(node.value).__name__} "
|
|
345
|
+
f"to {type(new).__name__}",
|
|
346
|
+
key=key,
|
|
347
|
+
node=node,
|
|
348
|
+
before=node.value,
|
|
349
|
+
after=new,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
changed[node.id] = new
|
|
353
|
+
|
|
354
|
+
values: dict[int, Any] = dict(changed)
|
|
355
|
+
stale: set[int] = set()
|
|
356
|
+
if changed:
|
|
357
|
+
with decimal.localcontext(trace.decimal_context):
|
|
358
|
+
for node in trace.nodes[min(changed) :]:
|
|
359
|
+
if node.kind == LEAF:
|
|
360
|
+
continue
|
|
361
|
+
dirty = blocked = False
|
|
362
|
+
for operand in node.operands():
|
|
363
|
+
if operand.id in stale:
|
|
364
|
+
blocked = True
|
|
365
|
+
break
|
|
366
|
+
if operand.id in values:
|
|
367
|
+
dirty = True
|
|
368
|
+
if not (dirty or blocked):
|
|
369
|
+
continue
|
|
370
|
+
reason = _reevaluate(node, values, blocked)
|
|
371
|
+
if reason is None:
|
|
372
|
+
continue
|
|
373
|
+
if reason.kind is not ReasonKind.GUARD_FLIPPED:
|
|
374
|
+
stale.add(node.id)
|
|
375
|
+
if not blocked or node.kind == GUARD:
|
|
376
|
+
reasons.append(reason)
|
|
377
|
+
|
|
378
|
+
run_level = any(r.kind in RUN_LEVEL for r in reasons)
|
|
379
|
+
outputs: dict[str, OutputChange] = {}
|
|
380
|
+
for key, out in trace.outputs.items():
|
|
381
|
+
if run_level:
|
|
382
|
+
change = OutputChange(key, Status.STALE, out.value, None)
|
|
383
|
+
elif out.node is None:
|
|
384
|
+
change = OutputChange(key, Status.UNLINKED, out.value, out.value)
|
|
385
|
+
elif out.node.id in stale:
|
|
386
|
+
change = OutputChange(key, Status.STALE, out.value, None)
|
|
387
|
+
elif out.node.id in values:
|
|
388
|
+
change = OutputChange(key, Status.UPDATED, out.value, values[out.node.id])
|
|
389
|
+
else:
|
|
390
|
+
change = OutputChange(key, Status.UNCHANGED, out.value, out.value)
|
|
391
|
+
outputs[key] = change
|
|
392
|
+
|
|
393
|
+
return Propagation(
|
|
394
|
+
trace=trace,
|
|
395
|
+
edits=dict(edits),
|
|
396
|
+
outputs=outputs,
|
|
397
|
+
reasons=tuple(reasons),
|
|
398
|
+
_values=values,
|
|
399
|
+
_stale=frozenset(stale),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _reevaluate(node: Node, values: dict[int, Any], blocked: bool) -> Reason | None:
|
|
404
|
+
"""Re-evaluate one dirty node, updating ``values``.
|
|
405
|
+
|
|
406
|
+
Returns why the node could not be brought up to date, if it could not.
|
|
407
|
+
"""
|
|
408
|
+
if blocked:
|
|
409
|
+
if node.kind == GUARD:
|
|
410
|
+
return Reason(
|
|
411
|
+
ReasonKind.GUARD_UNKNOWN,
|
|
412
|
+
f"cannot re-check {_describe(node)}: an input could not be recomputed",
|
|
413
|
+
node=node,
|
|
414
|
+
)
|
|
415
|
+
return Reason(ReasonKind.NOT_REPLAYABLE, "depends on a stale value", node=node)
|
|
416
|
+
if node_function(node) is None:
|
|
417
|
+
kind = ReasonKind.GUARD_UNKNOWN if node.kind == GUARD else ReasonKind.NOT_REPLAYABLE
|
|
418
|
+
return Reason(kind, f"operation {node.op!r} cannot be re-evaluated", node=node)
|
|
419
|
+
try:
|
|
420
|
+
new = evaluate_outcome(node, values)
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
return Reason(
|
|
423
|
+
ReasonKind.EVALUATION_ERROR,
|
|
424
|
+
f"{_describe(node)} raised {type(exc).__name__}: {exc}",
|
|
425
|
+
node=node,
|
|
426
|
+
)
|
|
427
|
+
if same_value(new, node.value):
|
|
428
|
+
return None # early cutoff: dependents are unaffected
|
|
429
|
+
if node.kind == GUARD:
|
|
430
|
+
return Reason(
|
|
431
|
+
ReasonKind.GUARD_FLIPPED,
|
|
432
|
+
f"{_describe(node)} was {node.value!r}, now {new!r}",
|
|
433
|
+
node=node,
|
|
434
|
+
before=node.value,
|
|
435
|
+
after=new,
|
|
436
|
+
)
|
|
437
|
+
if type(new) is not type(node.value):
|
|
438
|
+
# Code may branch on the type (isinstance, formatting) unseen.
|
|
439
|
+
return Reason(
|
|
440
|
+
ReasonKind.TYPE_CHANGED,
|
|
441
|
+
f"{_describe(node)} changed type from {type(node.value).__name__} to {type(new).__name__}",
|
|
442
|
+
node=node,
|
|
443
|
+
before=node.value,
|
|
444
|
+
after=new,
|
|
445
|
+
)
|
|
446
|
+
values[node.id] = new
|
|
447
|
+
return None
|
redroot/py.typed
ADDED
|
File without changes
|
redroot/serialization.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""JSON (de)serialization of traces.
|
|
2
|
+
|
|
3
|
+
The format is versioned so traces stored with a run can be reloaded later,
|
|
4
|
+
e.g. to propagate a reviewer's edit on a server long after the run::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"format": "redroot.trace",
|
|
8
|
+
"version": 1,
|
|
9
|
+
"id": "…", "name": "run-42",
|
|
10
|
+
"decimal_context": {"prec": 28, "rounding": "ROUND_HALF_EVEN", …},
|
|
11
|
+
"nodes": [
|
|
12
|
+
{"id": 0, "op": "leaf", "kind": "leaf", "key": "ext:a", "value": 1200},
|
|
13
|
+
{"id": 1, "op": "gt", "kind": "guard", "args": [{"$node": 0}, 1000], "value": true},
|
|
14
|
+
…
|
|
15
|
+
],
|
|
16
|
+
"outputs": {"out:total": {"node": 2}, "out:year": {"value": 2025}},
|
|
17
|
+
"untracked_inputs": {"ext:married": true}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Values that JSON cannot express are tagged: ``{"$decimal": "1.10"}``,
|
|
21
|
+
``{"$tuple": [...]}``, ``{"$dict": [[key, value], ...]}`` (only for dicts with
|
|
22
|
+
non-string keys), ``{"$float": "nan"}``, ``{"$raised": "ZeroDivisionError"}``
|
|
23
|
+
for an operation that raised, ``{"$node": 3}`` for a reference to another
|
|
24
|
+
node. A node computed under a non-default ``decimal`` context refers to it by
|
|
25
|
+
index (``"ctx"``) into ``"decimal_contexts"``; ``"replayable": false`` marks
|
|
26
|
+
nodes that must never be re-run. Values of any other type are stored as ``{"$opaque": repr}``;
|
|
27
|
+
they load as :class:`~redroot.ops.Opaque`, and nodes using them cannot be
|
|
28
|
+
re-evaluated.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import decimal
|
|
35
|
+
import math
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from decimal import Decimal
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
from redroot._core import LEAF, NOT_REPLAYABLE, Node, Raised, Traced
|
|
41
|
+
from redroot.ops import Opaque
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from redroot.trace import Trace
|
|
45
|
+
|
|
46
|
+
__all__ = ["FORMAT", "VERSION", "decode_value", "encode_value", "trace_from_dict", "trace_to_dict"]
|
|
47
|
+
|
|
48
|
+
FORMAT = "redroot.trace"
|
|
49
|
+
VERSION = 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def encode_value(value: Any) -> Any:
|
|
53
|
+
"""Encode a plain value (or node reference) as JSON-compatible data."""
|
|
54
|
+
if value is None or value is True or value is False:
|
|
55
|
+
return value
|
|
56
|
+
kind = type(value)
|
|
57
|
+
if kind is int or kind is str:
|
|
58
|
+
return value
|
|
59
|
+
if kind is float:
|
|
60
|
+
return value if math.isfinite(value) else {"$float": repr(value)}
|
|
61
|
+
if kind is Node:
|
|
62
|
+
return {"$node": value.id}
|
|
63
|
+
if kind is Decimal:
|
|
64
|
+
return {"$decimal": str(value)}
|
|
65
|
+
if kind is list:
|
|
66
|
+
return [encode_value(item) for item in value]
|
|
67
|
+
if isinstance(value, tuple):
|
|
68
|
+
return {"$tuple": [encode_value(item) for item in value]}
|
|
69
|
+
if kind is dict:
|
|
70
|
+
if all(type(k) is str for k in value) and not (
|
|
71
|
+
len(value) == 1 and next(iter(value)).startswith("$")
|
|
72
|
+
):
|
|
73
|
+
return {k: encode_value(v) for k, v in value.items()}
|
|
74
|
+
return {"$dict": [[encode_value(k), encode_value(v)] for k, v in value.items()]}
|
|
75
|
+
if kind is slice:
|
|
76
|
+
return {
|
|
77
|
+
"$slice": [
|
|
78
|
+
encode_value(value.start),
|
|
79
|
+
encode_value(value.stop),
|
|
80
|
+
encode_value(value.step),
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
if kind is complex:
|
|
84
|
+
return {"$complex": [encode_value(value.real), encode_value(value.imag)]}
|
|
85
|
+
if kind is bytes:
|
|
86
|
+
return {"$bytes": base64.b64encode(value).decode("ascii")}
|
|
87
|
+
if isinstance(value, Traced):
|
|
88
|
+
return encode_value(value._rt_node.value)
|
|
89
|
+
if isinstance(value, Opaque):
|
|
90
|
+
return {"$opaque": value.description}
|
|
91
|
+
if isinstance(value, Raised):
|
|
92
|
+
return {"$raised": value.type_name}
|
|
93
|
+
return {"$opaque": f"{type(value).__qualname__}: {repr(value)[:200]}"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decode_value(data: Any, nodes: list[Node] | None = None) -> Any:
|
|
97
|
+
"""Decode :func:`encode_value` output. ``nodes`` resolves ``$node`` references."""
|
|
98
|
+
if isinstance(data, list):
|
|
99
|
+
return [decode_value(item, nodes) for item in data]
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
return data
|
|
102
|
+
if len(data) == 1:
|
|
103
|
+
tag, payload = next(iter(data.items()))
|
|
104
|
+
if tag.startswith("$"):
|
|
105
|
+
return _decode_tagged(tag, payload, nodes)
|
|
106
|
+
return {k: decode_value(v, nodes) for k, v in data.items()}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _decode_tagged(tag: str, payload: Any, nodes: list[Node] | None) -> Any:
|
|
110
|
+
if tag == "$node":
|
|
111
|
+
if nodes is None or not 0 <= payload < len(nodes):
|
|
112
|
+
raise ValueError(f"reference to unknown or later node #{payload}")
|
|
113
|
+
return nodes[payload]
|
|
114
|
+
if tag == "$decimal":
|
|
115
|
+
return Decimal(payload)
|
|
116
|
+
if tag == "$float":
|
|
117
|
+
return float(payload)
|
|
118
|
+
if tag == "$tuple":
|
|
119
|
+
return tuple(decode_value(item, nodes) for item in payload)
|
|
120
|
+
if tag == "$dict":
|
|
121
|
+
return {decode_value(k, nodes): decode_value(v, nodes) for k, v in payload}
|
|
122
|
+
if tag == "$slice":
|
|
123
|
+
return slice(*(decode_value(item, nodes) for item in payload))
|
|
124
|
+
if tag == "$complex":
|
|
125
|
+
return complex(*(decode_value(item, nodes) for item in payload))
|
|
126
|
+
if tag == "$bytes":
|
|
127
|
+
return base64.b64decode(payload)
|
|
128
|
+
if tag == "$opaque":
|
|
129
|
+
return Opaque(payload)
|
|
130
|
+
if tag == "$raised":
|
|
131
|
+
return Raised(payload)
|
|
132
|
+
raise ValueError(f"unknown value tag {tag!r}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _context_to_dict(ctx: decimal.Context) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"prec": ctx.prec,
|
|
138
|
+
"rounding": ctx.rounding,
|
|
139
|
+
"Emin": ctx.Emin,
|
|
140
|
+
"Emax": ctx.Emax,
|
|
141
|
+
"capitals": ctx.capitals,
|
|
142
|
+
"clamp": ctx.clamp,
|
|
143
|
+
"traps": sorted(signal.__name__ for signal, on in ctx.traps.items() if on),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _context_from_dict(data: Mapping[str, Any]) -> decimal.Context:
|
|
148
|
+
traps = [getattr(decimal, name) for name in data.get("traps", [])]
|
|
149
|
+
return decimal.Context(
|
|
150
|
+
prec=data["prec"],
|
|
151
|
+
rounding=data["rounding"],
|
|
152
|
+
Emin=data["Emin"],
|
|
153
|
+
Emax=data["Emax"],
|
|
154
|
+
capitals=data["capitals"],
|
|
155
|
+
clamp=data["clamp"],
|
|
156
|
+
traps=traps,
|
|
157
|
+
flags=[],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def trace_to_dict(trace: Trace) -> dict[str, Any]:
|
|
162
|
+
"""Serialize ``trace`` to JSON-compatible data."""
|
|
163
|
+
nodes = []
|
|
164
|
+
for node in trace.nodes:
|
|
165
|
+
item: dict[str, Any] = {
|
|
166
|
+
"id": node.id,
|
|
167
|
+
"op": node.op,
|
|
168
|
+
"kind": node.kind,
|
|
169
|
+
"value": encode_value(node.value),
|
|
170
|
+
}
|
|
171
|
+
if node.args:
|
|
172
|
+
item["args"] = [encode_value(arg) for arg in node.args]
|
|
173
|
+
if node.kwargs:
|
|
174
|
+
item["kwargs"] = {k: encode_value(v) for k, v in node.kwargs.items()}
|
|
175
|
+
if node.key is not None:
|
|
176
|
+
item["key"] = node.key
|
|
177
|
+
if node.meta:
|
|
178
|
+
item["meta"] = encode_value(node.meta)
|
|
179
|
+
if node.ctx is not None:
|
|
180
|
+
item["ctx"] = node.ctx
|
|
181
|
+
if node.fn is NOT_REPLAYABLE:
|
|
182
|
+
item["replayable"] = False
|
|
183
|
+
nodes.append(item)
|
|
184
|
+
return {
|
|
185
|
+
"format": FORMAT,
|
|
186
|
+
"version": VERSION,
|
|
187
|
+
"id": trace.id,
|
|
188
|
+
"name": trace.name,
|
|
189
|
+
"decimal_context": _context_to_dict(trace.decimal_context),
|
|
190
|
+
"decimal_contexts": [_context_to_dict(ctx) for ctx in trace.decimal_contexts],
|
|
191
|
+
"nodes": nodes,
|
|
192
|
+
"outputs": {
|
|
193
|
+
key: {"node": out.node.id}
|
|
194
|
+
if out.node is not None
|
|
195
|
+
else {"value": encode_value(out.value)}
|
|
196
|
+
for key, out in trace.outputs.items()
|
|
197
|
+
},
|
|
198
|
+
"untracked_inputs": {key: encode_value(v) for key, v in trace.untracked_inputs.items()},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def trace_from_dict(data: Mapping[str, Any]) -> Trace:
|
|
203
|
+
"""Rebuild a trace from :func:`trace_to_dict` output."""
|
|
204
|
+
from redroot.trace import Output, Trace
|
|
205
|
+
|
|
206
|
+
if data.get("format") != FORMAT:
|
|
207
|
+
raise ValueError(f"not a RedRoot trace (format={data.get('format')!r})")
|
|
208
|
+
if data.get("version") != VERSION:
|
|
209
|
+
raise ValueError(f"unsupported trace version {data.get('version')!r}; expected {VERSION}")
|
|
210
|
+
|
|
211
|
+
trace = Trace(data.get("name"), id=data["id"])
|
|
212
|
+
trace.decimal_context = _context_from_dict(data["decimal_context"])
|
|
213
|
+
trace._contexts = [_context_from_dict(ctx) for ctx in data.get("decimal_contexts", [])]
|
|
214
|
+
nodes: list[Node] = trace._nodes
|
|
215
|
+
for item in data["nodes"]:
|
|
216
|
+
if item["id"] != len(nodes):
|
|
217
|
+
raise ValueError(f"node ids must be consecutive; got {item['id']} at {len(nodes)}")
|
|
218
|
+
node = Node(
|
|
219
|
+
item["op"],
|
|
220
|
+
tuple(decode_value(arg, nodes) for arg in item.get("args", ())),
|
|
221
|
+
{k: decode_value(v, nodes) for k, v in item["kwargs"].items()}
|
|
222
|
+
if "kwargs" in item
|
|
223
|
+
else None,
|
|
224
|
+
decode_value(item["value"]),
|
|
225
|
+
item["kind"],
|
|
226
|
+
key=item.get("key"),
|
|
227
|
+
meta=decode_value(item["meta"]) if "meta" in item else None,
|
|
228
|
+
)
|
|
229
|
+
node.id = item["id"]
|
|
230
|
+
node.trace = trace
|
|
231
|
+
node.ctx = item.get("ctx")
|
|
232
|
+
if item.get("replayable") is False:
|
|
233
|
+
node.fn = NOT_REPLAYABLE
|
|
234
|
+
nodes.append(node)
|
|
235
|
+
if node.kind == LEAF and node.key is not None:
|
|
236
|
+
trace._leaves[node.key] = node
|
|
237
|
+
for key, out in data.get("outputs", {}).items():
|
|
238
|
+
if "node" in out:
|
|
239
|
+
node = nodes[out["node"]]
|
|
240
|
+
trace._outputs[key] = Output(key, node, node.value)
|
|
241
|
+
else:
|
|
242
|
+
trace._outputs[key] = Output(key, None, decode_value(out["value"]))
|
|
243
|
+
for key, value in data.get("untracked_inputs", {}).items():
|
|
244
|
+
trace._untracked[key] = decode_value(value)
|
|
245
|
+
return trace
|