redroot 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (58) hide show
  1. redroot-0.4.0/CHANGELOG.md +219 -0
  2. {redroot-0.3.0 → redroot-0.4.0}/PKG-INFO +48 -3
  3. {redroot-0.3.0 → redroot-0.4.0}/README.md +47 -2
  4. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/__init__.py +6 -2
  5. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/_core.py +57 -0
  6. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/functions.py +117 -3
  7. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/instrument.py +328 -19
  8. redroot-0.4.0/src/redroot/limits.py +218 -0
  9. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/ops.py +14 -2
  10. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/paths.py +83 -6
  11. redroot-0.4.0/src/redroot/propagation.py +713 -0
  12. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/serialization.py +15 -3
  13. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/trace.py +293 -94
  14. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/validation.py +37 -12
  15. redroot-0.4.0/src/redroot/visualizer/data.py +248 -0
  16. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/graphviz.py +19 -11
  17. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/web/server.py +13 -4
  18. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/app.js +90 -68
  19. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/index.html +6 -4
  20. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/style.css +5 -0
  21. {redroot-0.3.0 → redroot-0.4.0}/tests/test_bools.py +5 -1
  22. redroot-0.4.0/tests/test_choices.py +85 -0
  23. redroot-0.4.0/tests/test_fingerprints.py +101 -0
  24. {redroot-0.3.0 → redroot-0.4.0}/tests/test_instrument.py +3 -1
  25. redroot-0.4.0/tests/test_json.py +63 -0
  26. redroot-0.4.0/tests/test_limits.py +106 -0
  27. redroot-0.4.0/tests/test_overrides.py +129 -0
  28. {redroot-0.3.0 → redroot-0.4.0}/tests/test_propagation.py +78 -1
  29. {redroot-0.3.0 → redroot-0.4.0}/tests/test_properties.py +5 -3
  30. redroot-0.4.0/tests/test_regressions.py +589 -0
  31. redroot-0.4.0/tests/test_sources.py +108 -0
  32. {redroot-0.3.0 → redroot-0.4.0}/tests/test_trace.py +49 -2
  33. redroot-0.4.0/tests/test_visualizers.py +132 -0
  34. redroot-0.3.0/CHANGELOG.md +0 -107
  35. redroot-0.3.0/src/redroot/propagation.py +0 -447
  36. redroot-0.3.0/src/redroot/visualizer/data.py +0 -63
  37. redroot-0.3.0/tests/test_regressions.py +0 -278
  38. redroot-0.3.0/tests/test_visualizers.py +0 -53
  39. {redroot-0.3.0 → redroot-0.4.0}/.gitignore +0 -0
  40. {redroot-0.3.0 → redroot-0.4.0}/LICENSE +0 -0
  41. {redroot-0.3.0 → redroot-0.4.0}/pyproject.toml +0 -0
  42. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/cli.py +0 -0
  43. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/py.typed +0 -0
  44. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/types.py +0 -0
  45. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/__init__.py +0 -0
  46. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/networkx.py +0 -0
  47. {redroot-0.3.0 → redroot-0.4.0}/src/redroot/visualizer/web/__init__.py +0 -0
  48. {redroot-0.3.0 → redroot-0.4.0}/tests/__init__.py +0 -0
  49. {redroot-0.3.0 → redroot-0.4.0}/tests/conftest.py +0 -0
  50. {redroot-0.3.0 → redroot-0.4.0}/tests/helpers.py +0 -0
  51. {redroot-0.3.0 → redroot-0.4.0}/tests/test_cli.py +0 -0
  52. {redroot-0.3.0 → redroot-0.4.0}/tests/test_docs.py +0 -0
  53. {redroot-0.3.0 → redroot-0.4.0}/tests/test_functions.py +0 -0
  54. {redroot-0.3.0 → redroot-0.4.0}/tests/test_paths.py +0 -0
  55. {redroot-0.3.0 → redroot-0.4.0}/tests/test_pydantic.py +0 -0
  56. {redroot-0.3.0 → redroot-0.4.0}/tests/test_serialization.py +0 -0
  57. {redroot-0.3.0 → redroot-0.4.0}/tests/test_types.py +0 -0
  58. {redroot-0.3.0 → redroot-0.4.0}/tests/test_validation.py +0 -0
