flow-xray 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.
- flow_xray/__init__.py +38 -0
- flow_xray/__main__.py +5 -0
- flow_xray/cli.py +149 -0
- flow_xray/debugger.py +119 -0
- flow_xray/export_dot.py +50 -0
- flow_xray/export_html.py +219 -0
- flow_xray/ir.py +39 -0
- flow_xray/static/viewer.html +52 -0
- flow_xray/trace.py +354 -0
- flow_xray/value.py +133 -0
- flow_xray/viewer.py +69 -0
- flow_xray-0.2.0.dist-info/METADATA +147 -0
- flow_xray-0.2.0.dist-info/RECORD +16 -0
- flow_xray-0.2.0.dist-info/WHEEL +4 -0
- flow_xray-0.2.0.dist-info/entry_points.txt +3 -0
- flow_xray-0.2.0.dist-info/licenses/LICENSE +21 -0
flow_xray/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flow-xray — execution graph debugger.
|
|
3
|
+
|
|
4
|
+
Primary API: ``trace`` (decorator + runner for capturing execution DAGs).
|
|
5
|
+
|
|
6
|
+
Also includes scalar Value autodiff core + inspection exports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from flow_xray.trace import TraceNode, TraceResult, trace
|
|
10
|
+
|
|
11
|
+
from flow_xray.debugger import GraphDebugger, TwoStepDebugger, iter_backward_steps
|
|
12
|
+
from flow_xray.export_dot import value_graph_to_dot, write_dot
|
|
13
|
+
from flow_xray.export_html import standalone_viewer_html, write_standalone_viewer_html
|
|
14
|
+
from flow_xray.ir import graph_to_ir, ir_to_json
|
|
15
|
+
from flow_xray.value import Value, backward_from, topological_order, zero_grad
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# execution tracing (primary)
|
|
19
|
+
"trace",
|
|
20
|
+
"TraceNode",
|
|
21
|
+
"TraceResult",
|
|
22
|
+
# scalar autodiff
|
|
23
|
+
"Value",
|
|
24
|
+
"backward_from",
|
|
25
|
+
"topological_order",
|
|
26
|
+
"zero_grad",
|
|
27
|
+
"value_graph_to_dot",
|
|
28
|
+
"write_dot",
|
|
29
|
+
"standalone_viewer_html",
|
|
30
|
+
"write_standalone_viewer_html",
|
|
31
|
+
"TwoStepDebugger",
|
|
32
|
+
"GraphDebugger",
|
|
33
|
+
"iter_backward_steps",
|
|
34
|
+
"graph_to_ir",
|
|
35
|
+
"ir_to_json",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
flow_xray/__main__.py
ADDED
flow_xray/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry points.
|
|
3
|
+
|
|
4
|
+
``flow-xray run`` — execute a script with @trace, export HTML execution graph.
|
|
5
|
+
``flow-xray dot`` — scalar-Value DOT/HTML export (demo / from script).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# gtype run (execution tracing)
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
21
|
+
from flow_xray.trace import TraceResult, _TraceSession, _count_all
|
|
22
|
+
|
|
23
|
+
path = Path(args.script)
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
print(f"error: not a file: {path}", file=sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
# Use a non-__main__ name so scripts' `if __name__ == "__main__"` blocks
|
|
28
|
+
# are skipped; @trace-decorated functions are captured by the CLI session.
|
|
29
|
+
ns: dict[str, Any] = {"__name__": "__flow_xray_run__", "__file__": str(path)}
|
|
30
|
+
session = _TraceSession()
|
|
31
|
+
with session:
|
|
32
|
+
exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), ns, ns)
|
|
33
|
+
result = TraceResult(session.roots)
|
|
34
|
+
html_path = args.html or path.stem + "_trace.html"
|
|
35
|
+
result.to_html(html_path, title=args.title or f"Trace: {path.name}")
|
|
36
|
+
total, errs = _count_all(result.roots)
|
|
37
|
+
print(f"Wrote {html_path} ({total} nodes, {errs} errors)", file=sys.stderr)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# gtype dot (scalar Value graph — legacy)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _demo_root() -> Any:
|
|
46
|
+
from flow_xray.value import Value
|
|
47
|
+
a = Value(2.0, label="a")
|
|
48
|
+
b = Value(-3.0, label="b")
|
|
49
|
+
c = Value(10.0, label="c")
|
|
50
|
+
L = ((a * b) + c).relu().tanh()
|
|
51
|
+
L.label = "L"
|
|
52
|
+
return L
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_demo(args: argparse.Namespace) -> int:
|
|
56
|
+
from flow_xray.export_dot import value_graph_to_dot, write_dot
|
|
57
|
+
from flow_xray.export_html import write_standalone_viewer_html
|
|
58
|
+
root = _demo_root()
|
|
59
|
+
forward = value_graph_to_dot(root, show_grad=False)
|
|
60
|
+
root.backward()
|
|
61
|
+
backward = value_graph_to_dot(root, show_grad=True)
|
|
62
|
+
if args.output_dir:
|
|
63
|
+
out = Path(args.output_dir)
|
|
64
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
write_dot(str(out / "forward.dot"), forward)
|
|
66
|
+
write_dot(str(out / "backward.dot"), backward)
|
|
67
|
+
print(f"Wrote {out / 'forward.dot'} and {out / 'backward.dot'}", file=sys.stderr)
|
|
68
|
+
return 0
|
|
69
|
+
if args.html:
|
|
70
|
+
dot = backward if args.which == "backward" else forward
|
|
71
|
+
title = f"flow-xray demo ({args.which})"
|
|
72
|
+
write_standalone_viewer_html(args.html, dot, title=title)
|
|
73
|
+
print(f"Wrote {args.html} — open in browser (no dot/PNG).", file=sys.stderr)
|
|
74
|
+
return 0
|
|
75
|
+
if args.which == "backward":
|
|
76
|
+
sys.stdout.write(backward)
|
|
77
|
+
else:
|
|
78
|
+
sys.stdout.write(forward)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
83
|
+
from flow_xray.export_dot import value_graph_to_dot, write_dot
|
|
84
|
+
from flow_xray.export_html import write_standalone_viewer_html
|
|
85
|
+
from flow_xray.value import Value
|
|
86
|
+
path = Path(args.script)
|
|
87
|
+
ns: dict[str, Any] = {"__name__": "__main__", "__file__": str(path)}
|
|
88
|
+
exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), ns, ns)
|
|
89
|
+
root = ns.get("root")
|
|
90
|
+
if root is None:
|
|
91
|
+
print("error: script must define variable `root` (a Value)", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
if not isinstance(root, Value):
|
|
94
|
+
print("error: `root` must be a flow_xray.Value", file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
forward = value_graph_to_dot(root, show_grad=False)
|
|
97
|
+
if args.backward:
|
|
98
|
+
root.backward()
|
|
99
|
+
dot = value_graph_to_dot(root, show_grad=True)
|
|
100
|
+
else:
|
|
101
|
+
dot = forward
|
|
102
|
+
if args.html:
|
|
103
|
+
title = "flow-xray graph" + (" (backward)" if args.backward else " (forward)")
|
|
104
|
+
write_standalone_viewer_html(args.html, dot, title=title)
|
|
105
|
+
print(f"Wrote {args.html} — open in browser.", file=sys.stderr)
|
|
106
|
+
return 0
|
|
107
|
+
out_path = args.output
|
|
108
|
+
if out_path:
|
|
109
|
+
write_dot(out_path, dot)
|
|
110
|
+
print(out_path, file=sys.stderr)
|
|
111
|
+
else:
|
|
112
|
+
sys.stdout.write(dot)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
118
|
+
p = argparse.ArgumentParser(prog="flow-xray", description="flow-xray — execution graph debugger")
|
|
119
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
120
|
+
|
|
121
|
+
# -- gtype run -------------------------------------------------------
|
|
122
|
+
r = sub.add_parser("run", help="run a script with @trace, export HTML execution graph")
|
|
123
|
+
r.add_argument("script", help="path to Python file using @trace")
|
|
124
|
+
r.add_argument("--html", metavar="FILE.html", help="output path (default: <script>_trace.html)")
|
|
125
|
+
r.add_argument("--title", metavar="TEXT", help="title shown in the viewer")
|
|
126
|
+
r.set_defaults(func=cmd_run)
|
|
127
|
+
|
|
128
|
+
# -- gtype dot demo --------------------------------------------------
|
|
129
|
+
d = sub.add_parser("dot", help="scalar-Value DOT subcommands")
|
|
130
|
+
dot_sub = d.add_subparsers(dest="dot_cmd", required=True)
|
|
131
|
+
dd = dot_sub.add_parser("demo", help="built-in (a*b+c).relu().tanh() graph")
|
|
132
|
+
dd.add_argument("which", nargs="?", default="forward", choices=("forward", "backward"))
|
|
133
|
+
dd.add_argument("--output-dir", metavar="DIR")
|
|
134
|
+
dd.add_argument("--html", metavar="FILE.html")
|
|
135
|
+
dd.set_defaults(func=cmd_demo)
|
|
136
|
+
|
|
137
|
+
de = dot_sub.add_parser("export", help="run .py that defines `root` (Value)")
|
|
138
|
+
de.add_argument("script")
|
|
139
|
+
de.add_argument("-o", "--output", metavar="FILE.dot")
|
|
140
|
+
de.add_argument("--backward", action="store_true")
|
|
141
|
+
de.add_argument("--html", metavar="FILE.html")
|
|
142
|
+
de.set_defaults(func=cmd_export)
|
|
143
|
+
|
|
144
|
+
args = p.parse_args(argv)
|
|
145
|
+
return int(args.func(args))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main())
|
flow_xray/debugger.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stepping / snapshot debugger — delivery hooks on top of core + export.
|
|
3
|
+
|
|
4
|
+
Stepping model
|
|
5
|
+
--------------
|
|
6
|
+
|
|
7
|
+
1. **Two-phase (global)** — ``TwoStepDebugger``:
|
|
8
|
+
- Phase A: export DOT with ``data`` only (``show_grad=False``).
|
|
9
|
+
- Phase B: after ``backward()`` (or ``backward_from``), export DOT with ``grad`` on each node.
|
|
10
|
+
|
|
11
|
+
2. **Per-node backward** — ``iter_backward_steps`` / ``GraphDebugger.backward_trace``:
|
|
12
|
+
- Start from ``zero_grad``, set ``root.grad = 1``, walk reverse topological order,
|
|
13
|
+
calling ``_backward()`` on **one** node at a time. After each call, partial gradients
|
|
14
|
+
are filled only along edges that have already been processed; later steps add more.
|
|
15
|
+
- This is for *inspection* of the backward schedule, not a separate math semantics.
|
|
16
|
+
|
|
17
|
+
3. **Named snapshots** — ``GraphDebugger.named_snapshots`` returns strings (DOT) keyed by
|
|
18
|
+
``forward``, ``after_backward``, and ``backward_step_{k}`` (k = 0..n-1 in reverse topo).
|
|
19
|
+
|
|
20
|
+
**Rewind:** not supported; re-build the expression or keep a copy if you need replay.
|
|
21
|
+
|
|
22
|
+
Double backward is not supported as a product guarantee; use ``zero_grad`` + single ``backward``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from flow_xray.export_dot import value_graph_to_dot
|
|
31
|
+
from flow_xray.ir import graph_to_ir
|
|
32
|
+
from flow_xray.value import Value, topological_order, zero_grad
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TwoStepDebugger:
|
|
36
|
+
"""
|
|
37
|
+
Two global phases: forward DOT (data only), backward DOT (data + grad).
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
dbg = TwoStepDebugger(y)
|
|
42
|
+
dot1 = dbg.step_forward_dot()
|
|
43
|
+
y.backward()
|
|
44
|
+
dot2 = dbg.step_backward_dot()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_root",)
|
|
48
|
+
|
|
49
|
+
def __init__(self, root: Value) -> None:
|
|
50
|
+
self._root = root
|
|
51
|
+
|
|
52
|
+
def step_forward_dot(self) -> str:
|
|
53
|
+
return value_graph_to_dot(self._root, show_grad=False)
|
|
54
|
+
|
|
55
|
+
def step_backward_dot(self) -> str:
|
|
56
|
+
return value_graph_to_dot(self._root, show_grad=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def iter_backward_steps(root: Value, *, seed: float = 1.0) -> Iterator[Value]:
|
|
60
|
+
"""
|
|
61
|
+
Yield each ``Value`` in reverse topological order as its ``_backward`` is applied.
|
|
62
|
+
|
|
63
|
+
Mutates ``root`` subgraph gradients. Starts from a clean slate (zeros then seed).
|
|
64
|
+
"""
|
|
65
|
+
zero_grad(root)
|
|
66
|
+
root.grad = float(seed)
|
|
67
|
+
for v in reversed(topological_order(root)):
|
|
68
|
+
v._backward()
|
|
69
|
+
yield v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GraphDebugger:
|
|
73
|
+
"""
|
|
74
|
+
Captures DOT (+ optional IR) for forward, each backward micro-step, and final state.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("root",)
|
|
78
|
+
|
|
79
|
+
def __init__(self, root: Value) -> None:
|
|
80
|
+
self.root = root
|
|
81
|
+
|
|
82
|
+
def dot_forward(self) -> str:
|
|
83
|
+
return value_graph_to_dot(self.root, show_grad=False)
|
|
84
|
+
|
|
85
|
+
def dot_backward(self) -> str:
|
|
86
|
+
return value_graph_to_dot(self.root, show_grad=True)
|
|
87
|
+
|
|
88
|
+
def ir_forward(self) -> dict[str, Any]:
|
|
89
|
+
return graph_to_ir(self.root, include_grad=False)
|
|
90
|
+
|
|
91
|
+
def ir_backward(self) -> dict[str, Any]:
|
|
92
|
+
return graph_to_ir(self.root, include_grad=True)
|
|
93
|
+
|
|
94
|
+
def named_snapshots(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
seed: float = 1.0,
|
|
98
|
+
include_step_dots: bool = True,
|
|
99
|
+
) -> dict[str, str]:
|
|
100
|
+
"""
|
|
101
|
+
Return DOT strings. Keys: ``forward``, optional ``backward_step_0``..``n-1``,
|
|
102
|
+
``after_backward``.
|
|
103
|
+
|
|
104
|
+
Runs backward on ``self.root`` (mutating grads). Caller should not rely on pre-backward
|
|
105
|
+
grad state after this returns.
|
|
106
|
+
"""
|
|
107
|
+
out: dict[str, str] = {"forward": self.dot_forward()}
|
|
108
|
+
zero_grad(self.root)
|
|
109
|
+
self.root.grad = float(seed)
|
|
110
|
+
rev = list(reversed(topological_order(self.root)))
|
|
111
|
+
if include_step_dots:
|
|
112
|
+
for k, v in enumerate(rev):
|
|
113
|
+
v._backward()
|
|
114
|
+
out[f"backward_step_{k}"] = value_graph_to_dot(self.root, show_grad=True)
|
|
115
|
+
else:
|
|
116
|
+
for v in rev:
|
|
117
|
+
v._backward()
|
|
118
|
+
out["after_backward"] = value_graph_to_dot(self.root, show_grad=True)
|
|
119
|
+
return out
|
flow_xray/export_dot.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""DOT export adapter — inspection layer; does not mutate the graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flow_xray.value import Value, topological_order
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _dot_escape(s: str) -> str:
|
|
9
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def value_graph_to_dot(root: Value, *, show_grad: bool = False) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Emit Graphviz DOT for the DAG rooted at ``root``.
|
|
15
|
+
|
|
16
|
+
Node DOT ids use ``Value.node_id`` (stable for object lifetime).
|
|
17
|
+
"""
|
|
18
|
+
nodes = topological_order(root)
|
|
19
|
+
lines = [
|
|
20
|
+
"digraph G {",
|
|
21
|
+
' rankdir="BT";',
|
|
22
|
+
" node [shape=box, fontname=Helvetica];",
|
|
23
|
+
" edge [fontname=Helvetica, fontsize=10];",
|
|
24
|
+
]
|
|
25
|
+
for v in nodes:
|
|
26
|
+
nid = v.node_id
|
|
27
|
+
title = _dot_escape(v.display_name)
|
|
28
|
+
parts = [title]
|
|
29
|
+
if v._op:
|
|
30
|
+
parts.append(f"op: {_dot_escape(v._op)}")
|
|
31
|
+
parts.append(f"data: {v.data:g}")
|
|
32
|
+
if show_grad:
|
|
33
|
+
parts.append(f"grad: {v.grad:g}")
|
|
34
|
+
label = "\\n".join(parts)
|
|
35
|
+
lines.append(f' {nid} [label="{label}"];')
|
|
36
|
+
|
|
37
|
+
for v in nodes:
|
|
38
|
+
child_id = v.node_id
|
|
39
|
+
for p in v._prev:
|
|
40
|
+
pid = p.node_id
|
|
41
|
+
op_lbl = _dot_escape(v._op or "")
|
|
42
|
+
lines.append(f' {pid} -> {child_id} [label="{op_lbl}"];')
|
|
43
|
+
|
|
44
|
+
lines.append("}")
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write_dot(path: str, dot: str) -> None:
|
|
49
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(dot)
|
flow_xray/export_html.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Single-file HTML viewer: embedded DOT + WASM Graphviz (no local ``dot``, no server)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def standalone_viewer_html(dot: str, *, title: str = "flow-xray graph") -> str:
|
|
11
|
+
"""
|
|
12
|
+
Return one HTML document that inlines ``dot`` as JSON so ``file://`` works.
|
|
13
|
+
|
|
14
|
+
Rendering uses ``@hpcc-js/wasm-graphviz`` from jsDelivr (needs network once).
|
|
15
|
+
If WASM fails, the raw DOT is shown in a ``<pre>``.
|
|
16
|
+
"""
|
|
17
|
+
dot_js = json.dumps(dot)
|
|
18
|
+
safe_title = html.escape(title, quote=True)
|
|
19
|
+
return f"""<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
24
|
+
<title>{safe_title}</title>
|
|
25
|
+
<style>
|
|
26
|
+
body {{ font-family: system-ui, sans-serif; margin: 1rem; }}
|
|
27
|
+
#host {{ overflow: auto; border: 1px solid #ccc; padding: 0.5rem; min-height: 200px; }}
|
|
28
|
+
#fallback {{ white-space: pre-wrap; font-size: 12px; display: none; }}
|
|
29
|
+
.err {{ color: #b00; }}
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<h1>{safe_title}</h1>
|
|
34
|
+
<p id="status">Rendering…</p>
|
|
35
|
+
<div id="host"></div>
|
|
36
|
+
<pre id="fallback"></pre>
|
|
37
|
+
<script type="module">
|
|
38
|
+
const status = document.getElementById("status");
|
|
39
|
+
const host = document.getElementById("host");
|
|
40
|
+
const fallback = document.getElementById("fallback");
|
|
41
|
+
const dot = {dot_js};
|
|
42
|
+
async function run() {{
|
|
43
|
+
try {{
|
|
44
|
+
const {{ Graphviz }} = await import(
|
|
45
|
+
"https://cdn.jsdelivr.net/npm/@hpcc-js/wasm-graphviz@1.6.1/+esm"
|
|
46
|
+
);
|
|
47
|
+
const gv = await Graphviz.load();
|
|
48
|
+
const svg = gv.dot(dot);
|
|
49
|
+
host.innerHTML = svg;
|
|
50
|
+
status.textContent = "Rendered (WASM Graphviz). Opened as local HTML — no PNG/dot CLI.";
|
|
51
|
+
}} catch (e) {{
|
|
52
|
+
status.innerHTML =
|
|
53
|
+
"<span class='err'>WASM render failed (offline or blocked CDN).</span> DOT below.";
|
|
54
|
+
console.error(e);
|
|
55
|
+
fallback.style.display = "block";
|
|
56
|
+
fallback.textContent = dot;
|
|
57
|
+
}}
|
|
58
|
+
}}
|
|
59
|
+
run();
|
|
60
|
+
</script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_standalone_viewer_html(path: str | Path, dot: str, *, title: str = "flow-xray graph") -> None:
|
|
67
|
+
Path(path).write_text(standalone_viewer_html(dot, title=title), encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Execution-trace viewer (dark theme, interactive)
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def trace_to_standalone_html(
|
|
75
|
+
trace_result: object,
|
|
76
|
+
*,
|
|
77
|
+
title: str = "flow-xray trace",
|
|
78
|
+
) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Return a self-contained HTML document that renders a ``TraceResult``
|
|
81
|
+
as an interactive execution DAG (dark theme, Graphviz WASM, click-to-inspect).
|
|
82
|
+
"""
|
|
83
|
+
trace_json = json.dumps(trace_result.to_dict(), ensure_ascii=False) # type: ignore[union-attr]
|
|
84
|
+
safe_title = html.escape(title, quote=True)
|
|
85
|
+
return _TRACE_HTML_TEMPLATE.replace("__TITLE__", safe_title).replace("__TRACE_JSON__", trace_json)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_TRACE_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
89
|
+
<html lang="en">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8">
|
|
92
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
93
|
+
<title>__TITLE__</title>
|
|
94
|
+
<style>
|
|
95
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
96
|
+
body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;display:flex;flex-direction:column;height:100vh}
|
|
97
|
+
header{padding:10px 20px;background:#161b22;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:18px;flex-shrink:0}
|
|
98
|
+
header h1{font-size:15px;font-weight:600}
|
|
99
|
+
.st{font-size:13px;color:#8b949e}.st b{color:#c9d1d9}.st b.err{color:#f85149}
|
|
100
|
+
#main{display:flex;flex:1;overflow:hidden}
|
|
101
|
+
#graph{flex:1;overflow:auto;display:flex;align-items:center;justify-content:center;padding:20px}
|
|
102
|
+
#graph svg{max-width:100%;height:auto;cursor:grab}
|
|
103
|
+
#det{width:370px;background:#161b22;border-left:1px solid #30363d;overflow-y:auto;padding:16px;font-size:13px;flex-shrink:0}
|
|
104
|
+
.dt{font-size:14px;font-weight:600;margin-bottom:10px;color:#f0f6fc}
|
|
105
|
+
.ds{margin-bottom:14px}
|
|
106
|
+
.dl{font-size:10px;text-transform:uppercase;color:#8b949e;margin-bottom:3px;letter-spacing:.5px}
|
|
107
|
+
.dv{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 10px;font-family:'SF Mono','Fira Code',monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:220px;overflow-y:auto;color:#c9d1d9}
|
|
108
|
+
.ok{color:#3fb950}.er{color:#f85149}.warn{color:#d29922}
|
|
109
|
+
#graph .node{cursor:pointer}
|
|
110
|
+
#graph .node:hover polygon{stroke:#58a6ff!important;stroke-width:2}
|
|
111
|
+
#graph .selected polygon{stroke:#58a6ff!important;stroke-width:2.5}
|
|
112
|
+
#graph .edge path{stroke:#30363d!important}
|
|
113
|
+
#graph .edge polygon{fill:#30363d!important;stroke:#30363d!important}
|
|
114
|
+
#graph text{fill:#fff!important}
|
|
115
|
+
#hint{color:#484f58;text-align:center;padding:40px 20px;line-height:1.6}
|
|
116
|
+
#fb{white-space:pre-wrap;font-size:12px;color:#8b949e;padding:20px;display:none}
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<header>
|
|
121
|
+
<h1>__TITLE__</h1>
|
|
122
|
+
<span class="st">Nodes: <b id="sn">-</b></span>
|
|
123
|
+
<span class="st">Total: <b id="stm">-</b></span>
|
|
124
|
+
<span class="st">Errors: <b id="se" class="err">-</b></span>
|
|
125
|
+
<span class="st" id="stok" style="display:none">Tokens: <b id="stokv">-</b></span>
|
|
126
|
+
<span class="st" id="scost" style="display:none">Cost: <b id="scostv" class="warn">-</b></span>
|
|
127
|
+
</header>
|
|
128
|
+
<div id="main">
|
|
129
|
+
<div id="graph"><div id="hint">Rendering graph…</div></div>
|
|
130
|
+
<div id="det"><div id="hint">Click a node to inspect</div></div>
|
|
131
|
+
</div>
|
|
132
|
+
<pre id="fb"></pre>
|
|
133
|
+
<script type="module">
|
|
134
|
+
const TRACE=__TRACE_JSON__;
|
|
135
|
+
function flat(roots,pid){let r=[];(roots||[]).forEach(n=>{r.push({...n,pid});r=r.concat(flat(n.children,n.id))});return r}
|
|
136
|
+
const nodes=flat(TRACE.nodes,null);
|
|
137
|
+
const byId=Object.fromEntries(nodes.map(n=>[n.id,n]));
|
|
138
|
+
document.getElementById("sn").textContent=nodes.length;
|
|
139
|
+
const errs=nodes.filter(n=>n.error).length;
|
|
140
|
+
document.getElementById("se").textContent=errs;
|
|
141
|
+
const total=TRACE.nodes.reduce((s,n)=>s+n.latency_ms,0);
|
|
142
|
+
document.getElementById("stm").textContent=total.toFixed(1)+"ms";
|
|
143
|
+
let sumTok=0,sumCost=0;
|
|
144
|
+
nodes.forEach(n=>{if(n.meta){if(n.meta.total_tokens)sumTok+=n.meta.total_tokens;if(n.meta.estimated_cost_usd)sumCost+=n.meta.estimated_cost_usd}});
|
|
145
|
+
if(sumTok){document.getElementById("stok").style.display="";document.getElementById("stokv").textContent=sumTok}
|
|
146
|
+
if(sumCost){document.getElementById("scost").style.display="";document.getElementById("scostv").textContent="$"+sumCost.toFixed(6)}
|
|
147
|
+
|
|
148
|
+
function mkDot(){
|
|
149
|
+
let d='digraph G {\n rankdir="TB";\n bgcolor="transparent";\n';
|
|
150
|
+
d+=' node [shape=box,style="rounded,filled",fontname="Helvetica",fontsize=11,margin="0.3,0.15"];\n';
|
|
151
|
+
d+=' edge [color="#30363d",arrowsize=0.7];\n';
|
|
152
|
+
for(const n of nodes){
|
|
153
|
+
const c=n.error?"#da3633":n.latency_ms>1000?"#9e6a03":"#238636";
|
|
154
|
+
const ms=n.latency_ms<1?n.latency_ms.toFixed(2):n.latency_ms<100?n.latency_ms.toFixed(1):Math.round(n.latency_ms);
|
|
155
|
+
let lb=n.name+"\\n"+ms+"ms";
|
|
156
|
+
if(n.meta&&n.meta.total_tokens)lb+="\\n"+n.meta.total_tokens+" tok";
|
|
157
|
+
d+=` ${n.id} [label="${lb}",fillcolor="${c}",fontcolor="white",tooltip="${n.id}"];\n`;
|
|
158
|
+
}
|
|
159
|
+
for(const n of nodes){if(n.pid)d+=` ${n.pid} -> ${n.id};\n`}
|
|
160
|
+
d+='}';return d;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const det=document.getElementById("det");
|
|
164
|
+
function showDet(n){
|
|
165
|
+
if(!n){det.innerHTML='<div id="hint">Click a node to inspect</div>';return}
|
|
166
|
+
const st=n.error?`<span class="er">ERROR</span>`:`<span class="ok">OK</span>`;
|
|
167
|
+
const ms=n.latency_ms.toFixed(2);
|
|
168
|
+
let h=`<div class="dt">${esc(n.name)}</div>`;
|
|
169
|
+
h+=`<div class="ds"><div class="dl">Status</div><div class="dv">${st}</div></div>`;
|
|
170
|
+
h+=`<div class="ds"><div class="dl">Latency</div><div class="dv">${ms} ms</div></div>`;
|
|
171
|
+
if(n.error)h+=`<div class="ds"><div class="dl">Error</div><div class="dv er">${esc(n.error)}</div></div>`;
|
|
172
|
+
const inp=Object.entries(n.inputs||{});
|
|
173
|
+
if(inp.length){h+=`<div class="ds"><div class="dl">Input</div>`;inp.forEach(([k,v])=>{h+=`<div class="dv"><b>${esc(k)}</b>: ${esc(v)}</div>`});h+=`</div>`}
|
|
174
|
+
if(n.output!=null)h+=`<div class="ds"><div class="dl">Output</div><div class="dv">${esc(String(n.output))}</div></div>`;
|
|
175
|
+
if(n.meta){
|
|
176
|
+
const m=n.meta;
|
|
177
|
+
if(m.model)h+=`<div class="ds"><div class="dl">Model</div><div class="dv">${esc(m.model)}</div></div>`;
|
|
178
|
+
const toks=[];
|
|
179
|
+
if(m.prompt_tokens!=null)toks.push("prompt: "+m.prompt_tokens);
|
|
180
|
+
if(m.completion_tokens!=null)toks.push("completion: "+m.completion_tokens);
|
|
181
|
+
if(m.total_tokens!=null)toks.push("total: "+m.total_tokens);
|
|
182
|
+
if(toks.length)h+=`<div class="ds"><div class="dl">Tokens</div><div class="dv">${esc(toks.join(" · "))}</div></div>`;
|
|
183
|
+
if(m.estimated_cost_usd!=null)h+=`<div class="ds"><div class="dl">Est. cost</div><div class="dv warn">$${m.estimated_cost_usd.toFixed(6)}</div></div>`;
|
|
184
|
+
const extra=Object.entries(m).filter(([k])=>!["model","prompt_tokens","completion_tokens","total_tokens","estimated_cost_usd"].includes(k));
|
|
185
|
+
if(extra.length){h+=`<div class="ds"><div class="dl">Meta</div>`;extra.forEach(([k,v])=>{h+=`<div class="dv"><b>${esc(k)}</b>: ${esc(JSON.stringify(v))}</div>`});h+=`</div>`}
|
|
186
|
+
}
|
|
187
|
+
det.innerHTML=h;
|
|
188
|
+
}
|
|
189
|
+
function esc(s){const d=document.createElement('div');d.textContent=String(s);return d.innerHTML}
|
|
190
|
+
|
|
191
|
+
async function render(){
|
|
192
|
+
const dot=mkDot();
|
|
193
|
+
const gc=document.getElementById("graph");
|
|
194
|
+
try{
|
|
195
|
+
const {Graphviz}=await import("https://cdn.jsdelivr.net/npm/@hpcc-js/wasm-graphviz@1.6.1/+esm");
|
|
196
|
+
const gv=await Graphviz.load();
|
|
197
|
+
const svg=gv.dot(dot);
|
|
198
|
+
gc.innerHTML=svg;
|
|
199
|
+
gc.querySelectorAll('.node').forEach(el=>{
|
|
200
|
+
const title=el.querySelector('title');
|
|
201
|
+
if(!title)return;
|
|
202
|
+
const nid=title.textContent.trim();
|
|
203
|
+
el.addEventListener('click',()=>{
|
|
204
|
+
gc.querySelectorAll('.selected').forEach(s=>s.classList.remove('selected'));
|
|
205
|
+
el.classList.add('selected');
|
|
206
|
+
showDet(byId[nid]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}catch(e){
|
|
210
|
+
console.error(e);
|
|
211
|
+
gc.innerHTML='<div id="hint" style="color:#f85149">WASM render failed. DOT below.</div>';
|
|
212
|
+
const fb=document.getElementById("fb");fb.style.display="block";fb.textContent=dot;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
render();
|
|
216
|
+
</script>
|
|
217
|
+
</body>
|
|
218
|
+
</html>
|
|
219
|
+
"""
|
flow_xray/ir.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""JSON-serializable graph IR — stable ids, no Python object identity in payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from flow_xray.value import Value, topological_order
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def graph_to_ir(root: Value, *, include_grad: bool = True) -> dict[str, Any]:
|
|
12
|
+
"""
|
|
13
|
+
Build a normalized dict suitable for viewers, CLI, and regression tests.
|
|
14
|
+
|
|
15
|
+
Schema version 1:
|
|
16
|
+
version, root_id, nodes[{id, op, label, data, grad?, parents: [id,...]}]
|
|
17
|
+
"""
|
|
18
|
+
nodes = topological_order(root)
|
|
19
|
+
payload: dict[str, Any] = {
|
|
20
|
+
"version": 1,
|
|
21
|
+
"root_id": root.node_id,
|
|
22
|
+
"nodes": [],
|
|
23
|
+
}
|
|
24
|
+
for v in nodes:
|
|
25
|
+
entry: dict[str, Any] = {
|
|
26
|
+
"id": v.node_id,
|
|
27
|
+
"op": v._op,
|
|
28
|
+
"label": v.label,
|
|
29
|
+
"data": v.data,
|
|
30
|
+
"parents": [p.node_id for p in v._prev],
|
|
31
|
+
}
|
|
32
|
+
if include_grad:
|
|
33
|
+
entry["grad"] = v.grad
|
|
34
|
+
payload["nodes"].append(entry)
|
|
35
|
+
return payload
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ir_to_json(root: Value, *, include_grad: bool = True, indent: int | None = 2) -> str:
|
|
39
|
+
return json.dumps(graph_to_ir(root, include_grad=include_grad), indent=indent)
|
|
@@ -0,0 +1,52 @@
|
|
|
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" />
|
|
6
|
+
<title>flow-xray graph viewer</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; margin: 1rem; }
|
|
9
|
+
#host { overflow: auto; border: 1px solid #ccc; padding: 0.5rem; min-height: 200px; }
|
|
10
|
+
#fallback { white-space: pre-wrap; font-size: 12px; display: none; }
|
|
11
|
+
.err { color: #b00; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<h1>flow-xray graph</h1>
|
|
16
|
+
<p id="status">Loading DOT…</p>
|
|
17
|
+
<div id="host"></div>
|
|
18
|
+
<pre id="fallback"></pre>
|
|
19
|
+
<script type="module">
|
|
20
|
+
const status = document.getElementById("status");
|
|
21
|
+
const host = document.getElementById("host");
|
|
22
|
+
const fallback = document.getElementById("fallback");
|
|
23
|
+
async function run() {
|
|
24
|
+
let dot;
|
|
25
|
+
try {
|
|
26
|
+
const r = await fetch("/graph.dot");
|
|
27
|
+
dot = await r.text();
|
|
28
|
+
} catch (e) {
|
|
29
|
+
status.textContent = "Could not load /graph.dot";
|
|
30
|
+
status.className = "err";
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { Graphviz } = await import(
|
|
35
|
+
"https://cdn.jsdelivr.net/npm/@hpcc-js/wasm-graphviz@1.6.1/+esm"
|
|
36
|
+
);
|
|
37
|
+
const gv = await Graphviz.load();
|
|
38
|
+
const svg = gv.dot(dot);
|
|
39
|
+
host.innerHTML = svg;
|
|
40
|
+
status.textContent = "Rendered (WASM Graphviz).";
|
|
41
|
+
} catch (e) {
|
|
42
|
+
status.innerHTML =
|
|
43
|
+
"<span class='err'>WASM render failed (offline or blocked CDN).</span> DOT below.";
|
|
44
|
+
console.error(e);
|
|
45
|
+
fallback.style.display = "block";
|
|
46
|
+
fallback.textContent = dot;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
run();
|
|
50
|
+
</script>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|