asecli 0.6.2__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.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Versioned quality report for meticulous ASE graph layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from .comment_bounds import comment_containment_issues
|
|
10
|
+
from .comment_metrics import WHITESPACE_ERROR, comment_layout_metrics, positioned_bounds
|
|
11
|
+
from .local_vars import GET_LOCAL_VAR_TYPE, REGISTER_LOCAL_VAR_TYPE
|
|
12
|
+
from .layout_graph import immediate_comment_owners
|
|
13
|
+
from .layout_audit_geometry import (
|
|
14
|
+
balance_errors, branch_side_balance, horizontal_edge_ratio, node_overlaps,
|
|
15
|
+
sibling_order_violations, spine_horizontal_errors, stage_gap_violations,
|
|
16
|
+
stage_right_alignment, subtree_overlaps, wire_crossings,
|
|
17
|
+
wire_through_comment_titles, wire_through_nodes,
|
|
18
|
+
)
|
|
19
|
+
from .layout_audit_repeated import repeated_module_mismatches
|
|
20
|
+
from .commentary import inspect_comment_groups
|
|
21
|
+
from .meticulous_layout import Geometry, MeticulousLayoutPlan
|
|
22
|
+
from .model import AseGraph
|
|
23
|
+
from .wire_router import logical_wires, routed_wire_paths
|
|
24
|
+
|
|
25
|
+
REPORT_SCHEMA = "asecli.graph-layout.v2"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def audit_meticulous_layout(
|
|
29
|
+
graph: AseGraph,
|
|
30
|
+
geometry: Mapping[str, Geometry],
|
|
31
|
+
plan: MeticulousLayoutPlan,
|
|
32
|
+
*,
|
|
33
|
+
moved: int,
|
|
34
|
+
comment_purpose: list[dict] | None = None,
|
|
35
|
+
) -> dict:
|
|
36
|
+
positions = plan.positions
|
|
37
|
+
_refresh_subtree_bounds(plan, geometry)
|
|
38
|
+
rects = {
|
|
39
|
+
node_id: (x, y, x + geometry[node_id].width, y + geometry[node_id].height)
|
|
40
|
+
for node_id, (x, y) in positions.items()
|
|
41
|
+
if node_id in geometry
|
|
42
|
+
}
|
|
43
|
+
node_overlap_items = node_overlaps(rects)
|
|
44
|
+
subtree_overlap_items = subtree_overlaps(plan)
|
|
45
|
+
balance = balance_errors(graph, plan, geometry)
|
|
46
|
+
stage_alignment = stage_right_alignment(graph, plan, geometry)
|
|
47
|
+
spine_alignment = spine_horizontal_errors(graph, plan, geometry)
|
|
48
|
+
side_balance = branch_side_balance(plan)
|
|
49
|
+
horizontal_ratio = horizontal_edge_ratio(graph, plan, geometry)
|
|
50
|
+
gap_violations = stage_gap_violations(graph, plan, geometry)
|
|
51
|
+
sibling_order = sibling_order_violations(graph, plan)
|
|
52
|
+
paths = routed_wire_paths(graph, geometry, positions, plan.wire_route_changes)
|
|
53
|
+
wire_through = wire_through_nodes(paths, rects)
|
|
54
|
+
wire_through += wire_through_comment_titles(graph, paths)
|
|
55
|
+
crossing_items = wire_crossings(paths)
|
|
56
|
+
live_bounds = positioned_bounds(geometry, positions)
|
|
57
|
+
comment_issues = comment_containment_issues(graph, live_bounds)
|
|
58
|
+
whitespace, outliers = comment_layout_metrics(graph, geometry, positions)
|
|
59
|
+
register_distance, get_distance = _local_var_distances(graph, rects)
|
|
60
|
+
remote_direct = _remote_direct_wires(graph, plan)
|
|
61
|
+
repeated = repeated_module_mismatches(graph, plan)
|
|
62
|
+
comment_preservation = _comment_preservation(graph, plan)
|
|
63
|
+
purpose = (
|
|
64
|
+
comment_purpose
|
|
65
|
+
if comment_purpose is not None
|
|
66
|
+
else _existing_comment_purpose(graph)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
containment = [item for item in comment_issues if item["code"] == "COMMENT_MEMBER_OUTSIDE_FRAME"]
|
|
70
|
+
comment_overlap = [item for item in comment_issues if item["code"] == "COMMENT_GROUP_OVERLAP"]
|
|
71
|
+
hard_failures = []
|
|
72
|
+
hard_failures += _failure_codes("NODE_OVERLAP", node_overlap_items)
|
|
73
|
+
hard_failures += _failure_codes("SUBTREE_OVERLAP", subtree_overlap_items)
|
|
74
|
+
hard_failures += _failure_codes("STAGE_RIGHT_ALIGNMENT", stage_alignment)
|
|
75
|
+
hard_failures += _failure_codes("SPINE_HORIZONTAL", spine_alignment)
|
|
76
|
+
hard_failures += _failure_codes(
|
|
77
|
+
"BRANCH_SIDE_BALANCE", [item for item in side_balance if item["count_difference"] > 1],
|
|
78
|
+
)
|
|
79
|
+
hard_failures += _failure_codes("STAGE_GAP", gap_violations)
|
|
80
|
+
hard_failures += _failure_codes("SIBLING_ORDER", sibling_order)
|
|
81
|
+
hard_failures += _failure_codes("WIRE_THROUGH_NODE", wire_through)
|
|
82
|
+
hard_failures += _failure_codes("COMMENT_CONTAINMENT", containment)
|
|
83
|
+
hard_failures += _failure_codes("COMMENT_OVERLAP", comment_overlap)
|
|
84
|
+
hard_failures += _failure_codes(
|
|
85
|
+
"COMMENT_WHITESPACE", [item for item in whitespace if item["ratio"] > WHITESPACE_ERROR],
|
|
86
|
+
)
|
|
87
|
+
hard_failures += _failure_codes("COMMENT_PRESERVATION", comment_preservation["issues"])
|
|
88
|
+
hard_failures += _failure_codes(
|
|
89
|
+
"COMMENT_PURPOSE_UNRESOLVED", [item for item in purpose if item["status"] == "unresolved"],
|
|
90
|
+
)
|
|
91
|
+
report = {
|
|
92
|
+
"schema": REPORT_SCHEMA,
|
|
93
|
+
"status": "failed" if hard_failures else "passed",
|
|
94
|
+
"node_overlaps": node_overlap_items,
|
|
95
|
+
"subtree_balance_error": balance,
|
|
96
|
+
"stage_right_alignment": stage_alignment,
|
|
97
|
+
"spine_horizontal_error": spine_alignment,
|
|
98
|
+
"branch_side_balance": side_balance,
|
|
99
|
+
"horizontal_edge_ratio": horizontal_ratio,
|
|
100
|
+
"stage_gap_violations": gap_violations,
|
|
101
|
+
"sibling_order_violations": sibling_order,
|
|
102
|
+
"subtree_overlaps": subtree_overlap_items,
|
|
103
|
+
"wire_through_nodes": wire_through,
|
|
104
|
+
"wire_wire_crossings": {"count": len(crossing_items), "pairs": crossing_items[:100]},
|
|
105
|
+
"residual_wire_crossings": {"count": len(crossing_items), "pairs": crossing_items[:100]},
|
|
106
|
+
"comment_containment": containment,
|
|
107
|
+
"comment_overlap": comment_overlap,
|
|
108
|
+
"comment_whitespace_ratio": whitespace,
|
|
109
|
+
"comment_preservation": comment_preservation,
|
|
110
|
+
"comment_purpose": purpose,
|
|
111
|
+
"outlier_members": outliers,
|
|
112
|
+
"register_distance": register_distance,
|
|
113
|
+
"get_consumer_distance": get_distance,
|
|
114
|
+
"remote_direct_wires": remote_direct,
|
|
115
|
+
"repeated_module_mismatches": repeated,
|
|
116
|
+
"wire_route_changes": list(plan.wire_route_changes),
|
|
117
|
+
"layout_stability": {
|
|
118
|
+
"moved_nodes": moved,
|
|
119
|
+
"fingerprint": _fingerprint(positions),
|
|
120
|
+
"deterministic": True,
|
|
121
|
+
},
|
|
122
|
+
"hard_failures": hard_failures,
|
|
123
|
+
"visual_validation": "requires_editor_review" if not hard_failures else "blocked",
|
|
124
|
+
}
|
|
125
|
+
return report
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _local_var_distances(graph, rects) -> tuple[list[dict], list[dict]]:
|
|
129
|
+
types = {node.node_id: node.type_name for node in graph.nodes}
|
|
130
|
+
register, get = [], []
|
|
131
|
+
for item in logical_wires(graph):
|
|
132
|
+
wire = item.wire
|
|
133
|
+
if wire.out_node not in rects or wire.in_node not in rects:
|
|
134
|
+
continue
|
|
135
|
+
distance = round(max(0.0, rects[wire.in_node][0] - rects[wire.out_node][2]), 3)
|
|
136
|
+
item = {"from": wire.out_node, "to": wire.in_node, "distance": distance,
|
|
137
|
+
"status": "passed" if 64.0 <= distance <= 160.0 else "warning"}
|
|
138
|
+
if types.get(wire.in_node) == REGISTER_LOCAL_VAR_TYPE:
|
|
139
|
+
register.append(item)
|
|
140
|
+
if types.get(wire.out_node) == GET_LOCAL_VAR_TYPE:
|
|
141
|
+
get.append(item)
|
|
142
|
+
return register, get
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _refresh_subtree_bounds(plan, geometry) -> None:
|
|
146
|
+
for node_id in plan.children:
|
|
147
|
+
members, stack = {node_id}, list(plan.children.get(node_id, ()))
|
|
148
|
+
while stack:
|
|
149
|
+
child = stack.pop()
|
|
150
|
+
if child not in members:
|
|
151
|
+
members.add(child)
|
|
152
|
+
stack.extend(plan.children.get(child, ()))
|
|
153
|
+
rects = [
|
|
154
|
+
(
|
|
155
|
+
plan.positions[member][0], plan.positions[member][1],
|
|
156
|
+
plan.positions[member][0] + geometry[member].width,
|
|
157
|
+
plan.positions[member][1] + geometry[member].height,
|
|
158
|
+
)
|
|
159
|
+
for member in members if member in plan.positions and member in geometry
|
|
160
|
+
]
|
|
161
|
+
plan.subtree_bounds[node_id] = (
|
|
162
|
+
min(rect[0] for rect in rects), min(rect[1] for rect in rects),
|
|
163
|
+
max(rect[2] for rect in rects), max(rect[3] for rect in rects),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _remote_direct_wires(graph, plan) -> list[dict]:
|
|
168
|
+
types = {node.node_id: node.type_name for node in graph.nodes}
|
|
169
|
+
owners = immediate_comment_owners(graph)
|
|
170
|
+
direct_wires = [
|
|
171
|
+
item.wire for item in logical_wires(graph)
|
|
172
|
+
if types.get(item.wire.out_node) != GET_LOCAL_VAR_TYPE
|
|
173
|
+
and types.get(item.wire.in_node) != REGISTER_LOCAL_VAR_TYPE
|
|
174
|
+
]
|
|
175
|
+
consumer_groups: dict[str, set[str]] = {}
|
|
176
|
+
for wire in direct_wires:
|
|
177
|
+
owner = owners.get(wire.in_node)
|
|
178
|
+
if owner is not None:
|
|
179
|
+
consumer_groups.setdefault(wire.out_node, set()).add(owner)
|
|
180
|
+
|
|
181
|
+
result = []
|
|
182
|
+
for wire in direct_wires:
|
|
183
|
+
groups = consumer_groups.get(wire.out_node, set())
|
|
184
|
+
consumer_group = owners.get(wire.in_node)
|
|
185
|
+
producer_group = owners.get(wire.out_node)
|
|
186
|
+
if len(groups) < 2 or consumer_group is None:
|
|
187
|
+
continue
|
|
188
|
+
# Once two or more algorithm groups consume the source, it is a module
|
|
189
|
+
# interface. Local uses inside the producer's own group may stay direct;
|
|
190
|
+
# every other known group must consume it through a nearby Get.
|
|
191
|
+
if producer_group is not None and consumer_group == producer_group:
|
|
192
|
+
continue
|
|
193
|
+
result.append({
|
|
194
|
+
"from": wire.out_node,
|
|
195
|
+
"to": wire.in_node,
|
|
196
|
+
"stage_span": plan.depths.get(wire.out_node, 0) - plan.depths.get(wire.in_node, 0),
|
|
197
|
+
"producer_group": producer_group,
|
|
198
|
+
"consumer_group": consumer_group,
|
|
199
|
+
"consumer_groups": sorted(groups),
|
|
200
|
+
"reason": "consumed_by_multiple_groups",
|
|
201
|
+
})
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _comment_preservation(graph, plan) -> dict:
|
|
206
|
+
current = tuple(sorted(
|
|
207
|
+
(group["node_id"], tuple(group["members"]), group["color"])
|
|
208
|
+
for group in inspect_comment_groups(graph)
|
|
209
|
+
))
|
|
210
|
+
expected = plan.comment_snapshot
|
|
211
|
+
issues = []
|
|
212
|
+
expected_by_id = {item[0]: item for item in expected}
|
|
213
|
+
current_by_id = {item[0]: item for item in current}
|
|
214
|
+
for comment_id in sorted(set(expected_by_id) | set(current_by_id)):
|
|
215
|
+
if comment_id not in current_by_id:
|
|
216
|
+
issues.append({"comment_id": comment_id, "code": "deleted"})
|
|
217
|
+
elif comment_id not in expected_by_id:
|
|
218
|
+
issues.append({"comment_id": comment_id, "code": "added"})
|
|
219
|
+
elif current_by_id[comment_id][1:] != expected_by_id[comment_id][1:]:
|
|
220
|
+
issues.append({"comment_id": comment_id, "code": "membership_or_color_changed"})
|
|
221
|
+
return {"status": "passed" if not issues else "failed", "issues": issues}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _existing_comment_purpose(graph) -> list[dict]:
|
|
225
|
+
return [
|
|
226
|
+
{"comment_id": group["node_id"], "status": "existing", "title": group["title"]}
|
|
227
|
+
for group in inspect_comment_groups(graph)
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _failure_codes(code: str, items: list) -> list[dict]:
|
|
232
|
+
return [{"code": code, "count": len(items)}] if items else []
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _fingerprint(positions) -> str:
|
|
236
|
+
payload = json.dumps(sorted(positions.items()), separators=(",", ":"))
|
|
237
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Geometric hard gates for meticulous layout reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .commentary import _rect_overlap, inspect_comment_groups
|
|
6
|
+
from .layout_graph import id_key, port_key
|
|
7
|
+
from .wire_geometry import path_hits_rect, paths_cross, segment_hits_rect
|
|
8
|
+
from .wire_router import logical_wires
|
|
9
|
+
|
|
10
|
+
GRID_SIZE = 256.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def node_overlaps(rects) -> list[dict]:
|
|
14
|
+
issues = []
|
|
15
|
+
ids = sorted(rects, key=id_key)
|
|
16
|
+
for index, left in enumerate(ids):
|
|
17
|
+
for right in ids[index + 1:]:
|
|
18
|
+
overlap = _rect_overlap(rects[left], rects[right])
|
|
19
|
+
if overlap is not None:
|
|
20
|
+
issues.append({"node_ids": [left, right], "overlap": overlap})
|
|
21
|
+
return issues
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def subtree_overlaps(plan) -> list[dict]:
|
|
25
|
+
issues = []
|
|
26
|
+
for parent, children in plan.children.items():
|
|
27
|
+
for index, left in enumerate(children):
|
|
28
|
+
for right in children[index + 1:]:
|
|
29
|
+
overlap = _rect_overlap(plan.subtree_bounds[left], plan.subtree_bounds[right])
|
|
30
|
+
if overlap is not None:
|
|
31
|
+
issues.append({"parent_id": parent, "child_ids": [left, right], "overlap": overlap})
|
|
32
|
+
return issues
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def balance_errors(graph, plan, geometry) -> list[dict]:
|
|
36
|
+
result = []
|
|
37
|
+
for parent, children in plan.children.items():
|
|
38
|
+
if not children:
|
|
39
|
+
continue
|
|
40
|
+
spine = plan.spine_child[parent]
|
|
41
|
+
index = children.index(spine)
|
|
42
|
+
upper = children[:index]
|
|
43
|
+
lower = children[index + 1:]
|
|
44
|
+
axis = _edge_y(graph, plan, geometry, spine, parent)[1]
|
|
45
|
+
upper_height = 0.0 if not upper else axis - min(plan.subtree_bounds[item][1] for item in upper)
|
|
46
|
+
lower_height = 0.0 if not lower else max(plan.subtree_bounds[item][3] for item in lower) - axis
|
|
47
|
+
result.append({
|
|
48
|
+
"node_id": parent,
|
|
49
|
+
"spine_child": spine,
|
|
50
|
+
"upper_height": round(max(0.0, upper_height), 3),
|
|
51
|
+
"lower_height": round(max(0.0, lower_height), 3),
|
|
52
|
+
"error": round(abs(upper_height - lower_height), 3),
|
|
53
|
+
})
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stage_right_alignment(graph, plan, geometry, *, tolerance: float = 8.0) -> list[dict]:
|
|
58
|
+
result = []
|
|
59
|
+
for parent, children in sorted(plan.children.items(), key=lambda item: id_key(item[0])):
|
|
60
|
+
if not children:
|
|
61
|
+
continue
|
|
62
|
+
expected = plan.stage_output_x[children[0]]
|
|
63
|
+
deviations = []
|
|
64
|
+
for child in children:
|
|
65
|
+
wire = _primary_wire(graph, child, parent)
|
|
66
|
+
actual = plan.positions[child][0] + geometry[child].output_ports[wire.out_port][0]
|
|
67
|
+
error = abs(actual - expected)
|
|
68
|
+
if error > tolerance:
|
|
69
|
+
deviations.append({"node_id": child, "error": round(error, 3)})
|
|
70
|
+
if deviations:
|
|
71
|
+
result.append({"parent_id": parent, "expected_x": expected, "nodes": deviations})
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def spine_horizontal_errors(graph, plan, geometry, *, tolerance: float = 8.0) -> list[dict]:
|
|
76
|
+
issues = []
|
|
77
|
+
for child, out_port, parent, in_port in plan.horizontal_edges:
|
|
78
|
+
source_y = plan.positions[child][1] + geometry[child].output_ports[out_port][1]
|
|
79
|
+
target_y = plan.positions[parent][1] + geometry[parent].input_ports[in_port][1]
|
|
80
|
+
error = abs(source_y - target_y)
|
|
81
|
+
if error > tolerance:
|
|
82
|
+
issues.append({
|
|
83
|
+
"from": child, "to": parent,
|
|
84
|
+
"out_port": out_port, "in_port": in_port,
|
|
85
|
+
"error": round(error, 3),
|
|
86
|
+
})
|
|
87
|
+
return issues
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def branch_side_balance(plan) -> list[dict]:
|
|
91
|
+
result = []
|
|
92
|
+
for parent, children in plan.children.items():
|
|
93
|
+
if not children:
|
|
94
|
+
continue
|
|
95
|
+
spine = plan.spine_child[parent]
|
|
96
|
+
index = children.index(spine)
|
|
97
|
+
result.append({
|
|
98
|
+
"node_id": parent,
|
|
99
|
+
"spine_child": spine,
|
|
100
|
+
"upper_count": index,
|
|
101
|
+
"lower_count": len(children) - index - 1,
|
|
102
|
+
"count_difference": abs(index - (len(children) - index - 1)),
|
|
103
|
+
})
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def horizontal_edge_ratio(graph, plan, geometry, *, tolerance: float = 8.0) -> dict:
|
|
108
|
+
total = horizontal = 0
|
|
109
|
+
for child, parent in plan.primary_parent.items():
|
|
110
|
+
wire = _primary_wire(graph, child, parent)
|
|
111
|
+
source_y, target_y = _edge_y(graph, plan, geometry, child, parent, wire=wire)
|
|
112
|
+
total += 1
|
|
113
|
+
horizontal += abs(source_y - target_y) <= tolerance
|
|
114
|
+
return {
|
|
115
|
+
"horizontal": horizontal,
|
|
116
|
+
"total": total,
|
|
117
|
+
"ratio": 1.0 if total == 0 else round(horizontal / total, 4),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def stage_gap_violations(graph, plan, geometry, *, minimum: float = 64.0, maximum: float = 160.0) -> list[dict]:
|
|
122
|
+
issues = []
|
|
123
|
+
for child, parent in plan.primary_parent.items():
|
|
124
|
+
source_right = plan.positions[child][0] + geometry[child].width
|
|
125
|
+
target_left = plan.positions[parent][0]
|
|
126
|
+
gap = target_left - source_right
|
|
127
|
+
if gap < minimum or gap > maximum:
|
|
128
|
+
issues.append({
|
|
129
|
+
"from": child, "to": parent, "gap": round(gap, 3),
|
|
130
|
+
"minimum": minimum, "maximum": maximum,
|
|
131
|
+
})
|
|
132
|
+
return issues
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sibling_order_violations(graph, plan) -> list[dict]:
|
|
136
|
+
wire_port = _wire_ports(graph)
|
|
137
|
+
issues = []
|
|
138
|
+
for parent, children in plan.children.items():
|
|
139
|
+
expected = sorted(children, key=lambda child: (port_key(wire_port[(child, parent)]), id_key(child)))
|
|
140
|
+
actual = sorted(children, key=lambda child: plan.subtree_bounds[child][1])
|
|
141
|
+
if expected != actual:
|
|
142
|
+
issues.append({"parent_id": parent, "expected": expected, "actual": actual})
|
|
143
|
+
return issues
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def wire_through_nodes(paths, rects) -> list[dict]:
|
|
147
|
+
issues = []
|
|
148
|
+
grid = {}
|
|
149
|
+
for node_id, rect in rects.items():
|
|
150
|
+
for cell in _rect_cells(rect):
|
|
151
|
+
grid.setdefault(cell, set()).add(node_id)
|
|
152
|
+
for wire, path in paths:
|
|
153
|
+
hit = set()
|
|
154
|
+
for left, right in zip(path, path[1:]):
|
|
155
|
+
segment_box = (min(left[0], right[0]), min(left[1], right[1]),
|
|
156
|
+
max(left[0], right[0]), max(left[1], right[1]))
|
|
157
|
+
candidates = set()
|
|
158
|
+
for cell in _rect_cells(segment_box):
|
|
159
|
+
candidates.update(grid.get(cell, ()))
|
|
160
|
+
for node_id in candidates - hit - {wire.out_node, wire.in_node}:
|
|
161
|
+
if segment_hits_rect(left, right, rects[node_id]):
|
|
162
|
+
hit.add(node_id)
|
|
163
|
+
issues.extend({"wire": wire_key(wire), "node_id": node_id} for node_id in sorted(hit, key=id_key))
|
|
164
|
+
return issues
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def wire_through_comment_titles(graph, paths) -> list[dict]:
|
|
168
|
+
issues = []
|
|
169
|
+
groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
|
|
170
|
+
related: dict[str, set[str]] = {}
|
|
171
|
+
|
|
172
|
+
def members(group_id):
|
|
173
|
+
if group_id not in related:
|
|
174
|
+
values = set(groups[group_id]["members"])
|
|
175
|
+
for member_id in tuple(values):
|
|
176
|
+
if member_id in groups:
|
|
177
|
+
values.update(members(member_id))
|
|
178
|
+
related[group_id] = values
|
|
179
|
+
return related[group_id]
|
|
180
|
+
|
|
181
|
+
for group in groups.values():
|
|
182
|
+
x, y = group["position"]["x"], group["position"]["y"]
|
|
183
|
+
title_rect = (x, y, x + group["width"], y + 48.0)
|
|
184
|
+
for wire, path in paths:
|
|
185
|
+
if {wire.out_node, wire.in_node} & members(group["node_id"]):
|
|
186
|
+
continue
|
|
187
|
+
if _bounds_intersect(_path_bounds(path), title_rect) and path_hits_rect(path, title_rect):
|
|
188
|
+
issues.append({"wire": wire_key(wire), "comment_id": group["node_id"], "area": "title"})
|
|
189
|
+
return issues
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def wire_crossings(paths) -> list[list[str]]:
|
|
193
|
+
issues = []
|
|
194
|
+
boxed = [(wire, path, _path_bounds(path)) for wire, path in paths]
|
|
195
|
+
for index, (left_wire, left_path, left_box) in enumerate(boxed):
|
|
196
|
+
left_nodes = {left_wire.out_node, left_wire.in_node}
|
|
197
|
+
for right_wire, right_path, right_box in boxed[index + 1:]:
|
|
198
|
+
if (not left_nodes & {right_wire.out_node, right_wire.in_node}
|
|
199
|
+
and _bounds_intersect(left_box, right_box)
|
|
200
|
+
and paths_cross(left_path, right_path)):
|
|
201
|
+
issues.append([wire_key(left_wire), wire_key(right_wire)])
|
|
202
|
+
return issues
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _wire_ports(graph):
|
|
206
|
+
result = {}
|
|
207
|
+
for wire in graph.wires:
|
|
208
|
+
key = (wire.out_node, wire.in_node)
|
|
209
|
+
if key not in result or port_key(wire.in_port) < port_key(result[key]):
|
|
210
|
+
result[key] = wire.in_port
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _primary_wire(graph, child, parent):
|
|
215
|
+
choices = [
|
|
216
|
+
item.wire for item in logical_wires(graph)
|
|
217
|
+
if item.wire.out_node == child and item.wire.in_node == parent
|
|
218
|
+
]
|
|
219
|
+
if not choices:
|
|
220
|
+
raise ValueError(f"primary layout edge {child}->{parent} is missing")
|
|
221
|
+
return min(choices, key=lambda wire: (port_key(wire.in_port), port_key(wire.out_port)))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _edge_y(graph, plan, geometry, child, parent, *, wire=None):
|
|
225
|
+
wire = wire or _primary_wire(graph, child, parent)
|
|
226
|
+
return (
|
|
227
|
+
plan.positions[child][1] + geometry[child].output_ports[wire.out_port][1],
|
|
228
|
+
plan.positions[parent][1] + geometry[parent].input_ports[wire.in_port][1],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def wire_key(wire) -> str:
|
|
233
|
+
return f"{wire.out_node}:{wire.out_port}->{wire.in_node}:{wire.in_port}"
|
|
234
|
+
def _path_bounds(path):
|
|
235
|
+
return (
|
|
236
|
+
min(point[0] for point in path), min(point[1] for point in path),
|
|
237
|
+
max(point[0] for point in path), max(point[1] for point in path),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _bounds_intersect(left, right):
|
|
242
|
+
return not (left[2] < right[0] or right[2] < left[0] or left[3] < right[1] or right[3] < left[1])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _rect_cells(rect):
|
|
246
|
+
left, top, right, bottom = (int(value // GRID_SIZE) for value in rect)
|
|
247
|
+
return ((x, y) for x in range(left, right + 1) for y in range(top, bottom + 1))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Repeated-subtree consistency diagnostics for meticulous layout."""
|
|
2
|
+
|
|
3
|
+
TEMPLATE_TOLERANCE = 8.0
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def repeated_module_mismatches(graph, plan) -> list[dict]:
|
|
7
|
+
types = {node.node_id: node.type_name for node in graph.nodes}
|
|
8
|
+
signatures: dict[tuple, list[str]] = {}
|
|
9
|
+
for parent, children in plan.children.items():
|
|
10
|
+
if children:
|
|
11
|
+
signature = _tree_signature(parent, plan, types)
|
|
12
|
+
signatures.setdefault(signature, []).append(parent)
|
|
13
|
+
issues = []
|
|
14
|
+
for parents in signatures.values():
|
|
15
|
+
if len(parents) < 2:
|
|
16
|
+
continue
|
|
17
|
+
baseline = _relative_profile(parents[0], plan)
|
|
18
|
+
for parent in parents[1:]:
|
|
19
|
+
profile = _relative_profile(parent, plan)
|
|
20
|
+
if len(profile) != len(baseline) or any(
|
|
21
|
+
abs(a - b) > TEMPLATE_TOLERANCE for a, b in zip(profile, baseline)
|
|
22
|
+
):
|
|
23
|
+
issues.append({"baseline": parents[0], "node_id": parent})
|
|
24
|
+
return issues
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _relative_profile(parent, plan) -> tuple[float, ...]:
|
|
28
|
+
origin_x, origin_y = plan.positions[parent]
|
|
29
|
+
values = []
|
|
30
|
+
stack = [parent]
|
|
31
|
+
while stack:
|
|
32
|
+
node_id = stack.pop()
|
|
33
|
+
if node_id != parent:
|
|
34
|
+
x, y = plan.positions[node_id]
|
|
35
|
+
values.extend((round(x - origin_x, 3), round(y - origin_y, 3)))
|
|
36
|
+
stack.extend(reversed(plan.children[node_id]))
|
|
37
|
+
return tuple(values)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tree_signature(node_id, plan, types):
|
|
41
|
+
return (
|
|
42
|
+
types[node_id],
|
|
43
|
+
tuple(_tree_signature(child, plan, types) for child in plan.children[node_id]),
|
|
44
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared graph and geometry helpers for meticulous layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
from .commentary import inspect_comment_groups
|
|
8
|
+
from .layout import MASTER_TYPES
|
|
9
|
+
from .model import AseGraph, WireLine
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def graph_depths(ids: set[str], outgoing: Mapping[str, list[WireLine]]) -> dict[str, int]:
|
|
13
|
+
memo: dict[str, int] = {}
|
|
14
|
+
def visit(node_id: str) -> int:
|
|
15
|
+
if node_id not in memo:
|
|
16
|
+
consumers = [wire.in_node for wire in outgoing.get(node_id, [])]
|
|
17
|
+
memo[node_id] = 0 if not consumers else 1 + max(visit(item) for item in consumers)
|
|
18
|
+
return memo[node_id]
|
|
19
|
+
for node_id in sorted(ids, key=id_key):
|
|
20
|
+
visit(node_id)
|
|
21
|
+
return memo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def reject_cycles(ids: set[str], outgoing: Mapping[str, list[WireLine]]) -> None:
|
|
25
|
+
state: dict[str, int] = {}
|
|
26
|
+
def visit(node_id: str) -> None:
|
|
27
|
+
if state.get(node_id) == 1:
|
|
28
|
+
raise ValueError(f"data-flow cycle detected at node {node_id}")
|
|
29
|
+
if state.get(node_id) == 2:
|
|
30
|
+
return
|
|
31
|
+
state[node_id] = 1
|
|
32
|
+
for wire in outgoing.get(node_id, []):
|
|
33
|
+
visit(wire.in_node)
|
|
34
|
+
state[node_id] = 2
|
|
35
|
+
for node_id in sorted(ids, key=id_key):
|
|
36
|
+
visit(node_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_connected_ports(graph: AseGraph, geometry, ids: set[str]) -> None:
|
|
40
|
+
for wire in graph.wires:
|
|
41
|
+
if wire.out_node not in ids or wire.in_node not in ids:
|
|
42
|
+
continue
|
|
43
|
+
if wire.out_port not in geometry[wire.out_node].output_ports:
|
|
44
|
+
raise ValueError(f"editor geometry missing output port {wire.out_node}:{wire.out_port}")
|
|
45
|
+
if wire.in_port not in geometry[wire.in_node].input_ports:
|
|
46
|
+
raise ValueError(f"editor geometry missing input port {wire.in_node}:{wire.in_port}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def immediate_comment_owners(graph: AseGraph) -> dict[str, str]:
|
|
50
|
+
return {member: group["node_id"] for group in inspect_comment_groups(graph) for member in group["members"]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sibling_gap(left: str, right: str, owners: Mapping[str, str], normal: float, module: float) -> float:
|
|
54
|
+
crosses_group = owners.get(left) != owners.get(right) and (owners.get(left) or owners.get(right))
|
|
55
|
+
return module if crosses_group else normal
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def alignment_offset(node_id, branch, primary_wire, geometry) -> float:
|
|
59
|
+
offsets = [geometry[node_id].input_ports[primary_wire[(child, node_id)].in_port][1] for child in branch]
|
|
60
|
+
return (min(offsets) + max(offsets)) / 2.0 if offsets else geometry[node_id].height / 2.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def descendants(node_id: str, children: Mapping[str, tuple[str, ...]]) -> set[str]:
|
|
64
|
+
result, stack = {node_id}, list(children[node_id])
|
|
65
|
+
while stack:
|
|
66
|
+
child = stack.pop()
|
|
67
|
+
if child not in result:
|
|
68
|
+
result.add(child)
|
|
69
|
+
stack.extend(children[child])
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def node_rect(node_id: str, positions, geometry) -> tuple[float, float, float, float]:
|
|
74
|
+
x, y = positions[node_id]
|
|
75
|
+
return x, y, x + geometry[node_id].width, y + geometry[node_id].height
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def overlaps_with_gap(left, right, gap: float) -> bool:
|
|
79
|
+
return not (left[2] + gap <= right[0] or right[2] + gap <= left[0]
|
|
80
|
+
or left[3] + gap <= right[1] or right[3] + gap <= left[1])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def node_position(node) -> tuple[float, float]:
|
|
84
|
+
if node is None:
|
|
85
|
+
raise ValueError("layout node not found")
|
|
86
|
+
try:
|
|
87
|
+
return tuple(float(value) for value in node.raw_fields[3].split(",")) # type: ignore[return-value]
|
|
88
|
+
except (IndexError, ValueError) as exc:
|
|
89
|
+
raise ValueError(f"node {node.node_id} has invalid x,y position") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_master(graph: AseGraph, node_id: str) -> bool:
|
|
93
|
+
node = graph.node_by_id(node_id)
|
|
94
|
+
return node is not None and node.type_name in MASTER_TYPES
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_incoming(graph: AseGraph, node_id: str) -> bool:
|
|
98
|
+
return any(wire.in_node == node_id for wire in graph.wires)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def port_key(value: str) -> tuple[int, float | str]:
|
|
102
|
+
try:
|
|
103
|
+
return 0, float(value)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return 1, value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def id_key(value: str) -> tuple[int, int | str]:
|
|
109
|
+
return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def round_coord(value: float) -> float:
|
|
113
|
+
return round(value, 1)
|