@@ -0,0 +1,219 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - `@traced` functions defined in code run through `exec()` (which have no
15
+ module) are named `<exec>.<name>` instead of `<name>`, and `@traced`
16
+ refuses the names of built-in operations. Traces saved by 0.3.0 that used
17
+ such helpers report them as not replayable.
18
+ - `explain()` is written for reviewers: other outputs appear by name
19
+ (`out:summary.total_matched - out:summary.total_expected`) instead of being
20
+ expanded, containers with more than three items are summarised
21
+ (`deposits_by_day([5 items], [11 items], 'SQUARE')`), long constants are
22
+ shortened and the formula stays within `max_length` (200) characters.
23
+ `explain(..., full=True)` expands everything. Reasons use the same
24
+ rendering, capped at 120 characters.
25
+ - Graphs stay small: `visualizer.graph_data()` groups inputs (and repeated
26
+ outputs) that differ only by list index (`ext:deposits[*].amount · 11
27
+ inputs`), hides list-length checks, focuses on one output's lineage up to
28
+ other outputs (`focus=`, with drill-down), and returns at most `max_nodes`
29
+ nodes. The web viewer and `to_graphviz()` use it; the viewer's
30
+ `/api/graph?focus=<key>` serves focused graphs.
31
+ - An edit naming an input the run did not have, under one of its input roots
32
+ (e.g. an added row), is reported as the new run-level reason `NEW_INPUT`
33
+ instead of raising `KeyError`, so callers can handle it like any other
34
+ re-execution case. Keys under other roots still raise.
35
+ - `exec_source()` gives each source a unique default filename
36
+ (`<workflow 1a2b3c...>`) instead of `<workflow>`, so concurrent workflows
37
+ cannot be confused.
38
+ - Recalculation scales with the edit, not the run: `propagate()` follows an
39
+ index from each step to the steps that use it (built once per trace)
40
+ instead of scanning everything recorded after the edited input, and output
41
+ statuses are computed when first read. On a run with 10,000 invoices
42
+ (120,000 steps, 70,000 outputs), editing one invoice went from 103 ms to
43
+ under 2 ms. `descendants()` and `dependents()` use the same index.
44
+
45
+ ### Added
46
+ - Overriding calculated values: `propagate(edits, overrides={"out:summary.total":
47
+ Decimal("9000.00")})` pins that step's value (by output key, node or traced
48
+ value) and recalculates everything after it, re-checking decisions as
49
+ usual. Outputs bound to it get the new status `OVERRIDDEN`;
50
+ `Propagation.overridden` lists them, and `verify()` skips what overrides
51
+ reach, since a re-execution cannot apply them.
52
+ - `propagate(..., changed_only=True)` reports only the outputs whose status
53
+ is `UPDATED`, `STALE` or `OVERRIDDEN`.
54
+ - Traces record a fingerprint of every `@traced` function they use (a hash
55
+ of its source, or `@traced(version=...)`), serialized as `"functions"`.
56
+ When a loaded trace is recalculated and the registered function differs
57
+ (or is not registered), its results are reported stale with the new reason
58
+ `FUNCTION_CHANGED` instead of being recomputed with different code.
59
+ `Trace.changed_functions()` lists such functions up front.
60
+ - `redroot.limits`: bounds on recalculation for traces from untrusted places.
61
+ The riskiest operations (powers, shifts, repetition, padding and format
62
+ widths, `str.replace` growth, `decimal` precision) are refused before they
63
+ run, every result's size is checked, and `Limits(max_seconds=...,
64
+ max_steps=...)` adds time and step budgets. Size limits are on by default
65
+ for `propagate()` and `check_identity()`; exceeding one raises
66
+ `LimitExceeded`.
67
+ - Inputs can carry their source from the start: `track(data, root,
68
+ envelope=Envelope())` recognises values wrapped as `{"value": ...,
69
+ "source": {...}}`, keys each by its field's path (`ext:bank.balance`), traces
70
+ the value in place and stores the source on the input
71
+ (`node.meta["source"]`); `meta=fn` attaches any metadata. `run()`,
72
+ `apply_edits()`, `check_perturbation()` and `check_fidelity()` accept
73
+ `envelope` too.
74
+ - In instrumented code, plain `json.dumps(...)` (no `default=` or `cls=`) is
75
+ one repeatable step, so its output stays traced and updates exactly,
76
+ however deeply the payload nests traced values (named tuples and dict
77
+ subclasses such as `OrderedDict` included); a custom encoder remains an
78
+ opaque, guarded call. `json.dump` writes real values and is guarded:
79
+ recalculation cannot rewrite the file, and what was written may be read
80
+ back, so editing what it wrote requires re-execution.
81
+ - In instrumented code, `x if cond else y` whose branches are literals or
82
+ variables is recorded as a choice step: when the condition flips, the
83
+ other value is picked exactly instead of requiring re-execution
84
+ (checkboxes such as `"Yes" if married else "Off"`, picking one of two
85
+ rates). A single comparison used as the condition is recorded as data even
86
+ without `bools=True`. Falls back to a guarded branch when a branch cannot
87
+ be evaluated yet or is not a scalar.
88
+ - `benchmarks/propagate.py` measures recalculation on large runs.
89
+
90
+ ### Fixed
91
+
92
+ - In instrumented code, a call whose arguments hold traced values more than
93
+ two levels deep, or inside a dict or list subclass, is now recorded (or
94
+ guarded) instead of losing its lineage silently.
95
+ - In class bodies, conditional expressions in method defaults and decorator
96
+ arguments read class-level names again.
97
+ - An unassigned local read in a conditional expression raises
98
+ `UnboundLocalError`, as in plain Python, not `NameError`.
99
+ - `apply_edits(..., envelope=...)` accepts edits to an envelope's other
100
+ fields (`ext:balance.source.page`).
101
+ - `explain()` puts parentheses around a conditional expression nested in
102
+ another one, and rejects `max_depth`/`max_length` below 1.
103
+ - Focused graphs show an input that is also an output as the input, and
104
+ `max_nodes` counts every node, after grouping.
105
+ - Function fingerprints include the constant values a helper reads from its
106
+ globals, closure and defaults: two helpers with the same source but
107
+ different rates were swapped after a trace was loaded.
108
+ - A named tuple passed to a `@traced` function is saved with its class
109
+ name, and a loaded trace reports such steps as not replayable, instead of
110
+ replaying them with a plain tuple.
111
+ - Overriding an output bound to an input without a key crashed.
112
+ - Reading a `Propagation` after its trace recorded more raises
113
+ `RuntimeError`; it used to mix the old results with the new steps.
114
+ - Limits: modular `pow` and converting or formatting `Decimal`s with huge
115
+ exponents are checked before they run (a tampered trace could stall a
116
+ recalculation for hours); `%(key)s` templates whose key holds digits and
117
+ `0 << n` are no longer refused.
118
+ - `apply_edits()` can add a field or append a row, and explains edits it
119
+ cannot apply instead of raising `IndexError`/`TypeError`.
120
+
121
+
122
+ ## [0.3.0] - 2026-10-05
123
+
124
+ ### Added
125
+ - Boolean tracing, opt-in with `Trace(bools=True)` (or `run(..., bools=True)`):
126
+ comparisons return `TracedBool` values that are data rather than guards, so
127
+ flags that are stored or computed with propagate exactly; a guard is
128
+ recorded only where a flag's truth value is used. Boolean inputs become
129
+ editable inputs. Instrumented code also routes `not`, `x is True/False`,
130
+ `isinstance(x, bool)` and `x in traced_str`, and passes real `bool`s to
131
+ external code.
132
+ - `validation.check_fidelity()`: run a workflow plainly and traced and
133
+ compare their outputs, catching differences tracing itself introduces.
134
+ - `redroot.unwrap_deep()` is public.
135
+
136
+ ### Fixed
137
+ - `explain()` no longer doubles parentheses around operator expressions
138
+ passed to calls, e.g. `round(x * 1.1, 2)` instead of `round((x * 1.1), 2)`.
139
+
140
+ ## [0.2.0] - 2026-10-02
141
+
142
+ Re-evaluable traces: RedRoot now answers "what does every output become if
143
+ this input changes?" without re-running the code, and says when it cannot.
144
+
145
+ ### Added
146
+ - `Trace`: a per-run recording, used as a context manager, with inputs
147
+ (`track`, `leaf`) and outputs (`collect`, `output`) keyed by semantic
148
+ paths such as `ext:bank.line_items[3].amount`; `run()` helper.
149
+ - Navigation: `sources()`, `ancestors()`, `descendants()`, `dependents()`,
150
+ `explain()`.
151
+ - Forward propagation: `Trace.propagate()` returns per-output
152
+ `UNCHANGED`/`UPDATED`/`STALE`/`UNLINKED` statuses with reasons, with early
153
+ cutoff, and `Propagation.verify()` compares against a re-execution.
154
+ - Guards: comparisons, truth tests, `int()`/`float()`/`hash()`/`len()` and
155
+ container shapes are recorded and re-checked during propagation.
156
+ - `TracedDecimal`; `round()`, `math.floor/ceil/trunc`, `format()`, `str()`,
157
+ `divmod()`, `str` and `Decimal` methods stay traced; results that are
158
+ lists, tuples or dicts are traced item by item.
159
+ - `@traced` (one re-callable node per call), `@traced_llm`, `annotate()`,
160
+ `derive()`.
161
+ - `redroot.instrument`: source rewriting that traces what operator
162
+ overloading cannot see (`0.8 * traced_int`, `float(text)`, f-strings,
163
+ `", ".join(...)`, indexing with traced ints).
164
+ - `redroot.validation`: `coverage()`, `check_identity()`,
165
+ `check_perturbation()`.
166
+ - Versioned JSON serialization (`to_json`/`from_json`); CLI commands
167
+ `explain` and `coverage`; web viewer highlights an output's lineage.
168
+ - `docs/design.md`, `docs/integration.md`, `AGENTS.md`, `SECURITY.md`,
169
+ a tracing-overhead benchmark.
170
+
171
+ ### Changed
172
+ - **Renamed from RedThread to RedRoot.** The name `redthread` on PyPI belongs
173
+ to an unrelated project, so the package is now `pip install redroot` and
174
+ `import redroot`; the command-line tool is `redroot`, and the trace format
175
+ is identified as `redroot.trace`.
176
+ - **Breaking:** nodes are lightweight objects with integer ids, owned by a
177
+ `Trace`; operations are recorded only inside an active trace (outside one,
178
+ traced values behave like plain values).
179
+ - **Breaking:** every operand is recorded in order, constants included.
180
+ - Pydantic is now an optional extra (`redroot[pydantic]`); the core has no
181
+ dependencies.
182
+ - About 8x faster per traced operation than 0.1.0.
183
+
184
+ ### Removed
185
+ - **Breaking:** `LineageGraph`, `TracedValue`, `get_context()`,
186
+ `lineage_context()`, `transform()` and `trace()`, superseded by `Trace`,
187
+ `annotate()` and `explain()`.
188
+
189
+ ### Fixed
190
+ - Mixed-type arithmetic returned wrong values (`TracedInt(5) + 1.5 == 6`).
191
+ - `TracedInt / 2` dropped tracing.
192
+ - `transform()` and `@traced_llm` raised `TypeError`.
193
+ - `lineage_context()` did not isolate recorded nodes; the global registry
194
+ grew without bound.
195
+ - Passing a traced value through a Pydantic model severed its lineage.
196
+ - The web viewer inserted trace values into the page as HTML.
197
+ - Soundness gaps found by adversarial review, where propagation reported an
198
+ exact value that re-execution contradicted: exceptions caught by
199
+ `try`/`except` (now recorded as guards), `decimal.localcontext()` (each
200
+ `Decimal` node keeps its context), intermediate values changing type,
201
+ `@traced` helpers sharing a name, inputs reaching `@traced` functions
202
+ through sets, dict views or closures, `hash(-1) == hash(-2)`, and, under
203
+ instrumentation, `bisect`/`sorted`/`heapq` with plain floats, `min`/`max`
204
+ with closure keys, `map(float, ...)`, `sum(gen, start)`, unbound
205
+ `str` methods, `repr`, f-strings of containers, and opaque library calls
206
+ such as `date.fromisoformat` (now guarded).
207
+ - `apply_edits()` failed on tuple inputs.
208
+
209
+ ### Security
210
+ - The web viewer renders trace content as text only.
211
+
212
+ ## [0.1.0] - 2026-01-24
213
+
214
+ Prototype, not published to PyPI.
215
+
216
+ ### Added
217
+ - `TracedInt`, `TracedFloat`, `TracedStr`, a lineage graph
218
+ with JSON export, LLM transformation tracking, Pydantic integration,
219
+ GraphViz/NetworkX export and a web visualizer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redroot
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Value-level lineage for Python: trace where every output came from, and propagate input edits to every output without re-running
5
5
  Project-URL: Homepage, https://github.com/gagan-gaurav/redthread
6
6
  Project-URL: Documentation, https://github.com/gagan-gaurav/redthread#readme
@@ -101,7 +101,10 @@ for key, change in result.outputs.items():
101
101
  - **Guards.** Comparisons, branches, conversions and lookups are recorded. If an edit would change one, every output is reported `stale` rather than shown with a value that may be wrong.
102
102
  - **Semantic keys.** Inputs and outputs are named by their path, e.g. `ext:bank.line_items[3].amount`.
103
103
  - **No cooperation needed from the code.** Tracing happens through operator overloading. For code you run from source, such as generated code, `redroot.instrument` closes the remaining gaps.
104
+ - **Fast on large runs.** Recalculation visits only what an edit reaches: one edit in a run of 120,000 steps takes about 2 ms.
105
+ - **Fix calculated values.** Override any calculated step and everything after it is recalculated.
104
106
  - **Re-execution as the arbiter.** Built-in tools compare the graph's answers with a real re-run, and measure coverage on your workloads.
107
+ - **Safe to load.** Traces record which version of each helper they used, and recalculation is bounded against tampered traces.
105
108
  - **Opaque steps.** `@traced` turns a function (a tax-table lookup, a geocoder) into one re-callable node. `@traced_llm` marks LLM calls as not replayable.
106
109
  - **Persistable.** Traces serialize to versioned JSON, ship with a CLI, and come with a web viewer plus GraphViz and NetworkX exports.
107
110
  - **Zero dependencies** in the core. Pydantic, visualization and the web viewer are optional extras.
@@ -145,6 +148,18 @@ print(trace.outputs["out:summary.year"].linked) # False
145
148
 
146
149
  `rr.run(workflow, inputs, input_root=..., output_root=...)` does the `track`, call and `collect` steps for you.
147
150
 
151
+ Extracted values often arrive wrapped with their provenance. `envelope=rr.Envelope()` keys each wrapped value by its field, traces it in place (the workflow sees the same shape) and stores the source on the input:
152
+
153
+ ```python
154
+ extracted = {"balance": {"value": Decimal("12450.00"), "source": {"file": "stmt.pdf", "page": 2}}}
155
+ with rr.Trace() as trace:
156
+ ext = trace.track(extracted, root="ext", envelope=rr.Envelope())
157
+ print(list(trace.inputs)) # ['ext:balance']
158
+ print(trace.inputs["ext:balance"].meta) # {'source': {'file': 'stmt.pdf', 'page': 2}}
159
+ ```
160
+
161
+ `track(..., meta=lambda key, value: {...})` attaches any other metadata.
162
+
148
163
  ### Navigating backwards and forwards
149
164
 
150
165
  | Question | Call |
@@ -186,7 +201,31 @@ print(result.outputs["out:other"].status.value) # stale
186
201
 
187
202
  When a guard flips, *every* output is reported stale, not just those computed under the branch. A trace only records the path that ran: writes on the other path are invisible to it, so no output can be vouched for. See [docs/design.md](docs/design.md#why-a-flipped-guard-makes-every-output-stale).
188
203
 
189
- Exceptions count as branches too: an operation that raised (and was caught) is recorded with its outcome, so an edit that makes it stop raising flips it. Other reasons for re-execution include an edit to an untracked input, a value that changes type, an operation that would now raise, and an input change reaching a non-replayable step. Each is reported in `result.reasons`.
204
+ Exceptions count as branches too: an operation that raised (and was caught) is recorded with its outcome, so an edit that makes it stop raising flips it. Other reasons for re-execution include an edit to an untracked input or to an input the run did not have (a new row), a value that changes type, an operation that would now raise, and an input change reaching a non-replayable step. Each is reported in `result.reasons`.
205
+
206
+ On large runs, `trace.propagate(edits, changed_only=True)` reports only the outputs that changed: recalculation itself already visits only what the edit reaches.
207
+
208
+ ### Fixing a calculated value
209
+
210
+ A reviewer may find that a *calculated* value is wrong rather than an input. Override it, and everything after it is recalculated, with decisions re-checked as usual:
211
+
212
+ ```python
213
+ def taxes(ext):
214
+ total = ext["wages"] + ext["interest"]
215
+ return {"total_income": total, "tax": (total * Decimal("0.2")).quantize(Decimal("0.01"))}
216
+
217
+
218
+ trace, _ = rr.run(
219
+ taxes, {"wages": Decimal("5000.00"), "interest": Decimal("120.00")}, input_root="ext"
220
+ )
221
+ result = trace.propagate({}, overrides={"out:total_income": Decimal("9000.00")})
222
+ for key, change in result.outputs.items():
223
+ print(key, change.status.value, change.new)
224
+ # out:total_income overridden 9000.00
225
+ # out:tax updated 1800.00
226
+ ```
227
+
228
+ The overridden step keeps its value even if its own inputs are edited. A re-execution cannot apply an override, so `verify()` skips what overrides reach.
190
229
 
191
230
  ### Verifying with re-execution
192
231
 
@@ -251,6 +290,8 @@ trace, _ = rr.run(namespace["main"], {"gross": "5,200.50"}, input_root="ext")
251
290
  print(trace.explain("out:net")) # 0.8 * float(ext:gross.replace(',', ''))
252
291
  ```
