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/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
@@ -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