stdraw 0.7.13__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.
stdraw/metrics.py ADDED
@@ -0,0 +1,1225 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Quality certificate (this project's moat) — five metrics + placement metrics + honest grading.
3
+
4
+ All verification runs on the **delivered coordinates** (the rounded/exported
5
+ copy); this is discipline, not an optimization option.
6
+ Metric implementations: seg_cross / Liang-Barsky seg_hits_rect / rect_gap /
7
+ row alignment rate.
8
+ Plus the first-priority placement metric: spacing coefficient of variation (CV).
9
+
10
+ Honest grading (never masquerade as a finished product):
11
+ best = placement requirements met + 0 line crossings
12
+ second = placement requirements met + 1-2 crossings (certificate records
13
+ crossing coordinates for human review)
14
+ uncertified = everything else (stated honestly, never downgraded to fake it)
15
+ The 3D layer is out of scope for this module (3D metrics belong to M2; the M0
16
+ 3D demo only reports placement and edge lengths).
17
+ """
18
+ import json
19
+ import math
20
+ from typing import Dict, List, Optional
21
+
22
+ EPS = 1e-9
23
+ ROW_EPS = 4 # visual same-row test: y difference <= 4px counts as one row
24
+ INFLATE = 4 # card-piercing caliber: card box outward inflation px
25
+ # (a softened substitute for the 16px inflation caliber)
26
+
27
+
28
+ def seg_cross(p1, p2, p3, p4):
29
+ """Strict intersection test (incl. collinear overlap; endpoint touch excluded)."""
30
+ def orient(a, b, c):
31
+ v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
32
+ return 0 if abs(v) < EPS else (1 if v > 0 else -1)
33
+ d1, d2 = orient(p3, p4, p1), orient(p3, p4, p2)
34
+ d3, d4 = orient(p1, p2, p3), orient(p1, p2, p4)
35
+ if d1 != d2 and d3 != d4 and d1 != 0 and d3 != 0:
36
+ return True
37
+ if d1 == 0 and d2 == 0 and d3 == 0 and d4 == 0:
38
+ def on(a, b, c):
39
+ return (min(a[0], b[0]) - EPS <= c[0] <= max(a[0], b[0]) + EPS and
40
+ min(a[1], b[1]) - EPS <= c[1] <= max(a[1], b[1]) + EPS)
41
+ if on(p3, p4, p1) or on(p3, p4, p2) or on(p1, p2, p3) or on(p1, p2, p4):
42
+ near = [q for q in (p3, p4) for p in (p1, p2)
43
+ if abs(q[0] - p[0]) < EPS and abs(q[1] - p[1]) < EPS]
44
+ if len(near) >= 1:
45
+ return False
46
+ return True
47
+ return False
48
+
49
+
50
+ def seg_hits_rect(x1, y1, x2, y2, cx, cy, hw, hh):
51
+ """Liang-Barsky: whether a segment crosses a rectangle (positive-length
52
+ entry counts; merely touching the border does not)."""
53
+ dx, dy = x2 - x1, y2 - y1
54
+ t0, t1 = 0.0, 1.0
55
+ for p, q in ((-dx, x1 - (cx - hw)), (dx, (cx + hw) - x1),
56
+ (-dy, y1 - (cy - hh)), (dy, (cy + hh) - y1)):
57
+ if abs(p) < EPS:
58
+ if q < 0:
59
+ return False
60
+ else:
61
+ r = q / p
62
+ if p < 0:
63
+ if r > t1:
64
+ return False
65
+ if r > t0:
66
+ t0 = r
67
+ else:
68
+ if r < t0:
69
+ return False
70
+ if r < t1:
71
+ t1 = r
72
+ return t1 - t0 > EPS
73
+
74
+
75
+ def rect_gap(x1, y1, w1, h1, x2, y2, w2, h2):
76
+ """Rectangular gap between two cards (hypot of components; 0 when overlapping)."""
77
+ dx = abs(x1 - x2) - (w1 + w2) / 2
78
+ dy = abs(y1 - y2) - (h1 + h2) / 2
79
+ return math.hypot(max(dx, 0.0), max(dy, 0.0))
80
+
81
+
82
+ def scene_from_planar(v4: dict, card_h: int = None) -> dict:
83
+ """Planar layout JSON -> generic scene (segments = vertical lines, y is row center)."""
84
+ nodes = {}
85
+ ch = card_h or v4["meta"].get("cardH", 42)
86
+ for v, p in v4["nodes"].items():
87
+ nodes[v] = {"x": p["x"], "y": p["y"], "w": p.get("w", 140), "h": ch}
88
+ edges = [{"a": e["a"], "b": e["b"]} for e in v4["edges"]]
89
+ segs = {}
90
+ for i, e in enumerate(v4["edges"]):
91
+ segs[i] = ((e["x"], e["y1"]), (e["x"], e["y2"]))
92
+ return {"nodes": nodes, "edges": edges, "segments": segs}
93
+
94
+
95
+ def scene_from_grid(nodes: dict, edges: list, card_h: int) -> dict:
96
+ """Center-coordinate graph (viewer grid/ring forms) -> generic scene
97
+ (segments = center-to-center lines)."""
98
+ segs = {}
99
+ for i, e in enumerate(edges):
100
+ a, b = nodes[e["a"]], nodes[e["b"]]
101
+ segs[i] = ((a["x"], a["y"]), (b["x"], b["y"]))
102
+ return {"nodes": {v: dict(p, h=card_h) for v, p in nodes.items()},
103
+ "edges": edges, "segments": segs}
104
+
105
+
106
+ def certificate(scene: dict, graph_name: str = "", layer: str = "2D",
107
+ gates: Optional[dict] = None,
108
+ notes: Optional[List[str]] = None,
109
+ interactive: str = "no", drag_layer: str = "no") -> dict:
110
+ nodes: Dict[str, dict] = scene["nodes"]
111
+ edges: List[dict] = scene["edges"]
112
+ segs: dict = scene["segments"]
113
+ ids = list(nodes)
114
+ notes = list(notes or [])
115
+
116
+ # 1. Straight-line crossings
117
+ crossings = []
118
+ for i in range(len(edges)):
119
+ for j in range(i + 1, len(edges)):
120
+ ei, ej = edges[i], edges[j]
121
+ if ei["a"] in (ej["a"], ej["b"]) or ei["b"] in (ej["a"], ej["b"]):
122
+ continue
123
+ si, sj = segs[i], segs[j]
124
+ if seg_cross(si[0], si[1], sj[0], sj[1]):
125
+ crossings.append({"edges": [f"{ei['a']}->{ei['b']}",
126
+ f"{ej['a']}->{ej['b']}"],
127
+ "at": si[1] if si[0] == sj[0] and si[1] == sj[1]
128
+ else _interpolate(si, sj)})
129
+
130
+ # 2. Line piercing unrelated cards (Liang-Barsky): INFLATE caliber +
131
+ # true 0px piercing caliber
132
+ occ_inflated, occ_true = [], []
133
+ for i in range(len(edges)):
134
+ s = segs[i]
135
+ for v in ids:
136
+ if v in (edges[i]["a"], edges[i]["b"]):
137
+ continue
138
+ p = nodes[v]
139
+ if seg_hits_rect(s[0][0], s[0][1], s[1][0], s[1][1],
140
+ p["x"], p["y"], p["w"] / 2 + INFLATE, p["h"] / 2 + INFLATE):
141
+ occ_inflated.append({"edge": f"{edges[i]['a']}->{edges[i]['b']}", "card": v})
142
+ if seg_hits_rect(s[0][0], s[0][1], s[1][0], s[1][1],
143
+ p["x"], p["y"], p["w"] / 2, p["h"] / 2):
144
+ occ_true.append({"edge": f"{edges[i]['a']}->{edges[i]['b']}", "card": v})
145
+
146
+ # 3. Average edge length (center distance)
147
+ avg_len = (sum(math.dist(segs[i][0], segs[i][1]) for i in segs) / len(segs)) if segs else 0.0
148
+
149
+ # 4. Row alignment rate (visual same row: grouped counting with y diff <= ROW_EPS)
150
+ ysorted = sorted(p["y"] for p in nodes.values())
151
+ aligned, i = 0, 0
152
+ while i < len(ysorted):
153
+ j = i
154
+ while j + 1 < len(ysorted) and ysorted[j + 1] - ysorted[j] <= ROW_EPS:
155
+ j += 1
156
+ if j > i:
157
+ aligned += j - i + 1
158
+ i = j + 1
159
+ row_rate = aligned / len(ids) if ids else 0.0
160
+
161
+ # 5. Card gap (minimum over all pairs)
162
+ gaps = []
163
+ for a in range(len(ids)):
164
+ for b in range(a + 1, len(ids)):
165
+ va, vb = nodes[ids[a]], nodes[ids[b]]
166
+ gaps.append(rect_gap(va["x"], va["y"], va["w"], va["h"],
167
+ vb["x"], vb["y"], vb["w"], vb["h"]))
168
+ min_gap = min(gaps) if gaps else 0.0
169
+
170
+ # Placement first priority: spacing CV (std/mean of nearest-neighbor
171
+ # center distances)
172
+ nn = []
173
+ for a in ids:
174
+ dists = [math.dist((nodes[a]["x"], nodes[a]["y"]),
175
+ (nodes[b]["x"], nodes[b]["y"]))
176
+ for b in ids if b != a]
177
+ if dists:
178
+ nn.append(min(dists))
179
+ mean_nn = sum(nn) / len(nn) if nn else 0.0
180
+ cv = (math.sqrt(sum((d - mean_nn) ** 2 for d in nn) / len(nn)) / mean_nn) if nn and mean_nn else 0.0
181
+
182
+ W = max(p["x"] + p["w"] / 2 for p in nodes.values()) - min(p["x"] - p["w"] / 2 for p in nodes.values())
183
+ H = max(p["y"] + p["h"] / 2 for p in nodes.values()) - min(p["y"] - p["h"] / 2 for p in nodes.values())
184
+
185
+ metrics = {
186
+ "crossings": len(crossings),
187
+ "crossing_details": crossings[:10],
188
+ "occlusions_inflated4px": len(occ_inflated),
189
+ "occlusions_true_0px": len(occ_true),
190
+ "occlusion_details_true": occ_true[:10],
191
+ "avg_edge_length": round(avg_len, 1),
192
+ "row_align_rate": round(row_rate, 3),
193
+ "min_card_gap": round(min_gap, 1),
194
+ "spacing_cv": round(cv, 3),
195
+ "bbox": [round(W), round(H)],
196
+ }
197
+
198
+ g = {
199
+ "crossings_zero": metrics["crossings"] == 0,
200
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
201
+ "row_align_ge_060": metrics["row_align_rate"] >= 0.60,
202
+ "cv_le_035": metrics["spacing_cv"] <= 0.35,
203
+ }
204
+ if gates:
205
+ for k, v in gates.items():
206
+ g[k] = v
207
+ g_pass = sum(1 for v in g.values() if v)
208
+
209
+ # Honest grading
210
+ arrangement_ok = g["min_gap_ge_16"] and g["cv_le_035"]
211
+ if metrics["crossings"] == 0 and arrangement_ok:
212
+ grade = "best"
213
+ grade_note = "placement requirements met + 0 line crossings"
214
+ elif arrangement_ok and metrics["crossings"] <= 2:
215
+ grade = "second"
216
+ grade_note = ("placement requirements met + <= 2 crossings "
217
+ "(coordinates recorded in the certificate for review); "
218
+ "on conflict, keep placement, release crossings")
219
+ else:
220
+ grade = "uncertified"
221
+ grade_note = ("placement requirements unmet or crossings > 2 — honestly "
222
+ "ungraded; a certificate bought with a relaxed budget "
223
+ "never poses as a finished product")
224
+
225
+ cert = {
226
+ "tool": "stdraw", "version": "0.1.0", "layer": layer,
227
+ "graph": graph_name,
228
+ "n": len(ids), "m": len(edges),
229
+ "coords": "delivered/rounded", # all verification runs on delivered coordinates
230
+ "metrics": metrics,
231
+ "gates": g,
232
+ "gates_passed": f"{g_pass}/{len(g)}",
233
+ "grade": grade,
234
+ "grade_note": grade_note,
235
+ "honest_notes": notes,
236
+ "no_zero_crossing_claim_for_all_graphs": (
237
+ "CLDs are often non-planar; 0 crossings has a mathematical "
238
+ "boundary. This certificate holds only for this graph and this layout"),
239
+ }
240
+ # Delivery-face confession: did this certificate ship with the
241
+ # interactive deliverable (demo-2d.html + its drag touch-up layer)?
242
+ # CLI passes yes/yes; library callers grading bare coordinates get no/no.
243
+ cert["interactive"] = interactive
244
+ cert["drag_layer"] = drag_layer
245
+ return cert
246
+
247
+
248
+ def _interpolate(si, sj):
249
+ """Approximate intersection coordinates of two segments (generic for
250
+ vertical/straight lines: solve the two line equations)."""
251
+ (x1, y1), (x2, y2) = si
252
+ (x3, y3), (x4, y4) = sj
253
+ denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
254
+ if abs(denom) < EPS:
255
+ return [round(si[1][0]), round(si[1][1])]
256
+ px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom
257
+ py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom
258
+ return [round(px), round(py)]
259
+
260
+
261
+ # ═══ (2) 2026-11-05 six-point rework: auto recipe v2 certificate (new hard gates) ═══
262
+ # v1 certificate() stays byte-compatible (ortho-v1 production behavior pin);
263
+ # auto goes through certificate_auto.
264
+
265
+ from .routing import (sample_polyline, dist_point_rect, seg_cross as _rc_seg_cross,
266
+ min_pair_dist,
267
+ PRESS_MARGIN, ANCHOR_MIN_DEG, CLEARANCE,
268
+ span_one_way_segs, COINCIDE_THR, COINCIDE_SPAN)
269
+ from .anchors import standard_anchors as _std_anchors
270
+
271
+
272
+ def scene_from_auto(v4: dict) -> dict:
273
+ """Auto layout JSON -> v2 scene (polyline/arc discretized sampling + anchors)."""
274
+ nodes = {}
275
+ ch = v4["meta"].get("cardH", 44)
276
+ for v, p in v4["nodes"].items():
277
+ nodes[v] = {"x": p["x"], "y": p["y"], "w": p.get("w", 140), "h": ch}
278
+ edges, polylines, anchors = [], {}, {}
279
+ for i, e in enumerate(v4["edges"]):
280
+ edges.append({"a": e["a"], "b": e["b"]})
281
+ polylines[i] = [tuple(p) for p in e["route"]["points"]]
282
+ anchors[i] = e["anchors"]
283
+ return {"nodes": nodes, "edges": edges, "polylines": polylines,
284
+ "anchors": anchors,
285
+ "router_pairs": v4["meta"].get("router_cross_pairs"),
286
+ # t_51392037 finalize: dense-snap deferral pass-through (None on
287
+ # normal graphs) -- certificate_auto annotates anchors_standard.
288
+ "anchor_snap_deferred": v4["meta"].get("anchor_snap_deferred"),
289
+ "line_style": v4["meta"].get("line_style", "straight"),
290
+ "deg0": set(v4["meta"].get("deg0", [])),
291
+ # ring placement caliber: certificate picks metrics per placement mode
292
+ "placement": v4["meta"].get("placement"),
293
+ "ring_nodes": (v4["meta"].get("ring") or {}).get("nodes"),
294
+ # column-chain / symmetric placement calibers
295
+ "column_spine": (v4["meta"].get("column") or {}).get("spine"),
296
+ "symmetry": v4["meta"].get("symmetry"),
297
+ # spine-wing mode certificate caliber
298
+ "spine_wing": v4["meta"].get("spine_wing"),
299
+ # comb-bilateral mode certificate caliber (v0.7.7)
300
+ "comb": v4["meta"].get("comb"),
301
+ # t_cbfca790: deterministic mode-decision audit trail (which rule
302
+ # hit, why, trial gate, legacy band exemption)
303
+ "mode_decision_trace": v4["meta"].get("mode_decision_trace"),
304
+ # line-kind statistics (arc > straight > polyline hierarchy report)
305
+ "line_kinds": [e["route"].get("kind") for e in v4["edges"]],
306
+ }
307
+
308
+
309
+ def _anchor_tangent_angle(node, anchor, direction):
310
+ """Angle (degrees) between the route direction at an anchor and the tangent
311
+ of the owning box border. Direction is the unit vector leaving the anchor."""
312
+ cx, cy, hw, hh = node["x"], node["y"], node["w"] / 2, node["h"] / 2
313
+ on_x_border = abs(abs(anchor[0] - cx) - hw) <= abs(abs(anchor[1] - cy) - hh)
314
+ tx, ty = (0.0, 1.0) if on_x_border else (1.0, 0.0)
315
+ c = abs(direction[0] * tx + direction[1] * ty)
316
+ return math.degrees(math.acos(max(0.0, min(1.0, c))))
317
+
318
+
319
+ # ── Line-line visual coincidence (machine-checkable detection) ─────────────
320
+ # Caliber constants (COINCIDE_THR=1.0 within-1px adherence / COINCIDE_SPAN=8.0
321
+ # over-8px counts as coincident, endpoint-meeting exempted) live in
322
+ # routing.py — routing penalty and certificate must share one source.
323
+
324
+ def _coincidence_span(sa, sb, step, thr=COINCIDE_THR):
325
+ """Length (px) over which two sampled polylines run in parallel within
326
+ <thr distance (max of both directions; deterministic).
327
+
328
+ Both directions delegate to routing.span_one_way_segs (same source as the
329
+ routing re-routing caliber; a copy would drift). The momentary adherence
330
+ at endpoint anchors is only ~1-2px and never crosses the 8px span gate;
331
+ true coincidence (long-span parallel adherence) is captured stably by
332
+ either direction.
333
+ """
334
+ return max(span_one_way_segs(sa, list(zip(sb[:-1], sb[1:])), step, thr),
335
+ span_one_way_segs(sb, list(zip(sa[:-1], sa[1:])), step, thr))
336
+
337
+
338
+ def _bbox_gap(pa, pb):
339
+ """Gap between the bounding boxes of two polylines (0 = intersect/overlap)."""
340
+ ax0 = min(p[0] for p in pa); ax1 = max(p[0] for p in pa)
341
+ ay0 = min(p[1] for p in pa); ay1 = max(p[1] for p in pa)
342
+ bx0 = min(p[0] for p in pb); bx1 = max(p[0] for p in pb)
343
+ by0 = min(p[1] for p in pb); by1 = max(p[1] for p in pb)
344
+ return max(0.0, max(bx0 - ax1, ax0 - bx1, by0 - ay1, ay0 - by1))
345
+
346
+
347
+ def certificate_auto(scene: dict, graph_name: str = "", layer: str = "2D",
348
+ gates: Optional[dict] = None,
349
+ notes: Optional[List[str]] = None,
350
+ graph=None, exactness: Optional[dict] = None,
351
+ interactive: str = "no", drag_layer: str = "no") -> dict:
352
+ """v2 quality certificate: five metrics + new hard gates + honest grading.
353
+ All verification runs on delivered coordinates.
354
+
355
+ New hard gates (v0.2 quality rework):
356
+ box_press_zero box press = 0 (any edge at any angle, incl. 1px
357
+ margin; endpoints only touch their own box border)
358
+ edge_box_clear_ge16 edge-box clearance >= 16px (non-endpoint boxes,
359
+ sampled minimum distance)
360
+ anchors_smooth anchor smoothness: departure direction vs border
361
+ tangent angle >= ANCHOR_MIN_DEG
362
+ regularity_simple simple graph (<=10 nodes) placement regularity:
363
+ uniform row pitch + 24px column snap >= 60%
364
+ """
365
+ nodes = scene["nodes"]
366
+ edges = scene["edges"]
367
+ polys = scene["polylines"]
368
+ anchors = scene.get("anchors", {})
369
+ ids = list(nodes)
370
+ notes = list(notes or [])
371
+ line_style = scene.get("line_style", "straight")
372
+ # Ring placement caliber: row alignment / column snap are naturally
373
+ # inapplicable to ring placement; metrics are picked per placement mode
374
+ # (ring = on-ring equal spacing CV + regular-polygon template fit error).
375
+ ring_mode = scene.get("placement") == "ring-cycle"
376
+ ring_nodes = scene.get("ring_nodes") or []
377
+ # column-chain / symmetric placement calibers
378
+ column_mode = scene.get("placement") == "column-chain"
379
+ sym_mode = scene.get("placement") == "symmetric"
380
+ # bowtie-vertex mode certificate caliber: mirror-axis error (upper/lower
381
+ # ring mirrored)
382
+ bowtie_mode = scene.get("placement") == "bowtie-vertex"
383
+ # spine-wing mode certificate caliber: spine x alignment + midline
384
+ # adherence + mirror symmetry error (outer mirror pairs)
385
+ spine_mode = scene.get("placement") == "spine-wing"
386
+ spine_wing = scene.get("spine_wing") or {}
387
+ column_spine = scene.get("column_spine") or []
388
+ symmetry = scene.get("symmetry") or {}
389
+ # comb-bilateral mode certificate caliber (v0.7.7): column axis alignment
390
+ # + constant column pitch + hub-pair mirror symmetry + all-straight fan
391
+ comb_mode = scene.get("placement") == "comb-bilateral"
392
+ comb = scene.get("comb") or {}
393
+
394
+ # 1. Crossings (pairwise polyline intersection)
395
+ crossings = []
396
+ for i in range(len(edges)):
397
+ for j in range(i + 1, len(edges)):
398
+ ei, ej = edges[i], edges[j]
399
+ if ei["a"] in (ej["a"], ej["b"]) or ei["b"] in (ej["a"], ej["b"]):
400
+ continue
401
+ for s1 in zip(polys[i][:-1], polys[i][1:]):
402
+ for s2 in zip(polys[j][:-1], polys[j][1:]):
403
+ if _rc_seg_cross(s1[0], s1[1], s2[0], s2[1]):
404
+ crossings.append({"edges": [f"{ei['a']}->{ei['b']}",
405
+ f"{ej['a']}->{ej['b']}"]})
406
+ break
407
+ else:
408
+ continue
409
+ break
410
+
411
+ # 1a. Visual crossings (reported metric, NOT a hard gate): pairwise
412
+ # polyline intersection over ALL delivered edge pairs, INCLUDING pairs
413
+ # sharing an endpoint (fan pairs). The graph-theory caliber above exempts
414
+ # shared-endpoint pairs, which misses fan lines visibly crossing mid-air
415
+ # under the same box (t_c135a6e3 blind spot: classic-cld showed an X under
416
+ # the adopter box while crossings=0). Repo principle: honest grading —
417
+ # never masquerade as zero crossings. Strict seg_cross already excludes
418
+ # mere endpoint touch, so edges that only meet at their anchors add
419
+ # nothing here; only real mid-air intersections count.
420
+ visual_crossings = []
421
+ for i in range(len(edges)):
422
+ for j in range(i + 1, len(edges)):
423
+ ei, ej = edges[i], edges[j]
424
+ shared = ({ei["a"], ei["b"]} & {ej["a"], ej["b"]})
425
+ hit = None
426
+ for s1 in zip(polys[i][:-1], polys[i][1:]):
427
+ for s2 in zip(polys[j][:-1], polys[j][1:]):
428
+ if _rc_seg_cross(s1[0], s1[1], s2[0], s2[1]):
429
+ hit = (s1, s2)
430
+ break
431
+ else:
432
+ continue
433
+ break
434
+ if hit is not None:
435
+ visual_crossings.append({
436
+ "edges": [f"{ei['a']}->{ei['b']}",
437
+ f"{ej['a']}->{ej['b']}"],
438
+ "at": _interpolate(hit[0], hit[1]),
439
+ "fan_pair": bool(shared)})
440
+
441
+ # 1b. Mutual-pair separation (F1): minimum two-line distance for
442
+ # bidirectional edge pairs (counted per "edge pair"; None when no mutual pair)
443
+ pair_infos = []
444
+ for i in range(len(edges)):
445
+ for j in range(i + 1, len(edges)):
446
+ ei, ej = edges[i], edges[j]
447
+ if (ei["a"], ei["b"]) != (ej["b"], ej["a"]):
448
+ continue
449
+ md = min_pair_dist(sample_polyline(polys[i]), sample_polyline(polys[j]))
450
+ pair_infos.append({"pair": [f"{ei['a']}<->{ei['b']}",
451
+ round(md, 1) if md is not None else None]})
452
+ mutual_min_sep = (min(p["pair"][1] for p in pair_infos if p["pair"][1] is not None)
453
+ if any(p["pair"][1] is not None for p in pair_infos) else None)
454
+
455
+ # 1c. Line-line visual coincidence pairs: any two edges (including edge
456
+ # pairs sharing a node — endpoint meeting is exempt via the 8px span gate)
457
+ # running in parallel within <1px over >8px on delivered geometry count as
458
+ # coincident. bbox gap prefilter (coincident pairs must have overlapping
459
+ # bboxes; an equivalent speedup).
460
+ coincide_pairs = []
461
+ _step = 4.0
462
+ for i in range(len(edges)):
463
+ sa = sample_polyline(polys[i])
464
+ for j in range(i + 1, len(edges)):
465
+ sb = sample_polyline(polys[j])
466
+ if _bbox_gap(sa, sb) > COINCIDE_THR:
467
+ continue
468
+ span = _coincidence_span(sa, sb, _step)
469
+ if span > COINCIDE_SPAN:
470
+ coincide_pairs.append(
471
+ {"pair": [f"{edges[i]['a']}->{edges[i]['b']}",
472
+ f"{edges[j]['a']}->{edges[j]['b']}"],
473
+ "span_px": round(span, 1)})
474
+
475
+ # 2. Box press (1px margin) + edge-box clearance + own-box endpoint compliance
476
+ press, clear_details, anchor_bad = [], [], []
477
+ anchor_standard_bad = 0 # t_51392037: 16-anchor-set membership gate
478
+ anchor_standard_details = []
479
+ min_clear = None
480
+ for i, e in enumerate(edges):
481
+ pts = sample_polyline(polys[i])
482
+ for v in ids:
483
+ p = nodes[v]
484
+ hw, hh = p["w"] / 2, p["h"] / 2
485
+ if v in (e["a"], e["b"]):
486
+ continue
487
+ dmin = min(dist_point_rect(px, py, p["x"], p["y"], hw, hh)
488
+ for (px, py) in pts)
489
+ dmin1 = min(dist_point_rect(px, py, p["x"], p["y"], hw + PRESS_MARGIN, hh + PRESS_MARGIN)
490
+ for (px, py) in pts)
491
+ # Card-piercing counting is deduped per "edge x card": the same
492
+ # (edge, card) pair is recorded once (a 0-margin hit is not
493
+ # double-recorded as a 1px-margin entry)
494
+ if dmin <= 0:
495
+ press.append({"edge": f"{e['a']}->{e['b']}", "card": v})
496
+ elif dmin1 <= 0:
497
+ press.append({"edge": f"{e['a']}->{e['b']}", "card": v, "margin": 1})
498
+ min_clear = dmin if min_clear is None else min(min_clear, dmin)
499
+ # Own box: no other sampled point may enter the box (incl. 1px margin);
500
+ # anchors must sit on the box border.
501
+ # t_51392037: standard anchors sit INSET 3px inside the border, so the
502
+ # attachment zone (samples within 6px of the endpoint, before the
503
+ # departure clears the 1px-inflated margin) is part of the attachment
504
+ # by construction and is exempt -- the same accounting as
505
+ # Router._own_press (anchor_snap gate). What this gate still catches
506
+ # is the route BODY (beyond the attachment zone) stabbing into the box.
507
+ for v, end_key in ((e["a"], "a"), (e["b"], "b")):
508
+ p = nodes[v]
509
+ hw, hh = p["w"] / 2, p["h"] / 2
510
+ a0 = anchors.get(i, {}).get(end_key)
511
+ if a0 is None or dist_point_rect(a0[0], a0[1], p["x"], p["y"], hw, hh) > 0.75:
512
+ anchor_bad.append({"edge": f"{e['a']}->{e['b']}", "end": end_key,
513
+ "why": "anchor off-border"})
514
+ continue
515
+ q0, q1 = pts[0], pts[-1]
516
+ interior = any(dist_point_rect(px, py, p["x"], p["y"], hw + PRESS_MARGIN, hh + PRESS_MARGIN) <= 0
517
+ for (px, py) in pts[1:-1]
518
+ if math.hypot(px - q0[0], py - q0[1]) > 6.0 and
519
+ math.hypot(px - q1[0], py - q1[1]) > 6.0)
520
+ if interior:
521
+ press.append({"edge": f"{e['a']}->{e['b']}", "card": v, "why": "own-box"})
522
+ # Anchor smoothness (angle recomputed independently)
523
+ poly = polys[i]
524
+ for end_key, p_idx, nxt_idx in (("a", 0, 1), ("b", len(poly) - 1, len(poly) - 2)):
525
+ a0 = anchors.get(i, {}).get(end_key)
526
+ if a0 is None:
527
+ continue
528
+ dx, dy = poly[nxt_idx][0] - a0[0], poly[nxt_idx][1] - a0[1]
529
+ n = math.hypot(dx, dy)
530
+ if n < EPS:
531
+ anchor_bad.append({"edge": f"{e['a']}->{e['b']}", "end": end_key,
532
+ "why": "zero-length departure"})
533
+ continue
534
+ v = e["a"] if end_key == "a" else e["b"]
535
+ ang = _anchor_tangent_angle(nodes[v], a0, (dx / n, dy / n))
536
+ if ang < ANCHOR_MIN_DEG:
537
+ anchor_bad.append({"edge": f"{e['a']}->{e['b']}", "end": end_key,
538
+ "angle": round(ang, 1)})
539
+ # t_51392037: endpoint membership in the 16 standard anchor set
540
+ # (anchors.py caliber; tol 0.75px = the same border tolerance as the
541
+ # anchor on-border check above). The default attachment caliber:
542
+ # every rendered endpoint sits on one of its box's 16 standard
543
+ # connection points.
544
+ for end_key in ("a", "b"):
545
+ v = e[end_key]
546
+ a0 = anchors.get(i, {}).get(end_key)
547
+ if a0 is None:
548
+ continue
549
+ nd = nodes[v]
550
+ pts16 = _std_anchors(nd["x"], nd["y"], nd["w"] / 2.0, nd["h"] / 2.0)
551
+ if not any(abs(ax - a0[0]) <= 0.75 and abs(ay - a0[1]) <= 0.75
552
+ for _aid, ax, ay in pts16):
553
+ anchor_standard_bad += 1
554
+ if len(anchor_standard_details) < 10:
555
+ anchor_standard_details.append(
556
+ {"edge": f"{e['a']}->{e['b']}", "end": end_key,
557
+ "anchor": [round(a0[0], 1), round(a0[1], 1)]})
558
+
559
+ # 3. Average edge length / row alignment / card gap / CV (same caliber as v1)
560
+ avg_len = sum(sum(math.dist(polys[i][k], polys[i][k + 1])
561
+ for k in range(len(polys[i]) - 1))
562
+ for i in polys) / len(polys) if polys else 0.0
563
+ if ring_mode:
564
+ row_rate = None # ring placement: row alignment inapplicable (caliber stated honestly)
565
+ else:
566
+ ysorted = sorted(p["y"] for p in nodes.values())
567
+ aligned, i2 = 0, 0
568
+ while i2 < len(ysorted):
569
+ j = i2
570
+ while j + 1 < len(ysorted) and ysorted[j + 1] - ysorted[j] <= ROW_EPS:
571
+ j += 1
572
+ if j > i2:
573
+ aligned += j - i2 + 1
574
+ i2 = j + 1
575
+ row_rate = aligned / len(ids) if ids else 0.0
576
+ gaps = []
577
+ for a in range(len(ids)):
578
+ for b in range(a + 1, len(ids)):
579
+ va, vb = nodes[ids[a]], nodes[ids[b]]
580
+ gaps.append(rect_gap(va["x"], va["y"], va["w"], va["h"],
581
+ vb["x"], vb["y"], vb["w"], vb["h"]))
582
+ min_gap = min(gaps) if gaps else 0.0
583
+ nn = []
584
+ for a in ids:
585
+ dists = [math.dist((nodes[a]["x"], nodes[a]["y"]),
586
+ (nodes[b]["x"], nodes[b]["y"]))
587
+ for b in ids if b != a]
588
+ if dists:
589
+ nn.append(min(dists))
590
+ mean_nn = sum(nn) / len(nn) if nn else 0.0
591
+ cv = (math.sqrt(sum((d - mean_nn) ** 2 for d in nn) / len(nn)) / mean_nn) if nn and mean_nn else 0.0
592
+
593
+ # Regularity (simple graph <=10 nodes): uniform row pitch + 24px column
594
+ # snap share (parking-lane nodes excluded from row pitch).
595
+ # Ring placement: regularity caliber switches to on-ring equal spacing CV +
596
+ # regular-polygon template fit error.
597
+ # Column placement: uniform row pitch judged on main-chain rows (branch
598
+ # half-row offsets excluded); column snap inapplicable.
599
+ # Symmetric placement: regularity caliber = mirror-axis error (left/right
600
+ # vertices equidistant to the axis + y on the same row).
601
+ deg0 = scene.get("deg0", set())
602
+ ring_spacing_cv = None
603
+ ring_template_dev = None
604
+ col_axis_dev = None
605
+ sym_mirror_err = None
606
+ sw_spine_axis_dev = None
607
+ sw_midline_dev = None
608
+ if ring_mode and len(ring_nodes) >= 3:
609
+ rpts = [(nodes[v]["x"], nodes[v]["y"]) for v in ring_nodes
610
+ if v in nodes]
611
+ if len(rpts) >= 3:
612
+ dists = [math.dist(rpts[i], rpts[(i + 1) % len(rpts)])
613
+ for i in range(len(rpts))]
614
+ md = sum(dists) / len(dists)
615
+ ring_spacing_cv = (math.sqrt(sum((d - md) ** 2 for d in dists)
616
+ / len(dists)) / md) if md else 0.0
617
+ try:
618
+ from . import regularity as _reg
619
+ ring_template_dev = _reg.cycle_polygon_deviation(
620
+ {v: (nodes[v]["x"], nodes[v]["y"]) for v in ring_nodes},
621
+ ring_nodes)
622
+ except Exception:
623
+ ring_template_dev = None
624
+ if ring_mode:
625
+ pitch_uniform, snap_rate, simple, regularity_ok = True, None, False, True
626
+ elif column_mode and len(column_spine) >= 2:
627
+ ys = [nodes[v]["y"] for v in column_spine if v in nodes]
628
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
629
+ if len(ys) > 1 else True)
630
+ axis_x = (sum(nodes[v]["x"] for v in column_spine if v in nodes) /
631
+ max(1, len([v for v in column_spine if v in nodes])))
632
+ col_axis_dev = max(abs(nodes[v]["x"] - axis_x)
633
+ for v in column_spine if v in nodes) if column_spine else None
634
+ snap_rate, simple = None, len(ids) <= 10
635
+ regularity_ok = pitch_uniform and (col_axis_dev or 0.0) <= 2.0
636
+ elif comb_mode and comb.get("column"):
637
+ # comb-bilateral: sources in one vertical column (x on-axis <=2px,
638
+ # constant column pitch), hubs in mirrored left/right slots at the
639
+ # column middle row (pair symmetric about the column axis)
640
+ col_ids = [v for v in comb["column"] if v in nodes]
641
+ axis_x = comb.get("axis_x", 0.0)
642
+ ys = [nodes[v]["y"] for v in col_ids]
643
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
644
+ if len(ys) > 1 else True)
645
+ col_axis_dev = max(abs(nodes[v]["x"] - axis_x)
646
+ for v in col_ids) if col_ids else None
647
+ hub_sym_err = None
648
+ hl, hr = comb.get("hubs") or (None, None)
649
+ if hl in nodes and hr in nodes:
650
+ hub_sym_err = max(abs((nodes[hl]["x"] + nodes[hr]["x"]) / 2 - axis_x),
651
+ abs(nodes[hl]["y"] - nodes[hr]["y"]))
652
+ snap_rate, simple = None, len(ids) <= 10
653
+ regularity_ok = (col_axis_dev is not None and col_axis_dev <= 2.0
654
+ and hub_sym_err is not None and hub_sym_err <= 2.0
655
+ and pitch_uniform)
656
+ elif sym_mode and symmetry.get("mirror_pairs"):
657
+ axis_x = (symmetry.get("axis_x") if symmetry.get("axis_x") is not None
658
+ else 0.0)
659
+ pairs = symmetry.get("mirror_pairs")
660
+ errs = []
661
+ for l, r in pairs:
662
+ if l in nodes and r in nodes:
663
+ errs.append(abs((nodes[l]["x"] + nodes[r]["x"]) / 2 - axis_x))
664
+ errs.append(abs(nodes[l]["y"] - nodes[r]["y"]))
665
+ sym_mirror_err = max(errs) if errs else None
666
+ ys = sorted({p["y"] for v, p in nodes.items() if v not in deg0})
667
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
668
+ if len(ys) > 1 else True)
669
+ snap_rate, simple = None, len(ids) <= 10
670
+ regularity_ok = (sym_mirror_err is not None and sym_mirror_err <= 2.0
671
+ and pitch_uniform)
672
+ elif spine_mode and spine_wing.get("spine"):
673
+ # spine-wing: spine x on-axis (<=2px) + wings hugging the midline band
674
+ # (<=2px) + mirror-pair error (x equal + y symmetric about the
675
+ # horizontal axis, <=2px); uniform row pitch judged on wing row pitch
676
+ # (constructed row_pitch, all-node y difference set is constant)
677
+ axis_x = (spine_wing.get("axis_x") if spine_wing.get("axis_x") is not None
678
+ else 0.0)
679
+ axis_y = (spine_wing.get("axis_y") if spine_wing.get("axis_y") is not None
680
+ else 0.0)
681
+ spine_ids = [s for s in spine_wing.get("spine") or [] if s in nodes]
682
+ wing_ids = [w for w in spine_wing.get("wings") or [] if w in nodes]
683
+ errs = []
684
+ if spine_ids:
685
+ sw_spine_axis_dev = max(abs(nodes[s]["x"] - axis_x)
686
+ for s in spine_ids)
687
+ errs.append(sw_spine_axis_dev)
688
+ if wing_ids:
689
+ sw_midline_dev = max(abs(nodes[w]["y"] - axis_y)
690
+ for w in wing_ids)
691
+ errs.append(sw_midline_dev)
692
+ for up, down in spine_wing.get("mirror_pairs") or []:
693
+ if up in nodes and down in nodes:
694
+ errs.append(abs(nodes[up]["x"] - nodes[down]["x"]))
695
+ errs.append(abs((nodes[up]["y"] + nodes[down]["y"]) / 2
696
+ - axis_y))
697
+ sym_mirror_err = max(errs) if errs else None
698
+ ys = sorted({p["y"] for v, p in nodes.items() if v not in deg0})
699
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
700
+ if len(ys) > 1 else True)
701
+ snap_rate, simple = None, len(ids) <= 10
702
+ regularity_ok = (sym_mirror_err is not None and sym_mirror_err <= 2.0
703
+ and pitch_uniform)
704
+ elif bowtie_mode and symmetry.get("mirror_pairs"):
705
+ # bowtie-vertex: upper/lower ring mirrored (equidistant to the vertical
706
+ # axis in x + symmetric about the horizontal axis in y), shared node centered
707
+ axis_x = (symmetry.get("axis_x") if symmetry.get("axis_x") is not None
708
+ else 0.0)
709
+ axis_y = (symmetry.get("axis_y") if symmetry.get("axis_y") is not None
710
+ else 0.0)
711
+ pairs = symmetry.get("mirror_pairs")
712
+ errs = []
713
+ for l, r in pairs:
714
+ if l in nodes and r in nodes:
715
+ errs.append(abs(nodes[l]["x"] - nodes[r]["x"]))
716
+ errs.append(abs((nodes[l]["y"] + nodes[r]["y"]) / 2 - axis_y))
717
+ sh = symmetry.get("shared_node")
718
+ if sh in nodes:
719
+ errs.append(abs(nodes[sh]["x"] - axis_x))
720
+ errs.append(abs(nodes[sh]["y"] - axis_y))
721
+ sym_mirror_err = max(errs) if errs else None
722
+ ys = sorted({p["y"] for v, p in nodes.items() if v not in deg0})
723
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
724
+ if len(ys) > 1 else True)
725
+ snap_rate, simple = None, len(ids) <= 10
726
+ regularity_ok = (sym_mirror_err is not None and sym_mirror_err <= 2.0
727
+ and pitch_uniform)
728
+ else:
729
+ ys = sorted({p["y"] for v, p in nodes.items() if v not in deg0})
730
+ pitch_uniform = (len(set(b - a for a, b in zip(ys, ys[1:]))) <= 1
731
+ if len(ys) > 1 else True)
732
+ snap_n = sum(1 for p in nodes.values() if abs(p["x"] % 24) < EPS)
733
+ snap_rate = snap_n / len(ids) if ids else 0.0
734
+ simple = len(ids) <= 10
735
+ regularity_ok = pitch_uniform and (snap_rate >= 0.6 if simple else snap_rate >= 0.0)
736
+
737
+ W = max(p["x"] + p["w"] / 2 for p in nodes.values()) - min(p["x"] - p["w"] / 2 for p in nodes.values())
738
+ H = max(p["y"] + p["h"] / 2 for p in nodes.values()) - min(p["y"] - p["h"] / 2 for p in nodes.values())
739
+
740
+ # WS3 dual crossing accounting: routing-theory caliber (crossing edge pairs
741
+ # of the final unrounded route) vs delivered-geometry recheck (item 1 of
742
+ # this section, strict intersection / Liang-Barsky). The certificate is
743
+ # issued only when both agree; disagreement -> uncertified (both values
744
+ # recorded honestly).
745
+ router_xe = scene.get("router_pairs")
746
+ routing_consistent = None
747
+ router_pairs = None
748
+ if router_xe is not None:
749
+ router_pairs = router_xe
750
+ routing_consistent = abs(router_pairs - len(crossings)) < 0.5
751
+
752
+ # Loop cohesion: each declared loop = max center distance / full-graph
753
+ # diagonal (smaller = more cohesive; large distances read as semantic
754
+ # disconnection). Center offset: horizontal offset between the node bbox
755
+ # center and the edge-inclusive geometry bbox center (a centering
756
+ # normalization report item).
757
+ loop_cohesion = None
758
+ if graph is not None:
759
+ try:
760
+ diag = math.hypot(W, H) or 1.0
761
+ loop_cohesion = {}
762
+ for lp in getattr(graph, "loops", []):
763
+ pts_l = [(nodes[v]["x"], nodes[v]["y"])
764
+ for v in lp.nodes if v in nodes]
765
+ if len(pts_l) >= 2:
766
+ spread = max(math.dist(p, q) for i, p in enumerate(pts_l)
767
+ for q in pts_l[i + 1:])
768
+ loop_cohesion[lp.id] = round(spread / diag, 4)
769
+ except Exception:
770
+ loop_cohesion = None
771
+ nodes_cx = ((max(p["x"] + p["w"] / 2 for p in nodes.values()) -
772
+ min(p["x"] - p["w"] / 2 for p in nodes.values())) / 2 +
773
+ min(p["x"] - p["w"] / 2 for p in nodes.values()))
774
+ all_cx = ((max(px for pl in polys.values() for px, _ in pl) +
775
+ min(px for pl in polys.values() for px, _ in pl)) / 2
776
+ if polys else nodes_cx)
777
+ center_offset = round(abs(nodes_cx - all_cx), 1)
778
+ line_kind_counts = {}
779
+ for k in scene.get("line_kinds") or []:
780
+ line_kind_counts[k] = line_kind_counts.get(k, 0) + 1
781
+
782
+ metrics = {
783
+ "crossings": len(crossings),
784
+ "crossing_details": crossings[:10],
785
+ "visual_crossings": len(visual_crossings),
786
+ "fan_visual_crossings": sum(1 for v in visual_crossings
787
+ if v["fan_pair"]),
788
+ "visual_crossing_details": visual_crossings[:10],
789
+ "box_press_1px": len(press),
790
+ "routing_router_pairs": router_pairs,
791
+ "press_details": press[:10],
792
+ "edge_box_min_clear": round(min_clear, 1) if min_clear is not None else None,
793
+ "anchor_violations": len(anchor_bad),
794
+ "anchor_details": anchor_bad[:10],
795
+ # t_51392037: delivered endpoints must sit on the box's 16 standard
796
+ # anchor set (anchors.py caliber) -- the default attachment caliber.
797
+ "anchor_standard_violations": anchor_standard_bad,
798
+ "anchor_standard_details": anchor_standard_details[:10],
799
+ "avg_edge_length": round(avg_len, 1),
800
+ "row_align_rate": round(row_rate, 3) if row_rate is not None else None,
801
+ "min_card_gap": round(min_gap, 1),
802
+ "spacing_cv": round(cv, 3),
803
+ "column_snap24_rate": round(snap_rate, 3) if snap_rate is not None else None,
804
+ "mutual_pair_min_sep": mutual_min_sep,
805
+ "mutual_pair_details": pair_infos[:10],
806
+ "line_line_coincident_pairs": len(coincide_pairs),
807
+ "line_line_coincident_details": coincide_pairs[:10],
808
+ "loop_cohesion_spread": loop_cohesion,
809
+ "center_offset_x": center_offset,
810
+ "line_kind_counts": line_kind_counts,
811
+ "bbox": [round(W), round(H)],
812
+ }
813
+ if ring_mode:
814
+ metrics["ring_spacing_cv"] = (round(ring_spacing_cv, 4)
815
+ if ring_spacing_cv is not None else None)
816
+ metrics["ring_template_deviation"] = (round(ring_template_dev, 4)
817
+ if ring_template_dev is not None else None)
818
+ if column_mode:
819
+ metrics["column_axis_deviation"] = (round(col_axis_dev, 2)
820
+ if col_axis_dev is not None else None)
821
+ if comb_mode:
822
+ metrics["column_axis_deviation"] = (round(col_axis_dev, 2)
823
+ if col_axis_dev is not None else None)
824
+ metrics["hub_symmetry_err"] = (round(hub_sym_err, 2)
825
+ if hub_sym_err is not None else None)
826
+ if sym_mode or bowtie_mode or spine_mode:
827
+ metrics["symmetry_mirror_err"] = (round(sym_mirror_err, 2)
828
+ if sym_mirror_err is not None else None)
829
+ if spine_mode:
830
+ metrics["spine_axis_deviation"] = (round(sw_spine_axis_dev, 2)
831
+ if sw_spine_axis_dev is not None else None)
832
+ metrics["midline_deviation"] = (round(sw_midline_dev, 2)
833
+ if sw_midline_dev is not None else None)
834
+
835
+ # Crossing caliber adjustment: crossings_zero demoted from hard gate to
836
+ # report item (<=2 crossings allowed when placement regularity holds —
837
+ # "some line intersection is acceptable; order and elegance come first");
838
+ # grading semantics unchanged (best = all hard gates + 0 crossings,
839
+ # second = all hard gates + <=2 crossings).
840
+ if ring_mode:
841
+ # Ring caliber gates: on-ring equal spacing CV + template fit error
842
+ # (within 2px quantization tolerance ~= 0)
843
+ g = {
844
+ "box_press_zero": metrics["box_press_1px"] == 0,
845
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
846
+ "anchors_smooth": metrics["anchor_violations"] == 0,
847
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
848
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
849
+ "ring_spacing_cv_le_005": (metrics["ring_spacing_cv"] is not None
850
+ and metrics["ring_spacing_cv"] <= 0.05),
851
+ "ring_template_fit_le_005": (metrics["ring_template_deviation"] is not None
852
+ and metrics["ring_template_deviation"] <= 0.05),
853
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
854
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
855
+ }
856
+ elif column_mode:
857
+ g = {
858
+ "box_press_zero": metrics["box_press_1px"] == 0,
859
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
860
+ "anchors_smooth": metrics["anchor_violations"] == 0,
861
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
862
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
863
+ "cv_le_035": metrics["spacing_cv"] <= 0.35,
864
+ "column_axis_aligned": (metrics["column_axis_deviation"] is not None
865
+ and metrics["column_axis_deviation"] <= 2.0),
866
+ "spine_pitch_uniform": pitch_uniform,
867
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
868
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
869
+ }
870
+ elif comb_mode:
871
+ # comb-bilateral caliber gates: column axis alignment + constant
872
+ # column pitch + hub-pair mirror symmetry + all hub edges straight
873
+ # (the comb contract); spacing CV is not applicable as a gate (the
874
+ # hub wings are structurally far from the column, reported only)
875
+ _hl, _hr = comb.get("hubs") or (None, None)
876
+ _hub_bad = 0
877
+ if _hl and _hr:
878
+ for _e, _k in zip(edges, scene.get("line_kinds") or []):
879
+ if (_e["a"] in (_hl, _hr) or _e["b"] in (_hl, _hr)) \
880
+ and _k != "line":
881
+ _hub_bad += 1
882
+ g = {
883
+ "box_press_zero": metrics["box_press_1px"] == 0,
884
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
885
+ "anchors_smooth": metrics["anchor_violations"] == 0,
886
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
887
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
888
+ "column_axis_aligned": (metrics["column_axis_deviation"] is not None
889
+ and metrics["column_axis_deviation"] <= 2.0),
890
+ "column_pitch_uniform": pitch_uniform,
891
+ "hub_symmetry_le_2px": (metrics["hub_symmetry_err"] is not None
892
+ and metrics["hub_symmetry_err"] <= 2.0),
893
+ "hub_edges_straight": _hub_bad == 0,
894
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
895
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
896
+ }
897
+ elif sym_mode or bowtie_mode:
898
+ g = {
899
+ "box_press_zero": metrics["box_press_1px"] == 0,
900
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
901
+ "anchors_smooth": metrics["anchor_violations"] == 0,
902
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
903
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
904
+ "cv_le_035": metrics["spacing_cv"] <= 0.35,
905
+ "symmetry_mirror_le_2px": (metrics["symmetry_mirror_err"] is not None
906
+ and metrics["symmetry_mirror_err"] <= 2.0),
907
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
908
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
909
+ }
910
+ elif spine_mode:
911
+ # spine-wing equivalent gates: the equivalent of ring CV/template fit
912
+ # for the spine-wing shape = spine x alignment <=2px + midline
913
+ # adherence <=2px + mirror-pair error <=2px (crossings is a soft-target
914
+ # report item; determinism is carried by the CLI double-run
915
+ # byte-identical gate)
916
+ g = {
917
+ "box_press_zero": metrics["box_press_1px"] == 0,
918
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
919
+ "anchors_smooth": metrics["anchor_violations"] == 0,
920
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
921
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
922
+ "cv_le_035": metrics["spacing_cv"] <= 0.35,
923
+ "spine_axis_le_2px": (metrics["spine_axis_deviation"] is not None
924
+ and metrics["spine_axis_deviation"] <= 2.0),
925
+ "midline_adherence_le_2px": (metrics["midline_deviation"] is not None
926
+ and metrics["midline_deviation"] <= 2.0),
927
+ "symmetry_mirror_le_2px": (metrics["symmetry_mirror_err"] is not None
928
+ and metrics["symmetry_mirror_err"] <= 2.0),
929
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
930
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
931
+ }
932
+ else:
933
+ g = {
934
+ "box_press_zero": metrics["box_press_1px"] == 0,
935
+ "edge_box_clear_ge_16": (metrics["edge_box_min_clear"] or 0) >= CLEARANCE,
936
+ "anchors_smooth": metrics["anchor_violations"] == 0,
937
+ "anchors_standard": metrics["anchor_standard_violations"] == 0,
938
+ "min_gap_ge_16": metrics["min_card_gap"] >= 16,
939
+ "cv_le_035": metrics["spacing_cv"] <= 0.35,
940
+ # Row alignment demoted to report item (14 hand-pulled samples show
941
+ # row_align delta ~= 0, users do not care; placement regularity is
942
+ # carried by uniform row pitch + column snap)
943
+ "pitch_uniform": pitch_uniform,
944
+ "column_snap24_ge_060": (snap_rate is None or snap_rate >= 0.60),
945
+ # F1 hard gate: mutual-pair two-line minimum distance >= 4px
946
+ # (trivially passes when no mutual pair exists)
947
+ "mutual_pairs_separated": mutual_min_sep is None or mutual_min_sep >= 4.0,
948
+ # Hard gate: line-line visual coincidence pairs = 0
949
+ "lines_no_coincidence": metrics["line_line_coincident_pairs"] == 0,
950
+ }
951
+ # t_0dedbfab: visual crossings (all-edge-pair caliber incl.
952
+ # shared-endpoint fan pairs) is a HARD gate in every placement mode --
953
+ # the 3-figure fan-X sweep (population-logistic / renewable-resource /
954
+ # shifting-the-burden) closed the last legacy nonzero counts, so the
955
+ # "awaits a legacy-number decision" report item becomes an assertion.
956
+ # hard_ok below folds it in: a drawing with a fan X can no longer
957
+ # grade best (honest grading: the certificate must see what the eye
958
+ # sees).
959
+ g["visual_crossings_zero"] = metrics["visual_crossings"] == 0
960
+ if routing_consistent is not None:
961
+ g["routing_consistent"] = routing_consistent
962
+ if gates:
963
+ for k, v in gates.items():
964
+ g[k] = v
965
+ g_pass = sum(1 for v in g.values() if v)
966
+ reports = {
967
+ "crossings_zero": metrics["crossings"] == 0,
968
+ "crossings_note": ("soft target: <=2 crossings allowed when placement "
969
+ "regularity holds (product call: some line "
970
+ "intersection is acceptable; order and elegance "
971
+ "come first); grading caliber unchanged "
972
+ "(best = all hard gates + 0 crossings)"),
973
+ "row_align_ge_060": (metrics["row_align_rate"] is None
974
+ or metrics["row_align_rate"] >= 0.60),
975
+ "row_align_note": ("report item: row alignment is no longer a "
976
+ "veto (hand-pulled samples show row_align delta ~= 0)"),
977
+ }
978
+
979
+ if ring_mode:
980
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
981
+ g["anchors_smooth"] and g["min_gap_ge_16"] and
982
+ g["ring_spacing_cv_le_005"] and g["ring_template_fit_le_005"])
983
+ elif comb_mode:
984
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
985
+ g["anchors_smooth"] and g["min_gap_ge_16"] and
986
+ g["column_axis_aligned"] and g["column_pitch_uniform"] and
987
+ g["hub_symmetry_le_2px"] and g["hub_edges_straight"])
988
+ elif column_mode:
989
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
990
+ g["anchors_smooth"] and g["min_gap_ge_16"] and
991
+ g["cv_le_035"] and g["column_axis_aligned"] and
992
+ g["spine_pitch_uniform"])
993
+ elif sym_mode or bowtie_mode:
994
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
995
+ g["anchors_smooth"] and g["min_gap_ge_16"] and
996
+ g["cv_le_035"] and g["symmetry_mirror_le_2px"])
997
+ elif spine_mode:
998
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
999
+ g["anchors_smooth"] and g["min_gap_ge_16"] and
1000
+ g["cv_le_035"] and g["spine_axis_le_2px"] and
1001
+ g["midline_adherence_le_2px"] and
1002
+ g["symmetry_mirror_le_2px"])
1003
+ else:
1004
+ hard_ok = (g["box_press_zero"] and g["edge_box_clear_ge_16"] and
1005
+ g["anchors_smooth"] and g["min_gap_ge_16"] and g["cv_le_035"] and
1006
+ g["pitch_uniform"])
1007
+ # t_51392037 finalize (jacky 2026-11-09 "C+B"): honest dense-snap
1008
+ # deferral annotation -- the anchors_standard gate value stays MEASURED
1009
+ # (False when endpoints remain off-anchor on a deferred dense graph);
1010
+ # the deferral record documents the approved downgrade with its measured
1011
+ # regressions. No gate fudging: grade reflects the deferral honestly.
1012
+ if scene.get("anchor_snap_deferred"):
1013
+ g["anchors_standard_deferred"] = scene["anchor_snap_deferred"]
1014
+ hard_ok = (hard_ok and g["anchors_standard"] # t_51392037: 16-anchor membership
1015
+ and g["visual_crossings_zero"])
1016
+ dual_ok = routing_consistent is not False # dual-accounting mismatch -> refuse the certificate
1017
+ if metrics["crossings"] == 0 and hard_ok and dual_ok:
1018
+ grade = "best"
1019
+ grade_note = ("all hard gates passed (box press 0 / clearance >=16 / "
1020
+ "anchors smooth / regularity) + 0 line crossings")
1021
+ elif hard_ok and dual_ok and metrics["crossings"] <= 2:
1022
+ grade = "second"
1023
+ grade_note = ("all hard gates passed + <= 2 crossings (details "
1024
+ "recorded in the certificate for review); on conflict, "
1025
+ "keep hard gates, release crossings")
1026
+ elif not dual_ok:
1027
+ grade = "uncertified"
1028
+ grade_note = (f"dual crossing accounting mismatch (routing theory "
1029
+ f"{router_pairs} vs geometric recheck {len(crossings)}) "
1030
+ f"— theory and geometry must agree before a certificate "
1031
+ f"is issued")
1032
+ else:
1033
+ grade = "uncertified"
1034
+ grade_note = ("hard gates not all passed or crossings > 2 — honestly "
1035
+ "ungraded; a certificate bought with a relaxed budget "
1036
+ "never poses as a finished product")
1037
+
1038
+ out = {
1039
+ "tool": "stdraw", "version": "0.7.1", "layer": layer,
1040
+ "graph": graph_name,
1041
+ "n": len(ids), "m": len(edges),
1042
+ "coords": "delivered/rounded",
1043
+ "recipe": "auto-v2",
1044
+ "line_style": line_style,
1045
+ "placement": ("ring-cycle" if ring_mode else
1046
+ "column-chain" if column_mode else
1047
+ "symmetric" if sym_mode else
1048
+ "bowtie-vertex" if bowtie_mode else
1049
+ "spine-wing" if spine_mode else
1050
+ "comb-bilateral" if comb_mode else "layered"),
1051
+ # t_cbfca790: which structural rule fired and why, trial-gate outcome,
1052
+ # and whether the registered anchor-band legacy exemption applies —
1053
+ # the trigger-template audit trail (None for scenes without the trace,
1054
+ # e.g. hand-built scenes in tests).
1055
+ "mode_decision_trace": scene.get("mode_decision_trace"),
1056
+ "metrics": metrics,
1057
+ "gates": g,
1058
+ "gates_passed": f"{g_pass}/{len(g)}",
1059
+ "reports": reports,
1060
+ "grade": grade,
1061
+ "grade_note": grade_note,
1062
+ "proven_optimal": None, # WS2 exact layer: see the exactness block
1063
+ "gap": None,
1064
+ "exactness": _exactness_block(exactness),
1065
+ "regularity": _regularity_block(graph, nodes),
1066
+ "honest_notes": notes + [
1067
+ "Hard-gate caliber: box press = 0 (1px margin), edge-box clearance >= 16px, "
1068
+ "smooth anchors, simple-graph regularity",
1069
+ "Mutual pairs (a<->b bidirectional edges): two-line minimum distance >= 4px "
1070
+ "(lens double-arc / parallel offset / outer detour; merging back into a "
1071
+ "double-arrowhead line is forbidden; card-piercing counting deduped per "
1072
+ "edge x card)",
1073
+ "Line-line visual coincidence pairs = 0 (any two lines running in parallel "
1074
+ "within <1px over >8px count as coincident; momentary meetings at endpoint "
1075
+ "anchors excluded; machine-checkable metric)",
1076
+ "Dual crossing accounting: the routing-theory per-edge hit count sum / 2 "
1077
+ "must equal the delivered-geometry recheck count; on mismatch the "
1078
+ "certificate is refused",
1079
+ "Routing math: obstacle avoidance = clearance-inflated obstacle visibility "
1080
+ "graph Dijkstra shortest path (exactly optimal on the graph; vs the true "
1081
+ "continuous-space shortest path around rounded-corner obstacles, at most "
1082
+ "(2 - pi/2) x inflation margin px more per hugging corner); smoothing = "
1083
+ "tangent arcs at bends (G1 tangent-continuous, bounded curvature); convex "
1084
+ "tangent arcs dip toward the obstacle side by r*(1/cos(theta/2) - 1), "
1085
+ "bounded by the inflation margin"],
1086
+ "no_zero_crossing_claim_for_all_graphs": (
1087
+ "CLDs are often non-planar; the crossing count is an objective-function "
1088
+ "minimization term, not a theorem guarantee. This certificate holds only "
1089
+ "for this graph and this layout"),
1090
+ }
1091
+ if ring_mode:
1092
+ out["honest_notes"] = out["honest_notes"] + [
1093
+ "Ring placement caliber: row alignment / 24px column snap are "
1094
+ "inapplicable to ring placement; the regularity gates switch to "
1095
+ "on-ring equal spacing CV <= 0.05 + regular-polygon template fit "
1096
+ "error <= 0.05; the main ring follows regular-polygon slots in ring "
1097
+ "order with chords inside the ring, and loop polarity (R/B) badges "
1098
+ "stay readable along the ring"]
1099
+ out["ring"] = {
1100
+ "nodes": list(ring_nodes),
1101
+ "spacing_cv": metrics.get("ring_spacing_cv"),
1102
+ "template_deviation": metrics.get("ring_template_deviation"),
1103
+ }
1104
+ if column_mode:
1105
+ out["honest_notes"] = out["honest_notes"] + [
1106
+ "Column placement caliber: the main chain aligns vertically in one "
1107
+ "column (column-axis deviation <= 2px gate) with a uniform "
1108
+ "main-chain row-pitch gate; branches hang on the main chain's "
1109
+ "nearest side (half-row offsets excluded from the main-chain "
1110
+ "row-pitch gate); the main-chain closing edge is constructed as a "
1111
+ "large left arc (loop cohesion / ring feel); row alignment / column "
1112
+ "snap reports inapplicable (honestly None)"]
1113
+ out["column"] = {
1114
+ "spine": list(column_spine),
1115
+ "axis_deviation": metrics.get("column_axis_deviation"),
1116
+ "loop_cohesion_spread": metrics.get("loop_cohesion_spread"),
1117
+ }
1118
+ if sym_mode:
1119
+ out["honest_notes"] = out["honest_notes"] + [
1120
+ "Symmetric placement caliber: bowtie (two triangles sharing an edge) "
1121
+ "structure made explicitly symmetric — shared-edge endpoints on the "
1122
+ "vertical axis, left/right vertices mirrored (mirror-axis error "
1123
+ "<= 2px gate); line-kind consistency hard rule: axis edges straight "
1124
+ "+ side edges all arcs (no mixing); row alignment / column snap "
1125
+ "reports inapplicable (honestly None)"]
1126
+ out["symmetry"] = {
1127
+ "mirror_pairs": symmetry.get("mirror_pairs"),
1128
+ "mirror_err": metrics.get("symmetry_mirror_err"),
1129
+ }
1130
+ if bowtie_mode:
1131
+ out["honest_notes"] = out["honest_notes"] + [
1132
+ "Bowtie-vertex caliber (aesthetics v3): two loops sharing a single "
1133
+ "node made explicitly symmetric — shared node centered, the two "
1134
+ "loops occupying the upper/lower half-planes mirrored "
1135
+ "(mirror-axis error <= 2px gate, cross-checked against the "
1136
+ "hand-pulled reference sample wage-price-spiral); detection = "
1137
+ "enumerated simple cycles (exactly 2 loops sharing exactly 1 "
1138
+ "node), declared loops are not a detection input; line-kind "
1139
+ "consistency hard rule: all straight lines (outer edges horizontal "
1140
+ "+ spokes direct, shared-side anchor edges symmetrically spread "
1141
+ "at edge midpoint +/-14px); row alignment / column snap reports "
1142
+ "inapplicable (honestly None)"]
1143
+ out["symmetry"] = {
1144
+ "shared_node": symmetry.get("shared_node"),
1145
+ "mirror_pairs": symmetry.get("mirror_pairs"),
1146
+ "mirror_err": metrics.get("symmetry_mirror_err"),
1147
+ }
1148
+ if spine_mode:
1149
+ out["honest_notes"] = out["honest_notes"] + [
1150
+ "Spine-wing caliber (v0.7.1): hub pair strictly vertical on the "
1151
+ "spine axis (x alignment <= 2px gate), wings pressed onto the "
1152
+ "midline horizontal band (adherence <= 2px gate), outer mirror "
1153
+ "pairs symmetric top-to-bottom (mirror error <= 2px gate) — "
1154
+ "cross-checked against the hand-pulled reference sample "
1155
+ "classic-cld-growth-and-burden; detection = deterministic "
1156
+ "topological metrics (hub pair with >= 3 common neighbors + "
1157
+ "pairable rest secondary clusters + main-chain length share "
1158
+ "<= 0.5), see docs/layout-modes.md; spine edges constructed as "
1159
+ "vertical straight lines, the rest through the Router; row "
1160
+ "alignment / column snap reports inapplicable (honestly None)"]
1161
+ out["spine_wing"] = {
1162
+ "spine": spine_wing.get("spine"),
1163
+ "wings": spine_wing.get("wings"),
1164
+ "mirror_pairs": spine_wing.get("mirror_pairs"),
1165
+ "rest_single": spine_wing.get("rest_single"),
1166
+ "spine_axis_deviation": metrics.get("spine_axis_deviation"),
1167
+ "midline_deviation": metrics.get("midline_deviation"),
1168
+ "mirror_err": metrics.get("symmetry_mirror_err"),
1169
+ }
1170
+ # Delivery-face confession (same caliber as certificate()):
1171
+ out["interactive"] = interactive
1172
+ out["drag_layer"] = drag_layer
1173
+ return out
1174
+
1175
+
1176
+ _EXACT_SCOPE_NOTE = (
1177
+ "proven_optimal holds only relative to the model class defined in "
1178
+ "docs/crossing-exact.md sections 1-5: the good-drawing crossing-number "
1179
+ "caliber (adjacent edges do not cross, each pair at most once, no three "
1180
+ "edges through one point), the 2-edge-connected core family after safe "
1181
+ "reduction, and the planar-subgraph x full-embedding family covered by "
1182
+ "the B&B. No optimality claim is made over the full metric set or an "
1183
+ "arbitrary drawing space.")
1184
+
1185
+
1186
+ def _exactness_block(exactness):
1187
+ """WS2 certificate exactness block: honestly marked computed=False when
1188
+ not computed."""
1189
+ if not exactness:
1190
+ return {"computed": False, "proven_optimal": None, "gap": None,
1191
+ "crossing_number": None, "lb": None, "coverage": None,
1192
+ "method": "crossing-number exactness not computed "
1193
+ "(pass --exact to compute)", "scope": _EXACT_SCOPE_NOTE}
1194
+ return {
1195
+ "computed": True,
1196
+ "proven_optimal": bool(exactness.get("proven_optimal")),
1197
+ "gap": exactness.get("gap"),
1198
+ "crossing_number": exactness.get("crossing_number"),
1199
+ "lb": exactness.get("lb"),
1200
+ "coverage": exactness.get("coverage"),
1201
+ "method": exactness.get("method"),
1202
+ "witness_digest": len(json.dumps(exactness.get("witness") or [],
1203
+ ensure_ascii=False, sort_keys=True)),
1204
+ "scope": _EXACT_SCOPE_NOTE,
1205
+ "caliber_note": (
1206
+ "crossing_number is the crossing number cr(G) of the graph itself "
1207
+ "(a lower bound over arbitrary drawings); metrics.crossings is the "
1208
+ "crossing count of this layout (the value achieved). The two "
1209
+ "calibers differ: cr(G) = 0 only guarantees a 0-crossing drawing "
1210
+ "exists, not that this layout achieves it"),
1211
+ }
1212
+
1213
+
1214
+ def _regularity_block(graph, nodes):
1215
+ """WS2 placement regularity block (structure-detection driven; definitions
1216
+ in stdraw/regularity.py and docs/crossing-exact.md section 6; all metrics
1217
+ computable and recomputable)."""
1218
+ if graph is None:
1219
+ return None
1220
+ try:
1221
+ from . import regularity
1222
+ positions = {v: (nodes[v]["x"], nodes[v]["y"]) for v in nodes}
1223
+ return regularity.regularity_report(graph, positions)
1224
+ except Exception:
1225
+ return None