253
292
 
293
+ Instrumentation also records plain `json.dumps(...)` as one repeatable step, and turns `x if cond else y` with literal or variable branches (`"Yes" if married else "Off"`, picking one of two rates) into a choice step, so a flipped condition updates outputs exactly instead of requiring re-execution.
294
+
254
295
  Instrumented code behaves exactly like the original. `exec_source` executes the code it is given: only use it on code you would run anyway.
255
296
 
256
297
  ### Saving, inspecting and viewing traces
@@ -267,7 +308,11 @@ redroot coverage trace.json # coverage report + re-evaluation c
267
308
  redroot visualize trace.json # web viewer (needs redroot[web])
268
309
  ```
269
310
 
270
- To re-evaluate a loaded trace, the modules defining its `@traced` functions must be imported, since they register those operations.
311
+ To re-evaluate a loaded trace, the modules defining its `@traced` functions must be imported, since they register those operations. A trace records a fingerprint of each one (a hash of its source, or `@traced(version="...")`). If the registered function differs from the one the run used, its results are reported stale (reason `FUNCTION_CHANGED`) instead of being recomputed with new code; `trace.changed_functions()` lists them up front.
312
+
313
+ Recalculating a trace runs the operations it names, so a tampered trace could ask for something huge (`10 ** 10**9`). Size limits are on by default; for traces from untrusted places add a budget, e.g. `trace.propagate(edits, limits=rr.Limits(max_seconds=2))`. Exceeding a limit raises `rr.LimitExceeded`.
314
+
315
+ The viewer and `to_graphviz()` stay readable on large runs: inputs that differ only by list index are grouped (`ext:deposits[*].amount · 11 inputs`), and clicking an output shows its lineage up to other outputs, which you can open in turn (`redroot.visualizer.graph_data(trace, focus=...)`).
271
316
 
272
317
  ### Measuring whether tracing covers your workload
273
318
 
@@ -54,7 +54,10 @@ for key, change in result.outputs.items():
54
54
  - **Guards.** Comparisons, branches, conversions and lookups are recorded. If an edit would change one, every output is reported `stale` rather than shown with a value that may be wrong.
55
55
  - **Semantic keys.** Inputs and outputs are named by their path, e.g. `ext:bank.line_items[3].amount`.
56
56
  - **No cooperation needed from the code.** Tracing happens through operator overloading. For code you run from source, such as generated code, `redroot.instrument` closes the remaining gaps.
57
+ - **Fast on large runs.** Recalculation visits only what an edit reaches: one edit in a run of 120,000 steps takes about 2 ms.
58
+ - **Fix calculated values.** Override any calculated step and everything after it is recalculated.
57
59
  - **Re-execution as the arbiter.** Built-in tools compare the graph's answers with a real re-run, and measure coverage on your workloads.
60
+ - **Safe to load.** Traces record which version of each helper they used, and recalculation is bounded against tampered traces.
58
61
  - **Opaque steps.** `@traced` turns a function (a tax-table lookup, a geocoder) into one re-callable node. `@traced_llm` marks LLM calls as not replayable.
59
62
  - **Persistable.** Traces serialize to versioned JSON, ship with a CLI, and come with a web viewer plus GraphViz and NetworkX exports.
60
63
  - **Zero dependencies** in the core. Pydantic, visualization and the web viewer are optional extras.
@@ -98,6 +101,18 @@ print(trace.outputs["out:summary.year"].linked) # False
98
101
 
99
102
  `rr.run(workflow, inputs, input_root=..., output_root=...)` does the `track`, call and `collect` steps for you.
100
103
 
104
+ Extracted values often arrive wrapped with their provenance. `envelope=rr.Envelope()` keys each wrapped value by its field, traces it in place (the workflow sees the same shape) and stores the source on the input:
105
+
106
+ ```python
107
+ extracted = {"balance": {"value": Decimal("12450.00"), "source": {"file": "stmt.pdf", "page": 2}}}
108
+ with rr.Trace() as trace:
109
+ ext = trace.track(extracted, root="ext", envelope=rr.Envelope())
110
+ print(list(trace.inputs)) # ['ext:balance']
111
+ print(trace.inputs["ext:balance"].meta) # {'source': {'file': 'stmt.pdf', 'page': 2}}
112
+ ```
113
+
114
+ `track(..., meta=lambda key, value: {...})` attaches any other metadata.
115
+
101
116
  ### Navigating backwards and forwards
102
117
 
103
118
  | Question | Call |
@@ -139,7 +154,31 @@ print(result.outputs["out:other"].status.value) # stale
139
154
 
140
155
  When a guard flips, *every* output is reported stale, not just those computed under the branch. A trace only records the path that ran: writes on the other path are invisible to it, so no output can be vouched for. See [docs/design.md](docs/design.md#why-a-flipped-guard-makes-every-output-stale).
141
156
 
142
- Exceptions count as branches too: an operation that raised (and was caught) is recorded with its outcome, so an edit that makes it stop raising flips it. Other reasons for re-execution include an edit to an untracked input, a value that changes type, an operation that would now raise, and an input change reaching a non-replayable step. Each is reported in `result.reasons`.
157
+ Exceptions count as branches too: an operation that raised (and was caught) is recorded with its outcome, so an edit that makes it stop raising flips it. Other reasons for re-execution include an edit to an untracked input or to an input the run did not have (a new row), a value that changes type, an operation that would now raise, and an input change reaching a non-replayable step. Each is reported in `result.reasons`.
158
+
159
+ On large runs, `trace.propagate(edits, changed_only=True)` reports only the outputs that changed: recalculation itself already visits only what the edit reaches.
160
+
161
+ ### Fixing a calculated value
162
+
163
+ A reviewer may find that a *calculated* value is wrong rather than an input. Override it, and everything after it is recalculated, with decisions re-checked as usual:
164
+
165
+ ```python
166
+ def taxes(ext):
167
+ total = ext["wages"] + ext["interest"]
168
+ return {"total_income": total, "tax": (total * Decimal("0.2")).quantize(Decimal("0.01"))}
169
+
170
+
171
+ trace, _ = rr.run(
172
+ taxes, {"wages": Decimal("5000.00"), "interest": Decimal("120.00")}, input_root="ext"
173
+ )
174
+ result = trace.propagate({}, overrides={"out:total_income": Decimal("9000.00")})
175
+ for key, change in result.outputs.items():
176
+ print(key, change.status.value, change.new)
177
+ # out:total_income overridden 9000.00
178
+ # out:tax updated 1800.00
179
+ ```
180
+
181
+ The overridden step keeps its value even if its own inputs are edited. A re-execution cannot apply an override, so `verify()` skips what overrides reach.
143
182
 
144
183
  ### Verifying with re-execution
145
184
 
@@ -204,6 +243,8 @@ trace, _ = rr.run(namespace["main"], {"gross": "5,200.50"}, input_root="ext")
204
243
  print(trace.explain("out:net")) # 0.8 * float(ext:gross.replace(',', ''))
205
244
  ```
