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.
Files changed (148) hide show
  1. decision_graph/__infra__.pxd +1 -0
  2. decision_graph/__init__.pxd +1 -0
  3. decision_graph/__init__.py +39 -0
  4. decision_graph/decision_tree/__init__.pxd +2 -0
  5. decision_graph/decision_tree/__init__.py +85 -0
  6. decision_graph/decision_tree/bake/__infra__.pxd +502 -0
  7. decision_graph/decision_tree/bake/__init__.pxd +502 -0
  8. decision_graph/decision_tree/bake/__init__.py +29 -0
  9. decision_graph/decision_tree/bake/c_action.cp315-win_amd64.pyd +0 -0
  10. decision_graph/decision_tree/bake/c_action.h +324 -0
  11. decision_graph/decision_tree/bake/c_action.pxd +54 -0
  12. decision_graph/decision_tree/bake/c_action.pyi +126 -0
  13. decision_graph/decision_tree/bake/c_action.pyx +65 -0
  14. decision_graph/decision_tree/bake/c_allocator_protocol.cp315-win_amd64.pyd +0 -0
  15. decision_graph/decision_tree/bake/c_allocator_protocol.pxd +24 -0
  16. decision_graph/decision_tree/bake/c_allocator_protocol.pyi +61 -0
  17. decision_graph/decision_tree/bake/c_allocator_protocol.pyx +109 -0
  18. decision_graph/decision_tree/bake/c_bake.cp315-win_amd64.pyd +0 -0
  19. decision_graph/decision_tree/bake/c_bake.h +629 -0
  20. decision_graph/decision_tree/bake/c_bake.pxd +52 -0
  21. decision_graph/decision_tree/bake/c_bake.pyi +151 -0
  22. decision_graph/decision_tree/bake/c_bake.pyx +142 -0
  23. decision_graph/decision_tree/bake/c_collections.cp315-win_amd64.pyd +0 -0
  24. decision_graph/decision_tree/bake/c_collections.h +692 -0
  25. decision_graph/decision_tree/bake/c_collections.pxd +52 -0
  26. decision_graph/decision_tree/bake/c_collections.pyi +193 -0
  27. decision_graph/decision_tree/bake/c_collections.pyx +161 -0
  28. decision_graph/decision_tree/bake/c_const.cp315-win_amd64.pyd +0 -0
  29. decision_graph/decision_tree/bake/c_const.h +395 -0
  30. decision_graph/decision_tree/bake/c_const.pxd +56 -0
  31. decision_graph/decision_tree/bake/c_const.pyi +305 -0
  32. decision_graph/decision_tree/bake/c_const.pyx +278 -0
  33. decision_graph/decision_tree/bake/c_edge.cp315-win_amd64.pyd +0 -0
  34. decision_graph/decision_tree/bake/c_edge.h +511 -0
  35. decision_graph/decision_tree/bake/c_edge.pxd +117 -0
  36. decision_graph/decision_tree/bake/c_edge.pyi +192 -0
  37. decision_graph/decision_tree/bake/c_edge.pyx +270 -0
  38. decision_graph/decision_tree/bake/c_eval.h +848 -0
  39. decision_graph/decision_tree/bake/c_eval.pxd +40 -0
  40. decision_graph/decision_tree/bake/c_expr.cp315-win_amd64.pyd +0 -0
  41. decision_graph/decision_tree/bake/c_expr.h +1900 -0
  42. decision_graph/decision_tree/bake/c_expr.pxd +129 -0
  43. decision_graph/decision_tree/bake/c_expr.pyi +280 -0
  44. decision_graph/decision_tree/bake/c_expr.pyx +272 -0
  45. decision_graph/decision_tree/bake/c_hierarchy.cp315-win_amd64.pyd +0 -0
  46. decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
  47. decision_graph/decision_tree/bake/c_hierarchy.pxd +71 -0
  48. decision_graph/decision_tree/bake/c_hierarchy.pyi +409 -0
  49. decision_graph/decision_tree/bake/c_hierarchy.pyx +277 -0
  50. decision_graph/decision_tree/bake/c_logic_group.cp315-win_amd64.pyd +0 -0
  51. decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
  52. decision_graph/decision_tree/bake/c_logic_group.pxd +162 -0
  53. decision_graph/decision_tree/bake/c_logic_group.pyi +264 -0
  54. decision_graph/decision_tree/bake/c_logic_group.pyx +308 -0
  55. decision_graph/decision_tree/bake/c_node.cp315-win_amd64.pyd +0 -0
  56. decision_graph/decision_tree/bake/c_node.h +2660 -0
  57. decision_graph/decision_tree/bake/c_node.pxd +381 -0
  58. decision_graph/decision_tree/bake/c_node.pyi +438 -0
  59. decision_graph/decision_tree/bake/c_node.pyx +639 -0
  60. decision_graph/decision_tree/bake/c_reconstruct.cp315-win_amd64.pyd +0 -0
  61. decision_graph/decision_tree/bake/c_reconstruct.pxd +63 -0
  62. decision_graph/decision_tree/bake/c_reconstruct.pyi +60 -0
  63. decision_graph/decision_tree/bake/c_reconstruct.pyx +252 -0
  64. decision_graph/decision_tree/bake/c_var.cp315-win_amd64.pyd +0 -0
  65. decision_graph/decision_tree/bake/c_var.h +2054 -0
  66. decision_graph/decision_tree/bake/c_var.pxd +205 -0
  67. decision_graph/decision_tree/bake/c_var.pyi +271 -0
  68. decision_graph/decision_tree/bake/c_var.pyx +228 -0
  69. decision_graph/decision_tree/capi/__infra__.pxd +33 -0
  70. decision_graph/decision_tree/capi/__init__.pxd +33 -0
  71. decision_graph/decision_tree/capi/__init__.py +57 -0
  72. decision_graph/decision_tree/capi/c_abc.cp315-win_amd64.pyd +0 -0
  73. decision_graph/decision_tree/capi/c_abc.pxd +246 -0
  74. decision_graph/decision_tree/capi/c_abc.pyi +770 -0
  75. decision_graph/decision_tree/capi/c_abc.pyx +1532 -0
  76. decision_graph/decision_tree/capi/c_collection.cp315-win_amd64.pyd +0 -0
  77. decision_graph/decision_tree/capi/c_collection.pxd +22 -0
  78. decision_graph/decision_tree/capi/c_collection.pyi +245 -0
  79. decision_graph/decision_tree/capi/c_collection.pyx +138 -0
  80. decision_graph/decision_tree/capi/c_node.cp315-win_amd64.pyd +0 -0
  81. decision_graph/decision_tree/capi/c_node.pxd +95 -0
  82. decision_graph/decision_tree/capi/c_node.pyi +492 -0
  83. decision_graph/decision_tree/capi/c_node.pyx +551 -0
  84. decision_graph/decision_tree/exc.py +189 -0
  85. decision_graph/decision_tree/native/__init__.py +57 -0
  86. decision_graph/decision_tree/native/abc.py +1167 -0
  87. decision_graph/decision_tree/native/collection.py +134 -0
  88. decision_graph/decision_tree/native/node.py +521 -0
  89. decision_graph/includes/decision_graph/decision_tree/bake/c_action.c +16320 -0
  90. decision_graph/includes/decision_graph/decision_tree/bake/c_action.h +324 -0
  91. decision_graph/includes/decision_graph/decision_tree/bake/c_allocator_protocol.c +12473 -0
  92. decision_graph/includes/decision_graph/decision_tree/bake/c_bake.c +16485 -0
  93. decision_graph/includes/decision_graph/decision_tree/bake/c_bake.h +629 -0
  94. decision_graph/includes/decision_graph/decision_tree/bake/c_collections.c +17381 -0
  95. decision_graph/includes/decision_graph/decision_tree/bake/c_collections.h +692 -0
  96. decision_graph/includes/decision_graph/decision_tree/bake/c_const.c +20815 -0
  97. decision_graph/includes/decision_graph/decision_tree/bake/c_const.h +395 -0
  98. decision_graph/includes/decision_graph/decision_tree/bake/c_edge.c +20696 -0
  99. decision_graph/includes/decision_graph/decision_tree/bake/c_edge.h +511 -0
  100. decision_graph/includes/decision_graph/decision_tree/bake/c_eval.h +848 -0
  101. decision_graph/includes/decision_graph/decision_tree/bake/c_expr.c +22065 -0
  102. decision_graph/includes/decision_graph/decision_tree/bake/c_expr.h +1900 -0
  103. decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.c +23267 -0
  104. decision_graph/includes/decision_graph/decision_tree/bake/c_hierarchy.h +731 -0
  105. decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.c +22434 -0
  106. decision_graph/includes/decision_graph/decision_tree/bake/c_logic_group.h +1226 -0
  107. decision_graph/includes/decision_graph/decision_tree/bake/c_node.c +28385 -0
  108. decision_graph/includes/decision_graph/decision_tree/bake/c_node.h +2660 -0
  109. decision_graph/includes/decision_graph/decision_tree/bake/c_reconstruct.c +15955 -0
  110. decision_graph/includes/decision_graph/decision_tree/bake/c_var.c +16881 -0
  111. decision_graph/includes/decision_graph/decision_tree/bake/c_var.h +2054 -0
  112. decision_graph/includes/decision_graph/decision_tree/capi/c_abc.c +59196 -0
  113. decision_graph/includes/decision_graph/decision_tree/capi/c_collection.c +19967 -0
  114. decision_graph/includes/decision_graph/decision_tree/capi/c_node.c +39229 -0
  115. decision_graph/logic_group/__init__.py +25 -0
  116. decision_graph/logic_group/base.py +100 -0
  117. decision_graph/logic_group/pending_request.py +384 -0
  118. decision_graph/webui/__init__.py +24 -0
  119. decision_graph/webui/bake/__init__.py +65 -0
  120. decision_graph/webui/bake/app.py +488 -0
  121. decision_graph/webui/bake/static/d3.v7.min.js +2 -0
  122. decision_graph/webui/bake/static/script.js +1440 -0
  123. decision_graph/webui/bake/static/style.css +920 -0
  124. decision_graph/webui/bake/templates/index.html +183 -0
  125. decision_graph/webui/bake/templates/offline.html +184 -0
  126. decision_graph/webui/capi/__init__.py +54 -0
  127. decision_graph/webui/capi/app.py +366 -0
  128. decision_graph/webui/capi/main.py +53 -0
  129. decision_graph/webui/capi/static/d3.v7.min.js +2 -0
  130. decision_graph/webui/capi/static/script.js +1026 -0
  131. decision_graph/webui/capi/static/style.css +656 -0
  132. decision_graph/webui/capi/templates/index.html +93 -0
  133. decision_graph/webui/capi/templates/offline.html +94 -0
  134. decision_graph/webui/native/__init__.py +54 -0
  135. decision_graph/webui/native/app.py +366 -0
  136. decision_graph/webui/native/main.py +53 -0
  137. decision_graph/webui/native/static/d3.v7.min.js +2 -0
  138. decision_graph/webui/native/static/script.js +1026 -0
  139. decision_graph/webui/native/static/style.css +656 -0
  140. decision_graph/webui/native/templates/index.html +93 -0
  141. decision_graph/webui/native/templates/offline.html +94 -0
  142. pydecisiongraph-0.3.2.post1.dist-info/METADATA +136 -0
  143. pydecisiongraph-0.3.2.post1.dist-info/RECORD +148 -0
  144. pydecisiongraph-0.3.2.post1.dist-info/WHEEL +5 -0
  145. pydecisiongraph-0.3.2.post1.dist-info/licenses/LICENSE +373 -0
  146. pydecisiongraph-0.3.2.post1.dist-info/top_level.txt +2 -0
  147. tests/bake_c_evaluator.cp315-win_amd64.pyd +0 -0
  148. 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)}"')