PyDecisionGraph 0.3.2.post1__cp315-cp315-win_amd64.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.
- decision_graph/__infra__.pxd +1 -0
- decision_graph/__init__.pxd +1 -0
- decision_graph/__init__.py +39 -0
- decision_graph/decision_tree/__init__.pxd +2 -0
- decision_graph/decision_tree/__init__.py +85 -0
- decision_graph/decision_tree/bake/__infra__.pxd +502 -0
- decision_graph/decision_tree/bake/__init__.pxd +502 -0
- decision_graph/decision_tree/bake/__init__.py +29 -0
- decision_graph/decision_tree/bake/c_action.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_action.h +324 -0
- decision_graph/decision_tree/bake/c_action.pxd +54 -0
- decision_graph/decision_tree/bake/c_action.pyi +126 -0
- decision_graph/decision_tree/bake/c_action.pyx +65 -0
- decision_graph/decision_tree/bake/c_allocator_protocol.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_allocator_protocol.pxd +24 -0
- decision_graph/decision_tree/bake/c_allocator_protocol.pyi +61 -0
- decision_graph/decision_tree/bake/c_allocator_protocol.pyx +109 -0
- decision_graph/decision_tree/bake/c_bake.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_bake.h +629 -0
- decision_graph/decision_tree/bake/c_bake.pxd +52 -0
- decision_graph/decision_tree/bake/c_bake.pyi +151 -0
- decision_graph/decision_tree/bake/c_bake.pyx +142 -0
- decision_graph/decision_tree/bake/c_collections.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_collections.h +692 -0
- decision_graph/decision_tree/bake/c_collections.pxd +52 -0
- decision_graph/decision_tree/bake/c_collections.pyi +193 -0
- decision_graph/decision_tree/bake/c_collections.pyx +161 -0
- decision_graph/decision_tree/bake/c_const.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_const.h +395 -0
- decision_graph/decision_tree/bake/c_const.pxd +56 -0
- decision_graph/decision_tree/bake/c_const.pyi +305 -0
- decision_graph/decision_tree/bake/c_const.pyx +278 -0
- decision_graph/decision_tree/bake/c_edge.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_edge.h +511 -0
- decision_graph/decision_tree/bake/c_edge.pxd +117 -0
- decision_graph/decision_tree/bake/c_edge.pyi +192 -0
- decision_graph/decision_tree/bake/c_edge.pyx +270 -0
- decision_graph/decision_tree/bake/c_eval.h +848 -0
- decision_graph/decision_tree/bake/c_eval.pxd +40 -0
- decision_graph/decision_tree/bake/c_expr.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_expr.h +1900 -0
- decision_graph/decision_tree/bake/c_expr.pxd +129 -0
- decision_graph/decision_tree/bake/c_expr.pyi +280 -0
- decision_graph/decision_tree/bake/c_expr.pyx +272 -0
- decision_graph/decision_tree/bake/c_hierarchy.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
- decision_graph/decision_tree/bake/c_hierarchy.pxd +71 -0
- decision_graph/decision_tree/bake/c_hierarchy.pyi +409 -0
- decision_graph/decision_tree/bake/c_hierarchy.pyx +277 -0
- decision_graph/decision_tree/bake/c_logic_group.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
- decision_graph/decision_tree/bake/c_logic_group.pxd +162 -0
- decision_graph/decision_tree/bake/c_logic_group.pyi +264 -0
- decision_graph/decision_tree/bake/c_logic_group.pyx +308 -0
- decision_graph/decision_tree/bake/c_node.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_node.h +2660 -0
- decision_graph/decision_tree/bake/c_node.pxd +381 -0
- decision_graph/decision_tree/bake/c_node.pyi +438 -0
- decision_graph/decision_tree/bake/c_node.pyx +639 -0
- decision_graph/decision_tree/bake/c_reconstruct.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_reconstruct.pxd +63 -0
- decision_graph/decision_tree/bake/c_reconstruct.pyi +60 -0
- decision_graph/decision_tree/bake/c_reconstruct.pyx +252 -0
- decision_graph/decision_tree/bake/c_var.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/bake/c_var.h +2054 -0
- decision_graph/decision_tree/bake/c_var.pxd +205 -0
- decision_graph/decision_tree/bake/c_var.pyi +271 -0
- decision_graph/decision_tree/bake/c_var.pyx +228 -0
- decision_graph/decision_tree/capi/__infra__.pxd +33 -0
- decision_graph/decision_tree/capi/__init__.pxd +33 -0
- decision_graph/decision_tree/capi/__init__.py +57 -0
- decision_graph/decision_tree/capi/c_abc.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/capi/c_abc.pxd +246 -0
- decision_graph/decision_tree/capi/c_abc.pyi +770 -0
- decision_graph/decision_tree/capi/c_abc.pyx +1532 -0
- decision_graph/decision_tree/capi/c_collection.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/capi/c_collection.pxd +22 -0
- decision_graph/decision_tree/capi/c_collection.pyi +245 -0
- decision_graph/decision_tree/capi/c_collection.pyx +138 -0
- decision_graph/decision_tree/capi/c_node.cp315-win_amd64.pyd +0 -0
- decision_graph/decision_tree/capi/c_node.pxd +95 -0
- decision_graph/decision_tree/capi/c_node.pyi +492 -0
- decision_graph/decision_tree/capi/c_node.pyx +551 -0
- decision_graph/decision_tree/exc.py +189 -0
- decision_graph/decision_tree/native/__init__.py +57 -0
- decision_graph/decision_tree/native/abc.py +1167 -0
- decision_graph/decision_tree/native/collection.py +134 -0
- decision_graph/decision_tree/native/node.py +521 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_action.c +16320 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_action.h +324 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_allocator_protocol.c +12473 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_bake.c +16485 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_bake.h +629 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_collections.c +17381 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_collections.h +692 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_const.c +20815 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_const.h +395 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_edge.c +20696 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_edge.h +511 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_eval.h +848 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_expr.c +22065 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_expr.h +1900 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.c +23267 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.c +22434 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_node.c +28385 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_node.h +2660 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_reconstruct.c +15955 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_var.c +16881 -0
- decision_graph/includes/decision_graph/decision_tree/bake/c_var.h +2054 -0
- decision_graph/includes/decision_graph/decision_tree/capi/c_abc.c +59196 -0
- decision_graph/includes/decision_graph/decision_tree/capi/c_collection.c +19967 -0
- decision_graph/includes/decision_graph/decision_tree/capi/c_node.c +39229 -0
- decision_graph/logic_group/__init__.py +25 -0
- decision_graph/logic_group/base.py +100 -0
- decision_graph/logic_group/pending_request.py +384 -0
- decision_graph/webui/__init__.py +24 -0
- decision_graph/webui/bake/__init__.py +65 -0
- decision_graph/webui/bake/app.py +488 -0
- decision_graph/webui/bake/static/d3.v7.min.js +2 -0
- decision_graph/webui/bake/static/script.js +1440 -0
- decision_graph/webui/bake/static/style.css +920 -0
- decision_graph/webui/bake/templates/index.html +183 -0
- decision_graph/webui/bake/templates/offline.html +184 -0
- decision_graph/webui/capi/__init__.py +54 -0
- decision_graph/webui/capi/app.py +366 -0
- decision_graph/webui/capi/main.py +53 -0
- decision_graph/webui/capi/static/d3.v7.min.js +2 -0
- decision_graph/webui/capi/static/script.js +1026 -0
- decision_graph/webui/capi/static/style.css +656 -0
- decision_graph/webui/capi/templates/index.html +93 -0
- decision_graph/webui/capi/templates/offline.html +94 -0
- decision_graph/webui/native/__init__.py +54 -0
- decision_graph/webui/native/app.py +366 -0
- decision_graph/webui/native/main.py +53 -0
- decision_graph/webui/native/static/d3.v7.min.js +2 -0
- decision_graph/webui/native/static/script.js +1026 -0
- decision_graph/webui/native/static/style.css +656 -0
- decision_graph/webui/native/templates/index.html +93 -0
- decision_graph/webui/native/templates/offline.html +94 -0
- pydecisiongraph-0.3.2.post1.dist-info/METADATA +136 -0
- pydecisiongraph-0.3.2.post1.dist-info/RECORD +148 -0
- pydecisiongraph-0.3.2.post1.dist-info/WHEEL +5 -0
- pydecisiongraph-0.3.2.post1.dist-info/licenses/LICENSE +373 -0
- pydecisiongraph-0.3.2.post1.dist-info/top_level.txt +2 -0
- tests/bake_c_evaluator.cp315-win_amd64.pyd +0 -0
- tests/bake_eval_probe.cp315-win_amd64.pyd +0 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""The bake layer's viewer: a baked graph, drawn as cards.
|
|
2
|
+
|
|
3
|
+
What this reads is the bake layer's own surface and nothing else — the node
|
|
4
|
+
types the C layer reports (``ROOT``, ``BINARY``, ``LONGACTION``, …), the store
|
|
5
|
+
labels a build hung on its nodes, and the evaluation record a walk filled in.
|
|
6
|
+
|
|
7
|
+
Two things make a bake graph different to draw, and both are here rather than
|
|
8
|
+
in the front end:
|
|
9
|
+
|
|
10
|
+
- a node's OPERANDS are not its children. A ``BINARY`` node's operands are
|
|
11
|
+
nodes of their own, reachable in C but not through ``children``, so a drawing
|
|
12
|
+
that followed the children alone would show the branches and hide the
|
|
13
|
+
arithmetic. The node's display text carries them (``probe.signal > 0``), and
|
|
14
|
+
that is what the card is given.
|
|
15
|
+
- a BREAKPOINT resumes into a node that already sits under another parent. It
|
|
16
|
+
is drawn with a "virtual parent" link rather than a second child edge, so the
|
|
17
|
+
continuation is drawn once and the break still says where the build stopped.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import queue
|
|
24
|
+
import socket
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import LOGGER
|
|
30
|
+
# Scoped on purpose: this UI draws what the bake layer built, and reaches it by
|
|
31
|
+
# name rather than through the package that re-exports whichever layer won.
|
|
32
|
+
from decision_graph.decision_tree.bake.c_hierarchy import BreakpointNode, RootLogicNode
|
|
33
|
+
from decision_graph.decision_tree.bake.c_node import LogicNode
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BakeWebUi(object):
|
|
37
|
+
"""Class to manage the Flask web UI for visualizing a baked logic graph."""
|
|
38
|
+
|
|
39
|
+
#: The type families the C layer reports, keyed by the family nibble of the
|
|
40
|
+
#: type name. The front end colours a card by this rather than by its type,
|
|
41
|
+
#: so a type this file has never heard of still draws as its family.
|
|
42
|
+
_family_of = {
|
|
43
|
+
'TRUE': 'INPUT', 'FALSE': 'INPUT', 'DOUBLE': 'INPUT', 'STRING': 'INPUT',
|
|
44
|
+
'INT': 'INPUT', 'VARIABLE': 'INPUT',
|
|
45
|
+
'UNARY': 'OP', 'BINARY': 'OP', 'TERNARY': 'OP', 'CALL': 'OP',
|
|
46
|
+
'NOACTION': 'ACTION', 'LONGACTION': 'ACTION', 'SHORTACTION': 'ACTION',
|
|
47
|
+
'CANCELACTION': 'ACTION', 'CLEARACTION': 'ACTION', 'PLACEHOLDER': 'ACTION',
|
|
48
|
+
'ROOT': 'SPECIAL', 'BREAKPOINT': 'SPECIAL',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def __init__(self, host: str, port: int, debug: bool):
|
|
52
|
+
"""
|
|
53
|
+
Initializes the web UI manager.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
host (str): The host address for the Flask server.
|
|
57
|
+
port (int): The port for the Flask server.
|
|
58
|
+
debug (bool): Whether to run Flask in debug mode.
|
|
59
|
+
"""
|
|
60
|
+
from flask import Flask
|
|
61
|
+
|
|
62
|
+
self.host = host
|
|
63
|
+
self.port = port
|
|
64
|
+
self.debug = debug
|
|
65
|
+
self.app = Flask(__name__, template_folder='templates', static_folder='static')
|
|
66
|
+
self.current_tree_data: dict[str, Any] | None = None
|
|
67
|
+
self.current_tree_id: str | None = None
|
|
68
|
+
self.node: LogicNode | None = None
|
|
69
|
+
self.with_eval = False
|
|
70
|
+
self.with_watch = False
|
|
71
|
+
|
|
72
|
+
self._setup_routes()
|
|
73
|
+
|
|
74
|
+
def _setup_routes(self):
|
|
75
|
+
"""Configures the Flask routes for the application."""
|
|
76
|
+
from flask import render_template, jsonify, request
|
|
77
|
+
|
|
78
|
+
@self.app.route('/')
|
|
79
|
+
def index():
|
|
80
|
+
return render_template(
|
|
81
|
+
'index.html',
|
|
82
|
+
initial_tree_data=self.current_tree_data or {},
|
|
83
|
+
tree_id=self.current_tree_id or "empty",
|
|
84
|
+
with_eval=self.with_eval,
|
|
85
|
+
with_watch=self.with_watch,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@self.app.route('/api/tree_data')
|
|
89
|
+
def get_tree_data():
|
|
90
|
+
if self.current_tree_data is None:
|
|
91
|
+
return jsonify({"error": "No tree data available"}), 404
|
|
92
|
+
return jsonify({"tree_data": self.current_tree_data, "tree_id": self.current_tree_id})
|
|
93
|
+
|
|
94
|
+
@self.app.route('/api/active_nodes')
|
|
95
|
+
def get_active_nodes():
|
|
96
|
+
return jsonify(self._activation())
|
|
97
|
+
|
|
98
|
+
@self.app.route('/api/evaluate', methods=['POST'])
|
|
99
|
+
def evaluate():
|
|
100
|
+
"""Walk the graph now, and answer with what the walk landed on.
|
|
101
|
+
|
|
102
|
+
The page asks for this rather than being handed an evaluation, so
|
|
103
|
+
that opening a viewer never runs a graph on its own: a walk is a
|
|
104
|
+
side effect on the node's own storage, and the caller decides when
|
|
105
|
+
to spend it.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(self.node, RootLogicNode):
|
|
108
|
+
return jsonify({'error': 'The viewer is not holding a root node.'}), 400
|
|
109
|
+
try:
|
|
110
|
+
self.node.eval()
|
|
111
|
+
except Exception as error:
|
|
112
|
+
LOGGER.warning("Evaluation failed: %s", error)
|
|
113
|
+
return jsonify({**self._activation(), 'error': str(error)})
|
|
114
|
+
return jsonify(self._activation())
|
|
115
|
+
|
|
116
|
+
def _activation(self) -> dict[str, Any]:
|
|
117
|
+
"""The nodes of the last walk, by id, with the outcome it recorded."""
|
|
118
|
+
walk = self._walk_record(self.node)
|
|
119
|
+
if walk is None:
|
|
120
|
+
return {'active_ids': []}
|
|
121
|
+
return {'active_ids': walk['ids'], 'code': walk['code'], 'leaf': walk['leaf'],
|
|
122
|
+
'failed': walk['failed'], 'length': walk['length']}
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _walk_record(cls, node: LogicNode) -> dict[str, Any] | None:
|
|
126
|
+
"""The walk a root has already recorded, or None when it has none.
|
|
127
|
+
|
|
128
|
+
Read, never taken: a viewer that evaluated on its own would run a graph
|
|
129
|
+
the caller only asked to look at. An empty record means nobody has
|
|
130
|
+
walked it yet, which the page shows as "not walked" rather than as an
|
|
131
|
+
empty path.
|
|
132
|
+
"""
|
|
133
|
+
if not isinstance(node, RootLogicNode):
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
path = node.eval_path
|
|
137
|
+
if not len(path):
|
|
138
|
+
return None
|
|
139
|
+
return {
|
|
140
|
+
'ids': [str(n.uuid) for n in path],
|
|
141
|
+
'code': path.code_name,
|
|
142
|
+
'leaf': None if path.leaf is None else str(path.leaf.uuid),
|
|
143
|
+
'failed': None if path.failed is None else str(path.failed.uuid),
|
|
144
|
+
'length': len(path),
|
|
145
|
+
}
|
|
146
|
+
except Exception:
|
|
147
|
+
LOGGER.error("Error reading the evaluation record", exc_info=True)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def _var_to_text(cls, node: LogicNode) -> str | None:
|
|
152
|
+
"""The value a node holds in its own slot, as text, or None.
|
|
153
|
+
|
|
154
|
+
Read defensively: a slot is the node's workspace, and a node that has
|
|
155
|
+
never been evaluated holds whatever it was born with - which for most
|
|
156
|
+
types is the null a card says nothing about.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
view = node.out
|
|
160
|
+
if view.is_null:
|
|
161
|
+
return None
|
|
162
|
+
value = view.value
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
if value is None:
|
|
166
|
+
return None
|
|
167
|
+
return repr(value) if isinstance(value, str) else str(value)
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def _own_value(cls, node: LogicNode) -> str | None:
|
|
171
|
+
"""The payload a leaf carries itself: a literal's value, a read's entry.
|
|
172
|
+
|
|
173
|
+
Asked of the layer rather than inferred from the display text, and
|
|
174
|
+
guarded: only the input family answers it, and a node that is not one
|
|
175
|
+
refuses the question.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
value = node.value
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
if value is None:
|
|
182
|
+
return None
|
|
183
|
+
return repr(value) if isinstance(value, str) else str(value)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _operands_of(cls, node: LogicNode) -> list[dict[str, Any]]:
|
|
187
|
+
"""The nodes an expression was built over.
|
|
188
|
+
|
|
189
|
+
An operand is NOT a child: it is reachable in C through the node's own
|
|
190
|
+
argument array, and a walk over ``children`` alone goes straight past it.
|
|
191
|
+
This is what the card needs to say what the arithmetic is over, rather
|
|
192
|
+
than leaving it to be read out of the display text.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
operands = node.operands
|
|
196
|
+
except Exception:
|
|
197
|
+
return []
|
|
198
|
+
return [
|
|
199
|
+
{"id": str(operand.uuid), "type": operand.type, "repr": operand.repr}
|
|
200
|
+
for operand in operands if operand is not None
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def _condition_to_dict(cls, condition: Any) -> dict[str, Any]:
|
|
205
|
+
"""An edge's condition, in the four shapes the front end draws.
|
|
206
|
+
|
|
207
|
+
``value`` is asked for last and guarded: a condition that carries no
|
|
208
|
+
payload refuses the question, and a build's TRUE/FALSE arm answers it
|
|
209
|
+
with the bool the arm is. The refusal is the ordinary case here, not an
|
|
210
|
+
error.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
value = condition.value
|
|
214
|
+
except Exception:
|
|
215
|
+
value = None
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
'text': str(condition),
|
|
219
|
+
'is_binary': bool(condition.is_binary),
|
|
220
|
+
'is_else': bool(condition.is_else),
|
|
221
|
+
'is_none': bool(condition.is_none),
|
|
222
|
+
'value': value if isinstance(value, (bool, int, float, str)) else None,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def _convert_node_to_dict(
|
|
227
|
+
cls,
|
|
228
|
+
node: LogicNode,
|
|
229
|
+
visited_nodes: dict[str, dict[str, Any]],
|
|
230
|
+
virtual_parent_links: list[dict[str, Any]],
|
|
231
|
+
activated_node_ids: set | None = None,
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Recursively converts a bake graph into the card model the page draws."""
|
|
234
|
+
node_id = str(node.uuid)
|
|
235
|
+
if node_id in visited_nodes:
|
|
236
|
+
# The same block reached twice: a resumed node, or a join. The card
|
|
237
|
+
# is drawn once, where it was first reached.
|
|
238
|
+
return {"id": node_id, "is_reference": True}
|
|
239
|
+
|
|
240
|
+
node_type = node.type
|
|
241
|
+
node_obj: dict[str, Any] = {
|
|
242
|
+
"id": node_id,
|
|
243
|
+
"type": node_type,
|
|
244
|
+
"family": cls._family_of.get(node_type, 'OTHER'),
|
|
245
|
+
"repr": node.repr,
|
|
246
|
+
"labels": list(node.labels),
|
|
247
|
+
"autogen": bool(node.autogen),
|
|
248
|
+
"is_leaf": bool(node.is_leaf),
|
|
249
|
+
"size": int(node.size),
|
|
250
|
+
"address": int(node.address),
|
|
251
|
+
"out": cls._var_to_text(node),
|
|
252
|
+
"self_value": cls._own_value(node),
|
|
253
|
+
"operands": cls._operands_of(node),
|
|
254
|
+
"hooks": list(node.eval_hooks),
|
|
255
|
+
"_children": [],
|
|
256
|
+
"activated": activated_node_ids is None or node_id in activated_node_ids,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
visited_nodes[node_id] = node_obj
|
|
260
|
+
|
|
261
|
+
if node_type == 'BREAKPOINT':
|
|
262
|
+
# A breakpoint's only child is the node it resumed into - and that
|
|
263
|
+
# node is already drawn where the branch was carried on from, so it
|
|
264
|
+
# becomes a virtual link rather than a child edge. Drawing it as a
|
|
265
|
+
# child would draw the same subtree twice and say the branch forks
|
|
266
|
+
# here, which is the one thing a break did not do.
|
|
267
|
+
node: BreakpointNode
|
|
268
|
+
child_node = node.linked_to
|
|
269
|
+
node_obj["await_connection"] = bool(node.await_connection)
|
|
270
|
+
if child_node is not None:
|
|
271
|
+
if child_node.parent is node:
|
|
272
|
+
child_dict = cls._convert_node_to_dict(child_node, visited_nodes, virtual_parent_links, activated_node_ids)
|
|
273
|
+
node_obj["_children"].append({
|
|
274
|
+
# The arm the break left on: the condition the
|
|
275
|
+
# continuation hangs by, read off the continuation.
|
|
276
|
+
"condition": cls._condition_to_dict(child_node.condition_to_parent),
|
|
277
|
+
"node": child_dict,
|
|
278
|
+
})
|
|
279
|
+
else:
|
|
280
|
+
virtual_parent_links.append({
|
|
281
|
+
"source": node_id,
|
|
282
|
+
"target": str(child_node.uuid),
|
|
283
|
+
"type": "virtual_parent",
|
|
284
|
+
})
|
|
285
|
+
return node_obj
|
|
286
|
+
|
|
287
|
+
for condition, child_node in node.children.items():
|
|
288
|
+
child_dict = cls._convert_node_to_dict(child_node, visited_nodes, virtual_parent_links, activated_node_ids)
|
|
289
|
+
node_obj["_children"].append({
|
|
290
|
+
"condition": cls._condition_to_dict(condition),
|
|
291
|
+
"node": child_dict,
|
|
292
|
+
})
|
|
293
|
+
return node_obj
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def _convert_tree_to_format(cls, root_node: LogicNode, walk: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
297
|
+
"""Converts a bake graph into the payload the page draws."""
|
|
298
|
+
visited_nodes: dict[str, dict[str, Any]] = {}
|
|
299
|
+
virtual_parent_links: list[dict[str, Any]] = []
|
|
300
|
+
|
|
301
|
+
activated_node_ids = None if walk is None else set(walk['ids'])
|
|
302
|
+
root_dict = cls._convert_node_to_dict(root_node, visited_nodes, virtual_parent_links, activated_node_ids)
|
|
303
|
+
|
|
304
|
+
# What the graph still carries of the BUILD: a breakpoint a split build
|
|
305
|
+
# stopped at, and a placeholder a branch reserved and never filled. Both
|
|
306
|
+
# are gone once the bake has run, so counting them is what tells the two
|
|
307
|
+
# states of a graph apart without having to have seen the report.
|
|
308
|
+
breakpoints = sum(1 for n in visited_nodes.values() if n['type'] == 'BREAKPOINT')
|
|
309
|
+
placeholders = sum(1 for n in visited_nodes.values() if n['type'] == 'PLACEHOLDER')
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"root": root_dict,
|
|
313
|
+
"virtual_links": virtual_parent_links,
|
|
314
|
+
"n_nodes": len(visited_nodes),
|
|
315
|
+
"root_name": getattr(root_node, 'name', None) or root_node.repr,
|
|
316
|
+
"scaffolding": {"breakpoints": breakpoints, "placeholders": placeholders},
|
|
317
|
+
"active_ids": [] if walk is None else walk['ids'],
|
|
318
|
+
"walk": walk,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def _port_is_free(self, port: int) -> bool:
|
|
322
|
+
try:
|
|
323
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
324
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
325
|
+
s.bind((self.host, port))
|
|
326
|
+
return True
|
|
327
|
+
except OSError:
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
def _auto_port(self, max_tries: int = 200) -> int:
|
|
331
|
+
requested_port = self.port
|
|
332
|
+
if not self._port_is_free(requested_port):
|
|
333
|
+
LOGGER.warning(f"Port {requested_port} is in use — searching for a free port...")
|
|
334
|
+
for i in range(max_tries):
|
|
335
|
+
requested_port += 1
|
|
336
|
+
if self._port_is_free(requested_port):
|
|
337
|
+
return requested_port
|
|
338
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
339
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
340
|
+
s.bind((self.host, 0))
|
|
341
|
+
requested_port = s.getsockname()[1]
|
|
342
|
+
LOGGER.info(f"Selected alternative port {requested_port} for this session")
|
|
343
|
+
return requested_port
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _open_browser(url):
|
|
347
|
+
import webbrowser
|
|
348
|
+
|
|
349
|
+
def open_browser():
|
|
350
|
+
time.sleep(1)
|
|
351
|
+
try:
|
|
352
|
+
webbrowser.open(url)
|
|
353
|
+
except Exception:
|
|
354
|
+
LOGGER.exception("Failed to open browser for URL: %s", url)
|
|
355
|
+
|
|
356
|
+
browser_thread = threading.Thread(target=open_browser)
|
|
357
|
+
browser_thread.daemon = True
|
|
358
|
+
browser_thread.start()
|
|
359
|
+
|
|
360
|
+
def show(self, node: LogicNode, with_eval: bool = True):
|
|
361
|
+
"""Starts the Flask web UI to visualize a bake graph."""
|
|
362
|
+
if not isinstance(node, LogicNode):
|
|
363
|
+
raise TypeError("The 'node' argument must be an instance of LogicNode or its subclass.")
|
|
364
|
+
|
|
365
|
+
LOGGER.info(f"Preparing to visualize bake graph starting at {node}")
|
|
366
|
+
|
|
367
|
+
self.with_eval = with_eval
|
|
368
|
+
self.current_tree_data = self._convert_tree_to_format(node, self._walk_record(node) if with_eval else None)
|
|
369
|
+
self.current_tree_id = str(node.uuid)
|
|
370
|
+
self.node = node
|
|
371
|
+
self.with_watch = False
|
|
372
|
+
|
|
373
|
+
port_to_use = self._auto_port()
|
|
374
|
+
url = f"http://{self.host}:{port_to_use}"
|
|
375
|
+
self._open_browser(url)
|
|
376
|
+
LOGGER.info(f"Starting Flask server on {url} (with_eval={with_eval})")
|
|
377
|
+
self.app.run(host=self.host, port=port_to_use, debug=self.debug, use_reloader=False, threaded=True)
|
|
378
|
+
|
|
379
|
+
def watch(self, node: RootLogicNode, interval: float = 0.5, block: bool = False):
|
|
380
|
+
"""
|
|
381
|
+
Starts a watch server that streams activation diffs via SSE.
|
|
382
|
+
|
|
383
|
+
A baked graph is immutable, but the STORE it reads is not: the caller
|
|
384
|
+
refills an entry and walks the graph again, and what moves is which
|
|
385
|
+
nodes the walk landed on. Each client connection gets its own worker and
|
|
386
|
+
queue, so multiple tabs do not interfere.
|
|
387
|
+
"""
|
|
388
|
+
from flask import Response, stream_with_context
|
|
389
|
+
|
|
390
|
+
walk = self._walk_record(node)
|
|
391
|
+
last_activated = set() if walk is None else set(walk['ids'])
|
|
392
|
+
self.current_tree_data = self._convert_tree_to_format(node, walk)
|
|
393
|
+
self.current_tree_id = str(node.uuid)
|
|
394
|
+
self.with_eval = True
|
|
395
|
+
self.with_watch = True
|
|
396
|
+
self.node = node
|
|
397
|
+
|
|
398
|
+
@self.app.route('/watch')
|
|
399
|
+
def sse_watch():
|
|
400
|
+
q = queue.Queue()
|
|
401
|
+
stop_event = threading.Event()
|
|
402
|
+
|
|
403
|
+
def worker():
|
|
404
|
+
nonlocal last_activated
|
|
405
|
+
while not stop_event.is_set():
|
|
406
|
+
activated_now = {str(n.uuid) for n in node.eval_path}
|
|
407
|
+
added = list(activated_now - last_activated)
|
|
408
|
+
removed = list(last_activated - activated_now)
|
|
409
|
+
if added or removed:
|
|
410
|
+
q.put(json.dumps({'added': added, 'removed': removed}))
|
|
411
|
+
last_activated = activated_now
|
|
412
|
+
time.sleep(interval)
|
|
413
|
+
|
|
414
|
+
t = threading.Thread(target=worker)
|
|
415
|
+
t.daemon = True
|
|
416
|
+
t.start()
|
|
417
|
+
|
|
418
|
+
def event_stream():
|
|
419
|
+
try:
|
|
420
|
+
while True:
|
|
421
|
+
try:
|
|
422
|
+
diff = q.get(timeout=interval)
|
|
423
|
+
except queue.Empty:
|
|
424
|
+
continue
|
|
425
|
+
yield f"data: {diff}\n\n"
|
|
426
|
+
except GeneratorExit:
|
|
427
|
+
stop_event.set()
|
|
428
|
+
|
|
429
|
+
return Response(stream_with_context(event_stream()), mimetype='text/event-stream')
|
|
430
|
+
|
|
431
|
+
port_to_use = self._auto_port()
|
|
432
|
+
url = f"http://{self.host}:{port_to_use}"
|
|
433
|
+
self._open_browser(url)
|
|
434
|
+
LOGGER.info(f"Starting SSE watch server on port {port_to_use}")
|
|
435
|
+
if block:
|
|
436
|
+
self.app.run(host=self.host, port=port_to_use, debug=self.debug, use_reloader=False, threaded=True)
|
|
437
|
+
else:
|
|
438
|
+
def run_flask():
|
|
439
|
+
self.app.run(host=self.host, port=port_to_use, debug=self.debug, use_reloader=False, threaded=True)
|
|
440
|
+
|
|
441
|
+
flask_thread = threading.Thread(target=run_flask)
|
|
442
|
+
flask_thread.daemon = True
|
|
443
|
+
flask_thread.start()
|
|
444
|
+
return flask_thread
|
|
445
|
+
|
|
446
|
+
@classmethod
|
|
447
|
+
def to_html(cls, node: LogicNode, file_name: str, with_eval: bool = True):
|
|
448
|
+
"""
|
|
449
|
+
Exports a bake graph as a self-contained offline HTML file.
|
|
450
|
+
|
|
451
|
+
The page is the one the server would have served, with the stylesheet,
|
|
452
|
+
the script and D3 read off disk and written into it — so the file opens
|
|
453
|
+
somewhere with no server, no network and no package installed.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
node (LogicNode): The root node of the graph to visualize.
|
|
457
|
+
file_name (str): Output HTML file path.
|
|
458
|
+
with_eval (bool): Whether to carry the evaluation record into the page.
|
|
459
|
+
"""
|
|
460
|
+
from jinja2 import Environment, FileSystemLoader
|
|
461
|
+
|
|
462
|
+
if not isinstance(node, LogicNode):
|
|
463
|
+
raise TypeError("The 'node' argument must be an instance of LogicNode or its subclass.")
|
|
464
|
+
|
|
465
|
+
tree_data = cls._convert_tree_to_format(node, cls._walk_record(node) if with_eval else None)
|
|
466
|
+
|
|
467
|
+
module_dir = pathlib.Path(__file__).parent
|
|
468
|
+
template_dir = module_dir / "templates"
|
|
469
|
+
static_dir = module_dir / "static"
|
|
470
|
+
|
|
471
|
+
def read(name: str) -> str:
|
|
472
|
+
with open(static_dir / name, "r", encoding="utf-8") as f:
|
|
473
|
+
return f.read()
|
|
474
|
+
|
|
475
|
+
env = Environment(loader=FileSystemLoader(template_dir))
|
|
476
|
+
template = env.get_template("offline.html")
|
|
477
|
+
html_output = template.render(
|
|
478
|
+
initial_tree_data=tree_data,
|
|
479
|
+
with_eval=with_eval,
|
|
480
|
+
css_content=read("style.css"),
|
|
481
|
+
js_content=read("script.js"),
|
|
482
|
+
d3_content=read("d3.v7.min.js"),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
with open(file_name, "w", encoding="utf-8") as f:
|
|
486
|
+
f.write(html_output)
|
|
487
|
+
|
|
488
|
+
LOGGER.info(f'Offline HTML exported to: "{os.path.realpath(file_name)}"')
|