206
245
 
246
+ Instrumentation also records plain `json.dumps(...)` as one repeatable step, and turns `x if cond else y` with literal or variable branches (`"Yes" if married else "Off"`, picking one of two rates) into a choice step, so a flipped condition updates outputs exactly instead of requiring re-execution.
247
+
207
248
  Instrumented code behaves exactly like the original. `exec_source` executes the code it is given: only use it on code you would run anyway.
208
249
 
209
250
  ### Saving, inspecting and viewing traces
@@ -220,7 +261,11 @@ redroot coverage trace.json # coverage report + re-evaluation c
220
261
  redroot visualize trace.json # web viewer (needs redroot[web])
221
262
  ```
222
263
 
223
- To re-evaluate a loaded trace, the modules defining its `@traced` functions must be imported, since they register those operations.
264
+ To re-evaluate a loaded trace, the modules defining its `@traced` functions must be imported, since they register those operations. A trace records a fingerprint of each one (a hash of its source, or `@traced(version="...")`). If the registered function differs from the one the run used, its results are reported stale (reason `FUNCTION_CHANGED`) instead of being recomputed with new code; `trace.changed_functions()` lists them up front.
265
+
266
+ Recalculating a trace runs the operations it names, so a tampered trace could ask for something huge (`10 ** 10**9`). Size limits are on by default; for traces from untrusted places add a budget, e.g. `trace.propagate(edits, limits=rr.Limits(max_seconds=2))`. Exceeding a limit raises `rr.LimitExceeded`.
267
+
268
+ The viewer and `to_graphviz()` stay readable on large runs: inputs that differ only by list index are grouped (`ext:deposits[*].amount · 11 inputs`), and clicking an output shows its lineage up to other outputs, which you can open in turn (`redroot.visualizer.graph_data(trace, focus=...)`).
224
269
 
225
270
  ### Measuring whether tracing covers your workload
226
271
 
@@ -17,7 +17,8 @@ See the README for the full guide.
17
17
 
18
18
  from redroot._core import Node, Traced, active_trace, node_of, unwrap, unwrap_deep
19
19
  from redroot.functions import annotate, derive, traced, traced_llm
20
- from redroot.paths import apply_edits, format_key, parse_key
20
+ from redroot.limits import LimitExceeded, Limits
21
+ from redroot.paths import Envelope, apply_edits, format_key, parse_key
21
22
  from redroot.propagation import (
22
23
  Mismatch,
23
24
  OutputChange,
@@ -30,7 +31,7 @@ from redroot.propagation import (
30
31
  from redroot.trace import Output, Trace, run, track
31
32
  from redroot.types import TracedBool, TracedDecimal, TracedFloat, TracedInt, TracedStr
32
33
 
33
- __version__ = "0.3.0"
34
+ __version__ = "0.4.0"
34
35
 
35
36
 
36
37
  def is_traced(value: object) -> bool:
@@ -39,6 +40,9 @@ def is_traced(value: object) -> bool:
39
40
 
40
41
 
41
42
  __all__ = [
43
+ "Envelope",
44
+ "LimitExceeded",
45
+ "Limits",
42
46
  "Mismatch",
43
47
  "Node",
44
48
  "Output",
@@ -124,6 +124,25 @@ def rebuild_tuple(template: tuple[Any, ...], items: Iterator[Any] | list[Any]) -
124
124
  return type(template)._make(items) # type: ignore[attr-defined, no-any-return]
125
125
 
126
126
 
127
+ class LoadedTuple(tuple): # type: ignore[type-arg]
128
+ """A named tuple (or other tuple subclass) read back from a saved trace.
129
+
130
+ Its class is not restored (loading a trace imports nothing), so a node
131
+ using it cannot be re-run: recalculation reports it, rather than passing
132
+ a plain tuple to code that may check the type.
133
+ """
134
+
135
+ type_name: str
136
+
137
+ def __new__(cls, items: Any, type_name: str) -> LoadedTuple:
138
+ self = super().__new__(cls, items)
139
+ self.type_name = type_name
140
+ return self
141
+
142
+ def __repr__(self) -> str:
143
+ return f"{self.type_name}{tuple.__repr__(self)}"
144
+
145
+
127
146
  class Raised:
128
147
  """The recorded outcome of an operation that raised an exception.
129
148
 
@@ -567,3 +586,41 @@ def compare(op: str, fn: Callable[[Any, Any], Any], left: Any, right: Any) -> An
567
586
  if trace is not None and not isinstance(trace, Observer) and trace._bools:
568
587
  return binary(op, fn, left, right)
569
588
  return observe_value(op, fn, left, right)
589
+
590
+
591
+ def compare_data(op: str, fn: Callable[[Any, Any], Any], left: Any, right: Any) -> Any:
592
+ """Record a comparison as data, a traced boolean, whatever ``Trace.bools`` says.
593
+
594
+ For internal consumers that use the outcome as data and never let the
595
+ traced boolean escape (e.g. instrumented conditional expressions).
596
+ """
597
+ trace = _active.get()
598
+ if trace is None or isinstance(trace, Observer):
599
+ return observe_value(op, fn, left, right)
600
+ refs: list[Any] = []
601
+ plains: list[Any] = []
602
+ linked = False
603
+ for operand in (left, right):
604
+ if isinstance(operand, Traced):
605
+ node = operand._rt_node
606
+ plains.append(node.value)
607
+ if node.trace is trace:
608
+ refs.append(node)
609
+ linked = True
610
+ else:
611
+ refs.append(node.value)
612
+ else:
613
+ plains.append(operand)
614
+ refs.append(operand)
615
+ if not linked:
616
+ return fn(*plains)
617
+ try:
618
+ result = fn(*plains)
619
+ except Exception as exc:
620
+ record_raise(trace, op, tuple(refs), None, exc)
621
+ raise
622
+ if type(result) is not bool:
623
+ add_node(trace, op, tuple(refs), None, result, GUARD)
624
+ return result
625
+ node = add_node(trace, op, tuple(refs), None, result, OP)
626
+ return _WRAPPERS[bool](result, node)