redroot 0.2.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.
- redroot-0.4.0/CHANGELOG.md +219 -0
- {redroot-0.2.0 → redroot-0.4.0}/PKG-INFO +70 -4
- {redroot-0.2.0 → redroot-0.4.0}/README.md +69 -3
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/__init__.py +10 -4
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/_core.py +71 -1
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/functions.py +117 -3
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/instrument.py +392 -26
- redroot-0.4.0/src/redroot/limits.py +218 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/ops.py +17 -2
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/paths.py +83 -6
- redroot-0.4.0/src/redroot/propagation.py +713 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/serialization.py +17 -4
- redroot-0.4.0/src/redroot/trace.py +713 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/types.py +37 -10
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/validation.py +101 -11
- redroot-0.4.0/src/redroot/visualizer/data.py +248 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/graphviz.py +19 -11
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/web/server.py +13 -4
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/app.js +90 -68
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/index.html +6 -4
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/web/static/style.css +5 -0
- redroot-0.4.0/tests/test_bools.py +275 -0
- redroot-0.4.0/tests/test_choices.py +85 -0
- redroot-0.4.0/tests/test_fingerprints.py +101 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_instrument.py +13 -4
- redroot-0.4.0/tests/test_json.py +63 -0
- redroot-0.4.0/tests/test_limits.py +106 -0
- redroot-0.4.0/tests/test_overrides.py +129 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_propagation.py +78 -1
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_properties.py +25 -12
- redroot-0.4.0/tests/test_regressions.py +589 -0
- redroot-0.4.0/tests/test_sources.py +108 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_trace.py +49 -2
- redroot-0.4.0/tests/test_visualizers.py +132 -0
- redroot-0.2.0/CHANGELOG.md +0 -89
- redroot-0.2.0/src/redroot/propagation.py +0 -447
- redroot-0.2.0/src/redroot/trace.py +0 -474
- redroot-0.2.0/src/redroot/visualizer/data.py +0 -63
- redroot-0.2.0/tests/test_regressions.py +0 -278
- redroot-0.2.0/tests/test_visualizers.py +0 -53
- {redroot-0.2.0 → redroot-0.4.0}/.gitignore +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/LICENSE +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/pyproject.toml +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/cli.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/py.typed +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/networkx.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/src/redroot/visualizer/web/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/__init__.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/conftest.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/helpers.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_cli.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_docs.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_functions.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_paths.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_pydantic.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_serialization.py +0 -0
- {redroot-0.2.0 → redroot-0.4.0}/tests/test_types.py +0 -0
- {redroot-0.2.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
|
+
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
|
|
|
@@ -283,6 +328,27 @@ report = check_perturbation(guarded, {"A": 1200, "B": 300}, {"ext:A": 1100}, inp
|
|
|
283
328
|
assert report.consistent # graph agrees with re-execution wherever it claimed to know
|
|
284
329
|
```
|
|
285
330
|
|
|
331
|
+
### Booleans
|
|
332
|
+
|
|
333
|
+
Python does not allow subclassing `bool`, so by default comparisons return plain `bool`s (recorded as guards) and boolean inputs are untracked. Pass `bools=True` to trace them too:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
def form(ext):
|
|
337
|
+
eligible = ext["income"] > 30000
|
|
338
|
+
return {"eligible": eligible, "credit": 500 * eligible + 100 * ext["married"]}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
trace, _ = rr.run(form, {"income": 25000, "married": True}, input_root="ext", bools=True)
|
|
342
|
+
print(trace.explain("out:eligible")) # ext:income > 30000
|
|
343
|
+
|
|
344
|
+
result = trace.propagate({"ext:income": 40000}) # the comparison flips...
|
|
345
|
+
print(result.exact, result.outputs["out:credit"].new) # True 600
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A comparison then returns a `TracedBool`, which is data: a flag that is stored, counted or multiplied propagates exactly when it changes. A guard is recorded only where its truth value is used (`if`, `while`, `and`/`or`), and boolean inputs such as checkboxes become editable inputs.
|
|
349
|
+
|
|
350
|
+
A `TracedBool` is an `int` subclass that prints, formats, hashes and compares like `True`/`False`. In code you do not instrument, `isinstance(x, bool)` and `x is True` are false and `json.dumps` writes `1`/`0`. Instrumented code gets all three right: external calls receive real `bool`s. Use `redroot.validation.check_fidelity(workflow, inputs, bools=True)` to confirm that tracing leaves a workflow's outputs unchanged.
|
|
351
|
+
|
|
286
352
|
### Pydantic
|
|
287
353
|
|
|
288
354
|
With `redroot[pydantic]`, traced types work as model fields. Traced values pass through validation with their lineage, plain values become inputs, and models serialize to plain values.
|
|
@@ -295,7 +361,7 @@ Overhead is about 0.7 µs per traced operation (0.9 µs for `Decimal`) and about
|
|
|
295
361
|
|
|
296
362
|
## Limitations
|
|
297
363
|
|
|
298
|
-
-
|
|
364
|
+
- `int`, `float`, `Decimal` and `str` are traced, and `bool` with `bools=True`. `None` inputs (and `bool` inputs otherwise) are recorded as untracked: editing them requires re-execution.
|
|
299
365
|
- Without instrumentation, a few C-level operations lose lineage (see above). `redroot.validation` finds them. [docs/design.md](docs/design.md#what-remains-invisible) lists what remains invisible even with it.
|
|
300
366
|
- Lineage does not cross threads unless the context is propagated (`contextvars.copy_context()`), nor processes, nor `pickle`.
|
|
301
367
|
- `type(x) is float` is `False` for traced values (instrumented code is unaffected).
|
|
@@ -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
|
|
|
@@ -236,6 +281,27 @@ report = check_perturbation(guarded, {"A": 1200, "B": 300}, {"ext:A": 1100}, inp
|
|
|
236
281
|
assert report.consistent # graph agrees with re-execution wherever it claimed to know
|
|
237
282
|
```
|
|
238
283
|
|
|
284
|
+
### Booleans
|
|
285
|
+
|
|
286
|
+
Python does not allow subclassing `bool`, so by default comparisons return plain `bool`s (recorded as guards) and boolean inputs are untracked. Pass `bools=True` to trace them too:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
def form(ext):
|
|
290
|
+
eligible = ext["income"] > 30000
|
|
291
|
+
return {"eligible": eligible, "credit": 500 * eligible + 100 * ext["married"]}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
trace, _ = rr.run(form, {"income": 25000, "married": True}, input_root="ext", bools=True)
|
|
295
|
+
print(trace.explain("out:eligible")) # ext:income > 30000
|
|
296
|
+
|
|
297
|
+
result = trace.propagate({"ext:income": 40000}) # the comparison flips...
|
|
298
|
+
print(result.exact, result.outputs["out:credit"].new) # True 600
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
A comparison then returns a `TracedBool`, which is data: a flag that is stored, counted or multiplied propagates exactly when it changes. A guard is recorded only where its truth value is used (`if`, `while`, `and`/`or`), and boolean inputs such as checkboxes become editable inputs.
|
|
302
|
+
|
|
303
|
+
A `TracedBool` is an `int` subclass that prints, formats, hashes and compares like `True`/`False`. In code you do not instrument, `isinstance(x, bool)` and `x is True` are false and `json.dumps` writes `1`/`0`. Instrumented code gets all three right: external calls receive real `bool`s. Use `redroot.validation.check_fidelity(workflow, inputs, bools=True)` to confirm that tracing leaves a workflow's outputs unchanged.
|
|
304
|
+
|
|
239
305
|
### Pydantic
|
|
240
306
|
|
|
241
307
|
With `redroot[pydantic]`, traced types work as model fields. Traced values pass through validation with their lineage, plain values become inputs, and models serialize to plain values.
|
|
@@ -248,7 +314,7 @@ Overhead is about 0.7 µs per traced operation (0.9 µs for `Decimal`) and about
|
|
|
248
314
|
|
|
249
315
|
## Limitations
|
|
250
316
|
|
|
251
|
-
-
|
|
317
|
+
- `int`, `float`, `Decimal` and `str` are traced, and `bool` with `bools=True`. `None` inputs (and `bool` inputs otherwise) are recorded as untracked: editing them requires re-execution.
|
|
252
318
|
- Without instrumentation, a few C-level operations lose lineage (see above). `redroot.validation` finds them. [docs/design.md](docs/design.md#what-remains-invisible) lists what remains invisible even with it.
|
|
253
319
|
- Lineage does not cross threads unless the context is propagated (`contextvars.copy_context()`), nor processes, nor `pickle`.
|
|
254
320
|
- `type(x) is float` is `False` for traced values (instrumented code is unaffected).
|
|
@@ -15,9 +15,10 @@ every output becomes when an input changes, without running it again::
|
|
|
15
15
|
See the README for the full guide.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
from redroot._core import Node, Traced, active_trace, node_of, unwrap
|
|
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.
|
|
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,
|
|
@@ -28,9 +29,9 @@ from redroot.propagation import (
|
|
|
28
29
|
Verification,
|
|
29
30
|
)
|
|
30
31
|
from redroot.trace import Output, Trace, run, track
|
|
31
|
-
from redroot.types import TracedDecimal, TracedFloat, TracedInt, TracedStr
|
|
32
|
+
from redroot.types import TracedBool, TracedDecimal, TracedFloat, TracedInt, TracedStr
|
|
32
33
|
|
|
33
|
-
__version__ = "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",
|
|
@@ -49,6 +53,7 @@ __all__ = [
|
|
|
49
53
|
"Status",
|
|
50
54
|
"Trace",
|
|
51
55
|
"Traced",
|
|
56
|
+
"TracedBool",
|
|
52
57
|
"TracedDecimal",
|
|
53
58
|
"TracedFloat",
|
|
54
59
|
"TracedInt",
|
|
@@ -68,4 +73,5 @@ __all__ = [
|
|
|
68
73
|
"traced_llm",
|
|
69
74
|
"track",
|
|
70
75
|
"unwrap",
|
|
76
|
+
"unwrap_deep",
|
|
71
77
|
]
|
|
@@ -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
|
|
|
@@ -349,7 +368,7 @@ def record_result(
|
|
|
349
368
|
if isinstance(value, Traced) or kind is list or kind is dict or isinstance(value, tuple):
|
|
350
369
|
value = unwrap_deep(value) # e.g. a function returned one of its traced arguments
|
|
351
370
|
factory = _WRAPPERS.get(type(value))
|
|
352
|
-
if factory is not None:
|
|
371
|
+
if factory is not None and (type(value) is not bool or trace._bools):
|
|
353
372
|
return factory(
|
|
354
373
|
value, add_node(trace, op, args, kwargs, value, OP, meta=meta, ctx=ctx, fn=fn)
|
|
355
374
|
)
|
|
@@ -554,3 +573,54 @@ def observe_value(op: str, fn: Callable[..., Any], *operands: Any) -> Any:
|
|
|
554
573
|
raise
|
|
555
574
|
add_node(trace, op, tuple(refs), None, result, GUARD)
|
|
556
575
|
return result
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def compare(op: str, fn: Callable[[Any, Any], Any], left: Any, right: Any) -> Any:
|
|
579
|
+
"""Record a comparison of traced values.
|
|
580
|
+
|
|
581
|
+
In a trace that traces booleans the outcome is a traced boolean (data),
|
|
582
|
+
and a guard is recorded only where its truth value is used. Otherwise the
|
|
583
|
+
plain ``bool`` is returned and recorded as a guard right away.
|
|
584
|
+
"""
|
|
585
|
+
trace = _active.get()
|
|
586
|
+
if trace is not None and not isinstance(trace, Observer) and trace._bools:
|
|
587
|
+
return binary(op, fn, left, right)
|
|
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)
|