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.
@@ -0,0 +1,44 @@
1
+ """Export traces to GraphViz (requires ``redroot[viz]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from redroot.trace import Trace
8
+ from redroot.visualizer.data import node_label
9
+
10
+ _STYLES: dict[str, dict[str, str]] = {
11
+ "leaf": {"shape": "box", "style": "filled", "fillcolor": "#e1f5fe"},
12
+ "op": {"shape": "box", "style": "rounded"},
13
+ "guard": {"shape": "diamond", "style": "dashed", "color": "#ef6c00"},
14
+ "output": {"shape": "note", "style": "filled", "fillcolor": "#e8f5e9"},
15
+ }
16
+
17
+
18
+ def to_graphviz(
19
+ trace: Trace, output_file: str | None = None, *, view: bool = False, format: str = "svg"
20
+ ) -> Any:
21
+ """Build a ``graphviz.Digraph`` of ``trace``; render it if ``output_file`` is given.
22
+
23
+ Inputs are blue boxes, guards dashed diamonds and outputs green notes.
24
+ """
25
+ try:
26
+ from graphviz import Digraph
27
+ except ImportError as exc:
28
+ raise ImportError(
29
+ "graphviz is not installed. Install with 'pip install redroot[viz]'"
30
+ ) from exc
31
+
32
+ dot = Digraph(name=trace.name or "redroot", comment="RedRoot trace", format=format)
33
+ dot.attr(rankdir="LR")
34
+ for node in trace.nodes:
35
+ dot.node(str(node.id), node_label(node), **_STYLES.get(node.kind, {}))
36
+ for operand_id in dict.fromkeys(operand.id for operand in node.operands()):
37
+ dot.edge(str(operand_id), str(node.id))
38
+ for key, out in trace.outputs.items():
39
+ dot.node(key, key, **_STYLES["output"])
40
+ if out.node is not None:
41
+ dot.edge(str(out.node.id), key)
42
+ if output_file is not None:
43
+ dot.render(output_file, view=view, cleanup=True)
44
+ return dot
@@ -0,0 +1,36 @@
1
+ """Export traces to NetworkX (requires ``redroot[viz]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from redroot.trace import Trace
8
+
9
+
10
+ def to_networkx(trace: Trace) -> Any:
11
+ """Convert ``trace`` to a ``networkx.MultiDiGraph``.
12
+
13
+ Nodes are keyed by node id, with ``op``, ``kind``, ``key`` and ``value``
14
+ attributes. Each operand becomes an edge carrying its argument
15
+ ``position``. Outputs are extra nodes keyed by their output key, with
16
+ ``kind="output"``.
17
+ """
18
+ try:
19
+ import networkx as nx
20
+ except ImportError as exc:
21
+ raise ImportError(
22
+ "networkx is not installed. Install with 'pip install redroot[viz]'"
23
+ ) from exc
24
+
25
+ graph = nx.MultiDiGraph(name=trace.name, trace_id=trace.id)
26
+ for node in trace.nodes:
27
+ graph.add_node(
28
+ node.id, op=node.op, kind=node.kind, key=node.key, value=node.value, meta=node.meta
29
+ )
30
+ for position, operand in enumerate(node.operands()):
31
+ graph.add_edge(operand.id, node.id, position=position)
32
+ for key, out in trace.outputs.items():
33
+ graph.add_node(key, kind="output", value=out.value)
34
+ if out.node is not None:
35
+ graph.add_edge(out.node.id, key)
36
+ return graph
@@ -0,0 +1 @@
1
+ """The local web viewer for traces."""
@@ -0,0 +1,52 @@
1
+ """A small local web viewer for traces (requires ``redroot[web]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from redroot.trace import Trace
11
+ from redroot.visualizer.data import graph_data
12
+
13
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
14
+
15
+
16
+ def create_app(trace: Trace) -> Any:
17
+ """Create the Flask app serving the viewer for ``trace``."""
18
+ try:
19
+ from flask import Flask, jsonify, send_from_directory
20
+ except ImportError as exc:
21
+ raise ImportError(
22
+ "flask is not installed. Install with 'pip install redroot[web]'"
23
+ ) from exc
24
+
25
+ app = Flask(__name__, static_folder=None)
26
+ data = graph_data(trace)
27
+
28
+ @app.route("/")
29
+ def index() -> Any:
30
+ return send_from_directory(STATIC_DIR, "index.html")
31
+
32
+ @app.route("/api/graph")
33
+ def get_graph() -> Any:
34
+ return jsonify(data)
35
+
36
+ @app.route("/<path:path>")
37
+ def serve_static(path: str) -> Any:
38
+ return send_from_directory(STATIC_DIR, path)
39
+
40
+ return app
41
+
42
+
43
+ def run_server(
44
+ trace: Trace, host: str = "127.0.0.1", port: int = 8050, open_browser: bool = True
45
+ ) -> None:
46
+ """Serve the viewer for ``trace`` until interrupted."""
47
+ app = create_app(trace)
48
+ url = f"http://{host}:{port}"
49
+ print(f"RedRoot viewer running at {url} (Ctrl+C to stop)")
50
+ if open_browser:
51
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
52
+ app.run(host=host, port=port, debug=False)
@@ -0,0 +1,125 @@
1
+ // RedRoot trace viewer. All trace content is inserted with textContent:
2
+ // values come from user data and must never be interpreted as HTML.
3
+
4
+ const COLORS = { leaf: '#e1f5fe', op: '#ffffff', guard: '#fff3e0', output: '#e8f5e9' };
5
+
6
+ document.addEventListener('DOMContentLoaded', () => {
7
+ fetch('/api/graph')
8
+ .then((response) => response.json())
9
+ .then(render)
10
+ .catch((err) => console.error('Error loading trace:', err));
11
+ });
12
+
13
+ function el(tag, text, className) {
14
+ const node = document.createElement(tag);
15
+ if (text !== undefined) node.textContent = text;
16
+ if (className) node.className = className;
17
+ return node;
18
+ }
19
+
20
+ function render(data) {
21
+ document.getElementById('trace-name').textContent = data.name || data.id;
22
+ document.getElementById('node-count').textContent = data.nodes.length;
23
+ document.getElementById('guard-count').textContent =
24
+ data.nodes.filter((n) => n.kind === 'guard').length;
25
+ document.getElementById('output-count').textContent = data.outputs.length;
26
+
27
+ const nodes = data.nodes.map((n) => ({ ...n, gid: `n${n.id}` }));
28
+ const links = data.edges.map((e) => ({ source: `n${e.source}`, target: `n${e.target}` }));
29
+ data.outputs.forEach((out) => {
30
+ nodes.push({ gid: `o:${out.key}`, kind: 'output', label: out.key, output: out });
31
+ if (out.node !== null) links.push({ source: `n${out.node}`, target: `o:${out.key}` });
32
+ });
33
+
34
+ const parents = new Map(nodes.map((n) => [n.gid, []]));
35
+ links.forEach((l) => parents.get(l.target).push(l.source));
36
+
37
+ const container = document.getElementById('graph');
38
+ const width = container.clientWidth;
39
+ const height = container.clientHeight;
40
+ const svg = d3.select(container).append('svg').attr('width', width).attr('height', height);
41
+ const g = svg.append('g');
42
+ svg.call(d3.zoom().on('zoom', (event) => g.attr('transform', event.transform)));
43
+
44
+ svg.append('defs').append('marker')
45
+ .attr('id', 'arrow').attr('viewBox', '0 -5 10 10').attr('refX', 22)
46
+ .attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
47
+ .append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', '#999');
48
+
49
+ const simulation = d3.forceSimulation(nodes)
50
+ .force('link', d3.forceLink(links).id((d) => d.gid).distance(90))
51
+ .force('charge', d3.forceManyBody().strength(-250))
52
+ .force('center', d3.forceCenter(width / 2, height / 2))
53
+ .force('collide', d3.forceCollide(40));
54
+
55
+ const link = g.append('g').selectAll('line').data(links).enter().append('line')
56
+ .attr('class', 'link').attr('marker-end', 'url(#arrow)');
57
+
58
+ const node = g.append('g').selectAll('g').data(nodes).enter().append('g')
59
+ .attr('class', (d) => `node ${d.kind}`)
60
+ .call(d3.drag()
61
+ .on('start', (event, d) => {
62
+ if (!event.active) simulation.alphaTarget(0.3).restart();
63
+ d.fx = d.x; d.fy = d.y;
64
+ })
65
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
66
+ .on('end', (event, d) => {
67
+ if (!event.active) simulation.alphaTarget(0);
68
+ d.fx = null; d.fy = null;
69
+ }));
70
+
71
+ node.append('circle').attr('r', 16).attr('fill', (d) => COLORS[d.kind] || '#fff');
72
+ node.append('text').attr('y', -22).attr('text-anchor', 'middle')
73
+ .text((d) => (d.label.length > 28 ? `${d.label.slice(0, 27)}…` : d.label));
74
+ node.on('click', (event, d) => select(d));
75
+
76
+ simulation.on('tick', () => {
77
+ link.attr('x1', (d) => d.source.x).attr('y1', (d) => d.source.y)
78
+ .attr('x2', (d) => d.target.x).attr('y2', (d) => d.target.y);
79
+ node.attr('transform', (d) => `translate(${d.x},${d.y})`);
80
+ });
81
+
82
+ function lineage(gid) {
83
+ const seen = new Set([gid]);
84
+ const stack = [gid];
85
+ while (stack.length) {
86
+ for (const p of parents.get(stack.pop())) {
87
+ if (!seen.has(p)) { seen.add(p); stack.push(p); }
88
+ }
89
+ }
90
+ return seen;
91
+ }
92
+
93
+ function select(d) {
94
+ const keep = lineage(d.gid);
95
+ node.classed('dimmed', (n) => !keep.has(n.gid));
96
+ link.classed('dimmed', (l) => !(keep.has(l.source.gid) && keep.has(l.target.gid)));
97
+ showDetails(d);
98
+ }
99
+
100
+ const list = document.getElementById('outputs');
101
+ data.outputs.forEach((out) => {
102
+ const item = el('li');
103
+ item.append(el('code', out.key), el('span', ` = ${out.value}`, out.node === null ? 'muted' : ''));
104
+ item.addEventListener('click', () => select(nodes.find((n) => n.gid === `o:${out.key}`)));
105
+ list.append(item);
106
+ });
107
+ }
108
+
109
+ function showDetails(d) {
110
+ document.getElementById('details').classList.remove('hidden');
111
+ document.getElementById('details-title').textContent = d.kind === 'output' ? d.output.key : d.label;
112
+ const rows = d.kind === 'output'
113
+ ? [
114
+ ['value', d.output.value],
115
+ ['computed as', d.output.expression ?? 'not linked to any traced input'],
116
+ ['from inputs', d.output.sources.join(', ') || '—'],
117
+ ]
118
+ : [['kind', d.kind], ['operation', d.op], ['value', d.value], ['node', `#${d.id}`]];
119
+ if (d.key) rows.push(['key', d.key]);
120
+ Object.entries(d.meta || {}).forEach(([k, v]) => rows.push([k, v]));
121
+
122
+ const dl = document.getElementById('details-list');
123
+ dl.replaceChildren();
124
+ rows.forEach(([term, value]) => dl.append(el('dt', term), el('dd', String(value))));
125
+ }
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RedRoot Trace Viewer</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
9
+ </head>
10
+ <body>
11
+ <div id="container">
12
+ <aside id="sidebar">
13
+ <h1 class="logo">RedRoot</h1>
14
+ <p id="trace-name" class="muted"></p>
15
+ <div id="stats">
16
+ <span><b id="node-count">0</b> nodes</span>
17
+ <span><b id="guard-count">0</b> guards</span>
18
+ <span><b id="output-count">0</b> outputs</span>
19
+ </div>
20
+ <ul class="legend">
21
+ <li><span class="swatch leaf"></span>input</li>
22
+ <li><span class="swatch op"></span>operation</li>
23
+ <li><span class="swatch guard"></span>guard</li>
24
+ <li><span class="swatch output"></span>output</li>
25
+ </ul>
26
+ <section id="details" class="hidden">
27
+ <h2 id="details-title"></h2>
28
+ <dl id="details-list"></dl>
29
+ </section>
30
+ <h2>Outputs</h2>
31
+ <p class="muted">Click an output to highlight the inputs it was computed from.</p>
32
+ <ul id="outputs"></ul>
33
+ </aside>
34
+ <main id="graph"></main>
35
+ </div>
36
+ <script src="app.js"></script>
37
+ </body>
38
+ </html>
@@ -0,0 +1,49 @@
1
+ body, html {
2
+ margin: 0;
3
+ height: 100%;
4
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
5
+ background: #fafafa;
6
+ color: #222;
7
+ }
8
+
9
+ #container { display: flex; height: 100%; }
10
+
11
+ #sidebar {
12
+ width: 340px;
13
+ padding: 20px;
14
+ overflow-y: auto;
15
+ background: #fff;
16
+ border-right: 1px solid #ddd;
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ #graph { flex: 1; overflow: hidden; }
21
+
22
+ .logo { color: #e53935; margin: 0; }
23
+ .muted { color: #777; font-size: 0.85em; }
24
+ h2 { font-size: 1em; margin: 18px 0 6px; }
25
+
26
+ #stats { display: flex; gap: 12px; font-size: 0.9em; color: #555; }
27
+
28
+ .legend { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; font-size: 0.8em; }
29
+ .swatch { display: inline-block; width: 10px; height: 10px; border: 1px solid #555; border-radius: 50%; margin-right: 4px; }
30
+ .swatch.leaf { background: #e1f5fe; }
31
+ .swatch.op { background: #fff; }
32
+ .swatch.guard { background: #fff3e0; border-style: dashed; border-color: #ef6c00; }
33
+ .swatch.output { background: #e8f5e9; }
34
+
35
+ #details { background: #f1f3f4; border-radius: 8px; padding: 10px 14px; }
36
+ #details.hidden { display: none; }
37
+ #details dl { margin: 0; }
38
+ #details dt { font-weight: 600; font-size: 0.8em; color: #555; margin-top: 6px; }
39
+ #details dd { margin: 0; font-family: monospace; word-break: break-word; }
40
+
41
+ #outputs { list-style: none; padding: 0; font-size: 0.85em; }
42
+ #outputs li { padding: 4px 6px; border-radius: 4px; cursor: pointer; word-break: break-word; }
43
+ #outputs li:hover { background: #f1f3f4; }
44
+
45
+ .link { stroke: #999; stroke-opacity: 0.6; stroke-width: 1.5; }
46
+ .node circle { stroke: #333; stroke-width: 1.5; cursor: pointer; }
47
+ .node.guard circle { stroke: #ef6c00; stroke-dasharray: 3 2; }
48
+ .node text { font-size: 10px; fill: #444; pointer-events: none; }
49
+ .dimmed { opacity: 0.15; }
@@ -0,0 +1,309 @@
1
+ Metadata-Version: 2.5
2
+ Name: redroot
3
+ Version: 0.2.0
4
+ Summary: Value-level lineage for Python: trace where every output came from, and propagate input edits to every output without re-running
5
+ Project-URL: Homepage, https://github.com/gagan-gaurav/redthread
6
+ Project-URL: Documentation, https://github.com/gagan-gaurav/redthread#readme
7
+ Project-URL: Repository, https://github.com/gagan-gaurav/redthread
8
+ Project-URL: Issues, https://github.com/gagan-gaurav/redthread/issues
9
+ Project-URL: Changelog, https://github.com/gagan-gaurav/redthread/blob/main/CHANGELOG.md
10
+ Author: Gagan Gaurav
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: audit,dag,data-engineering,data-provenance,incremental-computation,lineage,llm,provenance,pydantic,tracing
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Provides-Extra: all
28
+ Requires-Dist: flask>=3.0; extra == 'all'
29
+ Requires-Dist: graphviz>=0.20; extra == 'all'
30
+ Requires-Dist: networkx>=3.0; extra == 'all'
31
+ Requires-Dist: pydantic>=2.0; extra == 'all'
32
+ Provides-Extra: dev
33
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
34
+ Requires-Dist: mypy==2.3.1; extra == 'dev'
35
+ Requires-Dist: pre-commit>=3.0; extra == 'dev'
36
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
37
+ Requires-Dist: pytest>=7.0; extra == 'dev'
38
+ Requires-Dist: ruff==0.16.10; extra == 'dev'
39
+ Provides-Extra: pydantic
40
+ Requires-Dist: pydantic>=2.0; extra == 'pydantic'
41
+ Provides-Extra: viz
42
+ Requires-Dist: graphviz>=0.20; extra == 'viz'
43
+ Requires-Dist: networkx>=3.0; extra == 'viz'
44
+ Provides-Extra: web
45
+ Requires-Dist: flask>=3.0; extra == 'web'
46
+ Description-Content-Type: text/markdown
47
+
48
+ <div align="center">
49
+
50
+ # <img width="128" height="128" alt="RedRoot logo" src="https://github.com/user-attachments/assets/82d895e5-a7ba-447e-a3dc-dfeb36c4c1dd" /> RedRoot
51
+ ### Trace Every Value to Its Roots
52
+
53
+ [![CI](https://github.com/gagan-gaurav/redthread/actions/workflows/ci.yml/badge.svg)](https://github.com/gagan-gaurav/redthread/actions/workflows/ci.yml)
54
+ [![PyPI Version](https://img.shields.io/pypi/v/redroot?color=red)](https://pypi.org/project/redroot/)
55
+ [![Python Versions](https://img.shields.io/pypi/pyversions/redroot)](https://pypi.org/project/redroot/)
56
+ [![License](https://img.shields.io/pypi/l/redroot)](https://opensource.org/licenses/MIT)
57
+
58
+ **RedRoot** records how every value in a Python computation was produced, at the level of individual values, so you can ask, in both directions:
59
+
60
+ **Where did this output come from?** &nbsp;·&nbsp; **What does every output become if this input changes?**
61
+
62
+ </div>
63
+
64
+ ---
65
+
66
+ Run a computation once under RedRoot and you get a graph from inputs to outputs. Click a number that looks wrong and see the exact inputs and arithmetic behind it. Correct one input and every output that used it is recomputed from the graph, instantly and together, without running your code again. When the graph cannot be sure (a branch would go the other way, an LLM call would need re-running), RedRoot says so instead of guessing.
67
+
68
+ ```python
69
+ import redroot as rr
70
+
71
+
72
+ def workflow(ext):
73
+ a, b = ext["A"], ext["B"]
74
+ return {
75
+ "form1": {"line_12": a + b},
76
+ "form2": {"line_7": a * b},
77
+ "table3": {"amount": a},
78
+ "table4": {"total": b},
79
+ }
80
+
81
+
82
+ trace, _ = rr.run(workflow, {"A": 1200, "B": 300}, input_root="ext")
83
+
84
+ print(trace.sources("out:form1.line_12")) # [<Node #0 leaf ext:A=1200>, <Node #1 leaf ext:B=300>]
85
+ print(trace.explain("out:form2.line_7")) # ext:A * ext:B
86
+ print(trace.dependents("ext:A")) # ['out:form1.line_12', 'out:form2.line_7', 'out:table3.amount']
87
+
88
+ result = trace.propagate({"ext:A": 1300})
89
+ for key, change in result.outputs.items():
90
+ print(key, change.status.value, change.old, "->", change.new)
91
+ # out:form1.line_12 updated 1500 -> 1600
92
+ # out:form2.line_7 updated 360000 -> 390000
93
+ # out:table3.amount updated 1200 -> 1300
94
+ # out:table4.total unchanged 300 -> 300
95
+ ```
96
+
97
+ ## Features
98
+
99
+ - **Drop-in traced values.** `TracedInt`, `TracedFloat`, `TracedDecimal` and `TracedStr` are real `int`/`float`/`Decimal`/`str` instances. Results, types and errors are exactly those of plain Python.
100
+ - **Re-evaluable graph.** Every operand is recorded in order, constants included, so any node can be recomputed, not just read.
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
+ - **Semantic keys.** Inputs and outputs are named by their path, e.g. `ext:bank.line_items[3].amount`.
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
+ - **Re-execution as the arbiter.** Built-in tools compare the graph's answers with a real re-run, and measure coverage on your workloads.
105
+ - **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
+ - **Persistable.** Traces serialize to versioned JSON, ship with a CLI, and come with a web viewer plus GraphViz and NetworkX exports.
107
+ - **Zero dependencies** in the core. Pydantic, visualization and the web viewer are optional extras.
108
+
109
+ ## Installation
110
+
111
+ ```bash
112
+ pip install redroot # core, no dependencies
113
+ pip install "redroot[pydantic]" # traced types as Pydantic fields
114
+ pip install "redroot[viz,web]" # GraphViz/NetworkX exports and the web viewer
115
+ ```
116
+
117
+ Python 3.10 or newer is required.
118
+
119
+ ## Guide
120
+
121
+ ### Recording a trace
122
+
123
+ Operations on traced values are recorded while a `Trace` is active. Outside one, traced values behave exactly like plain values and nothing is recorded, so nothing accumulates in long-running processes.
124
+
125
+ ```python
126
+ from decimal import Decimal
127
+ import redroot as rr
128
+
129
+ extracted = {"bank": {"balance": Decimal("12450.00"), "fee": Decimal("12.50")}, "married": True}
130
+
131
+ with rr.Trace("run-42") as trace:
132
+ ext = trace.track(extracted, root="ext") # traced copy; inputs keyed by path
133
+ net = ext["bank"]["balance"] - ext["bank"]["fee"] * 12
134
+ trace.collect({"summary": {"net": net, "year": 2025}}, root="out")
135
+
136
+ print(list(trace.inputs)) # ['ext:bank.balance', 'ext:bank.fee']
137
+ print(trace.explain("out:summary.net")) # ext:bank.balance - (ext:bank.fee * 12)
138
+
139
+ # bool and None cannot be traced; such inputs are listed instead:
140
+ print(dict(trace.untracked_inputs)) # {'ext:married': True}
141
+
142
+ # 2025 was not computed from any input:
143
+ print(trace.outputs["out:summary.year"].linked) # False
144
+ ```
145
+
146
+ `rr.run(workflow, inputs, input_root=..., output_root=...)` does the `track`, call and `collect` steps for you.
147
+
148
+ ### Navigating backwards and forwards
149
+
150
+ | Question | Call |
151
+ |---|---|
152
+ | Which inputs produced this output? | `trace.sources("out:form1.line_12")` |
153
+ | Show me the computation. | `trace.explain("out:form1.line_12")` |
154
+ | Which outputs use this input? | `trace.dependents("ext:A")` |
155
+ | Every node involved | `trace.ancestors(...)`, `trace.descendants(...)` |
156
+
157
+ ### Propagating an edit
158
+
159
+ `trace.propagate(edits)` re-evaluates only the nodes downstream of the edited inputs and reports, for every output:
160
+
161
+ | Status | Meaning |
162
+ |---|---|
163
+ | `UPDATED` | New value, computed exactly from the graph. |
164
+ | `UNCHANGED` | The edit does not affect this output. |
165
+ | `STALE` | The graph cannot be sure. Re-execute to get the value. |
166
+ | `UNLINKED` | The output is not linked to any traced input. |
167
+
168
+ The graph is only trusted while execution would follow the same path. Branches are the case a pure graph gets wrong:
169
+
170
+ ```python
171
+ def guarded(ext):
172
+ a, b = ext["A"], ext["B"]
173
+ line_12 = a + b if a > 1000 else 0
174
+ return {"line_12": line_12, "other": b * 2}
175
+
176
+
177
+ trace, _ = rr.run(guarded, {"A": 1200, "B": 300}, input_root="ext")
178
+
179
+ print(trace.propagate({"ext:A": 1100}).exact) # True
180
+
181
+ result = trace.propagate({"ext:A": 800}) # A > 1000 no longer holds
182
+ print(result.exact) # False
183
+ print(result.reasons[0].message) # ext:A > 1000 was True, now False
184
+ print(result.outputs["out:other"].status.value) # stale
185
+ ```
186
+
187
+ 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
+
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`.
190
+
191
+ ### Verifying with re-execution
192
+
193
+ Re-execution on the edited inputs is the source of truth. The graph is the instant path.
194
+
195
+ ```python
196
+ inputs = {"A": 1200, "B": 300}
197
+ edits = {"ext:A": 1100}
198
+ trace, _ = rr.run(guarded, inputs, input_root="ext")
199
+ result = trace.propagate(edits)
200
+
201
+ retrace, _ = rr.run(guarded, rr.apply_edits(inputs, edits, root="ext"), input_root="ext")
202
+ assert result.verify(retrace.output_values()).ok
203
+ ```
204
+
205
+ A mismatch on an output the graph claimed to know is a tracing gap. `redroot.validation.check_perturbation` runs this experiment in one call.
206
+
207
+ ### Lookups, external services and LLM calls
208
+
209
+ Decorate a step to record each call as one node. Deterministic functions are re-called during propagation; the others keep their recorded result until their inputs change.
210
+
211
+ ```python
212
+ @rr.traced(name="irs.national_standard")
213
+ def national_standard(household_size: int) -> Decimal:
214
+ return {1: Decimal("785"), 2: Decimal("1410"), 3: Decimal("1617")}[household_size]
215
+
216
+
217
+ @rr.traced(name="geo.county", deterministic=False) # e.g. a web lookup
218
+ def county_for_zip(zip_code: str) -> str:
219
+ return "Travis"
220
+
221
+
222
+ @rr.traced_llm(model="gpt-4o", task="summarize")
223
+ def summarize(text: str) -> str:
224
+ rr.annotate(prompt_tokens=len(text) // 4) # stored on the node
225
+ return text[:40]
226
+
227
+
228
+ with rr.Trace() as trace:
229
+ size = trace.leaf(3, "ext:household_size")
230
+ trace.output("out:allowance", national_standard(size) * 12)
231
+
232
+ print(trace.propagate({"ext:household_size": 2}).outputs["out:allowance"].new) # 16920
233
+ ```
234
+
235
+ For results computed elsewhere, `rr.derive(value, inputs, "op-name")` records the link manually.
236
+
237
+ ### Running generated or untrusted-shape code: instrumentation
238
+
239
+ Operator overloading cannot see everything. CPython computes `0.8 * income` (a plain `float` times a traced `int`), `float(text)`, `", ".join(parts)` and `sorted([income, 20000.5])` in C, without consulting the traced value, so those results would lose their lineage. For code you execute from source, `exec_source` rewrites the syntax tree first (as pytest does for `assert`) and closes these gaps. Calls into code it cannot see into (`date.fromisoformat`, `json.dumps`, third-party libraries) that consume traced values but return none are guarded, so editing their inputs requires re-execution rather than producing a stale value:
240
+
241
+ ```python
242
+ from redroot.instrument import exec_source
243
+
244
+ generated = """
245
+ def main(ext):
246
+ gross = float(ext["gross"].replace(",", ""))
247
+ return {"net": 0.8 * gross, "label": f"{gross:,.2f} USD"}
248
+ """
249
+ namespace = exec_source(generated)
250
+ trace, _ = rr.run(namespace["main"], {"gross": "5,200.50"}, input_root="ext")
251
+ print(trace.explain("out:net")) # 0.8 * float(ext:gross.replace(',', ''))
252
+ ```
253
+
254
+ 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
+
256
+ ### Saving, inspecting and viewing traces
257
+
258
+ ```python
259
+ data = trace.to_json() # versioned format; store it with the run
260
+ restored = rr.Trace.from_json(data)
261
+ print(restored.propagate({"ext:gross": "6,000"}).outputs["out:net"].new) # 4800.0
262
+ ```
263
+
264
+ ```bash
265
+ redroot explain trace.json out:summary.net # expression and source inputs
266
+ redroot coverage trace.json # coverage report + re-evaluation check
267
+ redroot visualize trace.json # web viewer (needs redroot[web])
268
+ ```
269
+
270
+ To re-evaluate a loaded trace, the modules defining its `@traced` functions must be imported, since they register those operations.
271
+
272
+ ### Measuring whether tracing covers your workload
273
+
274
+ Before relying on propagation, measure it on real runs:
275
+
276
+ ```python
277
+ from redroot.validation import check_identity, check_perturbation, coverage
278
+
279
+ # Linked and replayable outputs, guards, and the inputs that feed guards:
280
+ print(coverage(trace))
281
+ assert check_identity(trace).ok # every node re-evaluates to its recorded value
282
+ report = check_perturbation(guarded, {"A": 1200, "B": 300}, {"ext:A": 1100}, input_root="ext")
283
+ assert report.consistent # graph agrees with re-execution wherever it claimed to know
284
+ ```
285
+
286
+ ### Pydantic
287
+
288
+ 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.
289
+
290
+ ## How it works
291
+
292
+ Traced types subclass the builtins and override their operators. Each operation computes its result with the same function the graph later uses to re-evaluate it, so recomputation reproduces execution exactly. Results that leave tracking (a `bool` from a comparison, an `int()` conversion, a hash) are recorded as *guards*. Propagation re-checks guards, and any change means execution could have diverged. Details, guarantees and limits are in [docs/design.md](docs/design.md). For an end-to-end review workflow (record, review, edit, verify), see [docs/integration.md](docs/integration.md).
293
+
294
+ Overhead is about 0.7 µs per traced operation (0.9 µs for `Decimal`) and about 240 bytes per recorded node (`python benchmarks/overhead.py`).
295
+
296
+ ## Limitations
297
+
298
+ - Only `int`, `float`, `Decimal` and `str` are traced. `bool` and `None` inputs are recorded as untracked: editing them requires re-execution.
299
+ - 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
+ - Lineage does not cross threads unless the context is propagated (`contextvars.copy_context()`), nor processes, nor `pickle`.
301
+ - `type(x) is float` is `False` for traced values (instrumented code is unaffected).
302
+
303
+ ## Contributing
304
+
305
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md), and [AGENTS.md](AGENTS.md) for the architecture's invariants.
306
+
307
+ ## License
308
+
309
+ MIT. See [LICENSE](LICENSE).