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/layout_auto.py ADDED
@@ -0,0 +1,1827 @@
1
+ # -*- coding: utf-8 -*-
2
+ """auto layout layer (② objective-function rework): layering + order/position optimization + crossing count in the objective + regularity terms.
3
+
4
+ Differences from ortho-v1 (planar.py, plane-embedding construction, used to pin
5
+ production behavior):
6
+ - No planarity requirement: any CLD can be laid out, and the crossing count
7
+ is explicitly minimized as a **first-class term of the objective function**;
8
+ - Objective = crossing count (weight 60) + sum(|dx|) edge-length term
9
+ (regularity/compactness) + grid snapping (regularity);
10
+ - Placement can be refined with OR-Tools CP-SAT (`pip install stdraw[solver]`);
11
+ when not installed it degrades gracefully
12
+ to the deterministic constructive method (barycenter order + even
13
+ placement) - full functionality, quality falls back to the constructive tier;
14
+ - Routing is delegated to routing.Router (straight/arc first-class, boxes do
15
+ not press lines, 16px clearance hard gate).
16
+
17
+ Determinism: CP-SAT fixed at num_workers=1 + random_seed; the constructive
18
+ method has no RNG. Same input, same output.
19
+ """
20
+ import math
21
+ import sys
22
+ from collections import defaultdict
23
+
24
+ from . import textmetrics
25
+ from .routing import (Router, CLEARANCE, SIDE_MARGIN, PAIR_SEP_W,
26
+ ANCHOR_MIN_DEG, border_point)
27
+
28
+ # t_51392037 finalize (jacky 2026-11-09 ruling "C+B"): edge/node density at or
29
+ # above which anchor_snap runs in gate-guarded probe mode (see the snap block
30
+ # in layout()). Measured basis: random n10 corpus E/N = 1.60 (dense; n10-i05
31
+ # regresses: crossings 0->2 / coincidence / 1 endpoint unanchored), classic
32
+ # corpus <= 1.62 (classic-cld snaps clean) -- the probe keeps snapping any
33
+ # dense graph that passes the hard gates with zero regressions.
34
+ ANCHOR_SNAP_DENSE_E_OVER_N = 1.6
35
+
36
+
37
+ def _clone_route(rt):
38
+ """Deep copy of one route dict (points/ctrl/vg_corners/vg_fillets only --
39
+ the fields anchor_snap mutates; used by the dense-probe revert path)."""
40
+ c = dict(rt)
41
+ c["points"] = [list(p) for p in rt["points"]]
42
+ if rt.get("ctrl") is not None:
43
+ c["ctrl"] = list(rt["ctrl"])
44
+ if rt.get("vg_corners") is not None:
45
+ c["vg_corners"] = [list(q) for q in rt["vg_corners"]]
46
+ if rt.get("vg_fillets") is not None:
47
+ c["vg_fillets"] = [dict(f) for f in rt["vg_fillets"]]
48
+ return c
49
+
50
+
51
+ class AutoConfig:
52
+ fs_title: float = 12.5
53
+ fs_value: float = 14.0
54
+ card_min_w: int = 96
55
+ card_h: int = 44
56
+ pitch: int = 80 # row pitch: card 44 + inter-row corridor 36 (the source of clearance for detour lanes)
57
+ top: int = 92 # first-row center y (clearance from the parking strip >=18px: 28+22=50 -> 92-22=70)
58
+ park_y: int = 28 # parking-strip center y for isolated nodes
59
+ margin: int = 44
60
+ min_gap: int = 18 # card gap (headroom over the >=16 gate)
61
+ clearance: int = 16 # edge-box clearance hard gate (fed into router scoring)
62
+ grid: int = 8
63
+ snap_regular: int = 24 # regular snap for simple graphs (<=10 nodes)
64
+ # v0.7.7: extra per-side padding for the value (second) text line.
65
+ # Default None = historical behavior (pad-4 baseline) byte-for-byte;
66
+ # opt in for long ticker/price value strings via CLI --value-pad.
67
+ value_pad_extra: float = None
68
+ # v0.7.11 deterministic layout constraints (comb-bilateral; both default
69
+ # None = engine-decided order/side byte-for-byte, the deterministic
70
+ # initial-render contract is untouched):
71
+ # - column_order: explicit node-id list fixing the source-column internal
72
+ # order (top -> bottom). Hard constraint applied at component level:
73
+ # components are ordered by the best (smallest) rank of their members,
74
+ # intra-component DFS order is preserved (pendant trees stay contiguous),
75
+ # unlisted column nodes keep engine order after the listed ones.
76
+ # - hub_side: {node_id: "left"|"right"} for the comb's bilateral hubs;
77
+ # a single explicit side is enough to fix the assignment, contradicting
78
+ # specs degrade deterministically (first left wins, then first right).
79
+ column_order: list = None
80
+ hub_side: dict = None
81
+ target_w: int = 1280
82
+ target_h: int = 680
83
+ aspect_min: float = 1.5
84
+ park_y: int = 28 # parking strip for isolated nodes
85
+
86
+
87
+ def _cycle_break(nodes, edges):
88
+ """DFS cycle breaking (deterministic via declaration order). Returns (set of edges reversed to point backward, DAG edge list)."""
89
+ adj = defaultdict(list)
90
+ for i, (a, b) in enumerate(edges):
91
+ adj[a].append((b, i))
92
+ state = {}
93
+ reversed_idx = set()
94
+ def dfs(v):
95
+ state[v] = 1
96
+ for w, i in adj[v]:
97
+ if state.get(w, 0) == 0:
98
+ dfs(w)
99
+ elif state.get(w, 0) == 1:
100
+ reversed_idx.add(i) # back edge: treated as w->v during layering
101
+ state[v] = 2
102
+ for v in nodes:
103
+ if state.get(v, 0) == 0:
104
+ dfs(v)
105
+ dag = []
106
+ for i, (a, b) in enumerate(edges):
107
+ dag.append((b, a) if i in reversed_idx else (a, b))
108
+ return reversed_idx, dag
109
+
110
+
111
+ def _layers(nodes, dag):
112
+ """DAG longest-path layering + empty-layer compression."""
113
+ preds = defaultdict(list)
114
+ outs = defaultdict(list)
115
+ for a, b in dag:
116
+ preds[b].append(a)
117
+ outs[a].append(b)
118
+ layer = {}
119
+ def calc(v):
120
+ if v in layer:
121
+ return layer[v]
122
+ layer[v] = 0 # cycle guard (theoretically acyclic, just in case)
123
+ lv = 0 if not preds[v] else 1 + max(calc(u) for u in preds[v])
124
+ layer[v] = lv
125
+ return lv
126
+ for v in nodes:
127
+ calc(v)
128
+ return layer
129
+
130
+
131
+ def _has_solver():
132
+ try:
133
+ from ortools.sat.python import cp_model # noqa: F401
134
+ return True
135
+ except Exception:
136
+ return False
137
+
138
+
139
+ def _solve_positions(nodes, rows, widths, cfg: AutoConfig, linestyle):
140
+ """Placement solver: CP-SAT available -> crossing-count + edge-length + regularity objective; otherwise the constructive method.
141
+
142
+ Returns ({v: x_px}, engine, extra_meta) (box centers). y is decided by rows.
143
+ """
144
+ row_of = {v: r for r, vs in enumerate(rows) for v in vs}
145
+ # minimal total width per row -> content width
146
+ row_w = {r: sum(widths[v] for v in vs) + cfg.min_gap * (len(vs) + 1)
147
+ for r, vs in enumerate(rows)}
148
+ content_w = max(row_w.values()) + 2 * cfg.margin
149
+ max_x = int(content_w)
150
+
151
+ # edge endpoint row numbers (comparing crossings on the original y order would need real y; row numbers suffice)
152
+ einfo = []
153
+ for (a, b) in EDGES_REF[0]:
154
+ einfo.append((a, b, row_of[a], row_of[b]))
155
+
156
+ # WS5 scalability: proving CP-SAT global optimality does not scale to large graphs (after O(m^2) booleanization of crossing pairs, proof
157
+ # time explodes with n - measured on n20: 5min+ without completing the proof). Switched to a **deterministic time budget**
158
+ # (max_deterministic_time, OR-Tools' dedicated reproducibility caliber: counts operations instead of wall clock, so the same version
159
+ # and input reach the same incumbent on any machine). The budget is generous: small graphs (<=10 nodes, the entire classic domain)
160
+ # still prove OPTIMAL within budget, output byte-for-byte identical to the unbudgeted version; only the new n>10 domain is budget-bound
161
+ # (FEASIBLE labeled honestly, never passed off as optimal).
162
+ det_budget = CP_SAT_DET_BUDGET if len(nodes) > 10 else None
163
+
164
+ if _has_solver():
165
+ snap = cfg.snap_regular if len(nodes) <= 10 else cfg.grid
166
+ res, status = _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x, snap,
167
+ det_budget)
168
+ if res is None and snap != cfg.grid: # 24px snap infeasible -> fall back to the 8px grid
169
+ res, status = _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x,
170
+ cfg.grid, det_budget)
171
+ if res is not None:
172
+ extra = {}
173
+ if det_budget is not None:
174
+ # only the budgeted path (n>10) writes meta: the small-graph path keeps historical output byte-for-byte unchanged
175
+ extra = {"solver_status": status,
176
+ "solver_det_budget": det_budget}
177
+ return res, "cp-sat", extra
178
+ return _construct_positions(nodes, rows, widths, cfg), "construct", {}
179
+
180
+
181
+ CP_SAT_DET_BUDGET = 8.0 # deterministic time budget (det units; measured on n20: ~=17s wall clock per 8 det)
182
+
183
+
184
+ EDGES_REF = [()] # module-level reference slot - injected by layout(), avoids a long parameter list
185
+
186
+
187
+ def _cpsat_positions(nodes, rows, widths, cfg, einfo, max_x, grid, det_budget=None):
188
+ """CP-SAT: decision variables = box-center x; objective = crossing count + sum(|dx|); constraints = clearance/grid/bounds.
189
+
190
+ det_budget: deterministic time budget (max_deterministic_time, the
191
+ operation-count caliber) - past budget, take the current best incumbent
192
+ (FEASIBLE) and stop proving optimality. None = no budget (the historical
193
+ behavior: solve to optimality). Returns (solution or None, "optimal"/"feasible").
194
+ """
195
+ try:
196
+ from ortools.sat.python import cp_model
197
+ except Exception:
198
+ return None, "no-solver"
199
+ m = cp_model.CpModel()
200
+ row_of = {v: r for r, vs in enumerate(rows) for v in vs}
201
+ xs = {v: m.NewIntVar(cfg.margin, max_x - cfg.margin, f"x_{v}") for v in nodes}
202
+ for v in nodes:
203
+ m.AddModuloEquality(0, xs[v], grid)
204
+ # same-row clearance (center distance >= half the summed widths + gap)
205
+ for vs in rows:
206
+ for i in range(len(vs)):
207
+ for j in range(i + 1, len(vs)):
208
+ u, w = vs[i], vs[j]
209
+ need = int(math.ceil((widths[u] + widths[w]) / 2.0) + cfg.min_gap)
210
+ d = m.NewIntVar(0, max_x, "")
211
+ m.AddAbsEquality(d, xs[u] - xs[w])
212
+ m.Add(d >= need)
213
+ # crossing objective: two independent edges with interleaved row intervals -> reversed endpoint order means a crossing (conservative: close proximity not counted)
214
+ cross_vars = []
215
+ for i in range(len(einfo)):
216
+ a1, b1, r11, r12 = einfo[i]
217
+ for j in range(i + 1, len(einfo)):
218
+ a2, b2, r21, r22 = einfo[j]
219
+ if {a1, b1} & {a2, b2}:
220
+ continue
221
+ lo1, hi1 = min(r11, r12), max(r11, r12)
222
+ lo2, hi2 = min(r21, r22), max(r21, r22)
223
+ if min(hi1, hi2) - max(lo1, lo2) < 1:
224
+ continue
225
+ # compare the relative order of the two endpoints (at the lowest/highest row of each side)
226
+ top_e = a1 if r11 < r12 else b1
227
+ bot_e = b1 if r11 < r12 else a1
228
+ top_f = a2 if r21 < r22 else b2
229
+ bot_f = b2 if r21 < r22 else a2
230
+ if row_of[top_e] == row_of[top_f] or row_of[bot_e] == row_of[bot_f]:
231
+ continue
232
+ p = m.NewBoolVar("")
233
+ q = m.NewBoolVar("")
234
+ m.Add(xs[top_e] <= xs[top_f] - 4).OnlyEnforceIf(p)
235
+ m.Add(xs[top_e] >= xs[top_f]).OnlyEnforceIf(p.Not())
236
+ m.Add(xs[bot_e] <= xs[bot_f] - 4).OnlyEnforceIf(q)
237
+ m.Add(xs[bot_e] >= xs[bot_f]).OnlyEnforceIf(q.Not())
238
+ c = m.NewBoolVar("")
239
+ m.Add(p + q == 1).OnlyEnforceIf(c)
240
+ m.Add(p + q != 1).OnlyEnforceIf(c.Not())
241
+ cross_vars.append(c)
242
+ # edge-length term |dx|
243
+ lens = []
244
+ for a, b, _, _ in einfo:
245
+ d = m.NewIntVar(0, max_x, "")
246
+ m.AddAbsEquality(d, xs[a] - xs[b])
247
+ lens.append(d)
248
+ m.Minimize(60 * sum(cross_vars) + sum(lens))
249
+ solver = cp_model.CpSolver()
250
+ solver.parameters.num_workers = 1 # determinism iron rule
251
+ solver.parameters.random_seed = 42
252
+ # A wall-clock limit drifts with machine load (breaking determinism) - the budget uses only the deterministic-time caliber;
253
+ # no budget = solve to optimality (historical behavior; small graphs were fast anyway)
254
+ if det_budget is not None:
255
+ solver.parameters.max_deterministic_time = det_budget
256
+ st = solver.Solve(m)
257
+ if st == cp_model.OPTIMAL:
258
+ return {v: int(solver.Value(xs[v])) for v in nodes}, "optimal"
259
+ if st == cp_model.FEASIBLE:
260
+ return {v: int(solver.Value(xs[v])) for v in nodes}, "feasible"
261
+ return None, "no-solution"
262
+
263
+
264
+ def _construct_positions(nodes, rows, widths, cfg):
265
+ """Constructive method: barycenter order + even in-row placement + row centering (deterministic)."""
266
+ pos = {}
267
+ order = {}
268
+ for r, vs in enumerate(rows):
269
+ order[r] = sorted(vs)
270
+ # simple barycenter sweeps (3 rounds, alternating up/down)
271
+ row_of = {v: r for r, vs in enumerate(rows) for v in vs}
272
+ neigh = defaultdict(list)
273
+ for (a, b) in EDGES_REF[0]:
274
+ neigh[a].append(b)
275
+ neigh[b].append(a)
276
+ for _ in range(3):
277
+ for r in list(range(len(rows))) + list(range(len(rows) - 1, -1, -1)):
278
+ vs = rows[r]
279
+ key = {}
280
+ for v in vs:
281
+ ups = [order[row_of[u]].index(u) if u in order[row_of[u]] else 0
282
+ for u in neigh[v]]
283
+ key[v] = (sum(ups) / len(ups)) if ups else order[r].index(v)
284
+ order[r] = sorted(vs, key=lambda v: (key[v], v))
285
+ for r, vs in enumerate(rows):
286
+ xs, cursor = [], 0
287
+ for v in order[r]:
288
+ xs.append(cursor + widths[v] / 2 + cfg.min_gap)
289
+ cursor += widths[v] + cfg.min_gap * 2
290
+ total = xs[-1] + widths[vs[-1]] / 2 + cfg.min_gap
291
+ shift = (max(0, _content_w(rows, widths, cfg)) - total) / 2
292
+ for v, x in zip(vs, xs):
293
+ pos[v] = int(round((x + shift) / cfg.grid)) * cfg.grid
294
+ return pos
295
+
296
+
297
+ def _content_w(rows, widths, cfg):
298
+ return max((sum(widths[v] for v in vs) + cfg.min_gap * (len(vs) + 1))
299
+ for vs in rows) + 2 * cfg.margin
300
+
301
+
302
+ def _route_total(nodes_px, edges, cfg, hard_gates=False, quality=False):
303
+ """Routing ground-truth total cost: a weighted sum of press/clearance/crossing/coincidence/edge length (the layout layer's true objective function).
304
+
305
+ hard_gates: hard clearance/anchor penalties (1500 per violation). Default
306
+ False - the historical scoring caliber stays unchanged
307
+ (polish trajectories of the classic domain and existing passing cases stay
308
+ byte-for-byte); enabled only on the gated reorder path of routing-quality
309
+ rework. quality: the Router expands the stair candidate pool, likewise
310
+ gated path only.
311
+ t_c6ab760d breathing room (14 hand-drawn samples: min_card_gap averaged
312
+ +35.8px): adds a soft penalty targeting a 36px
313
+ card gap (penalized 8x by the shortfall), raising the gap weight to the
314
+ first tier;
315
+ no hard gate (crowding is caught by the existing clearance gate).
316
+ """
317
+ from .metrics import rect_gap
318
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch, quality=quality)
319
+ routes = router.route_all()
320
+ eset = set(edges)
321
+ total = 0.0
322
+ clr_hard = 0
323
+ ang_hard = 0
324
+ for i, info in routes.items():
325
+ a, b = edges[i]
326
+ clr = info.get("min_clear")
327
+ clr_pen = (30 * max(0.0, cfg.clearance - clr)) if clr is not None else 30 * cfg.clearance
328
+ if (b, a) in eset: # F1: tightened mutual-pair clearance, same caliber as _evaluate
329
+ clr_pen += 120 * (max(0.0, cfg.clearance - clr) if clr is not None
330
+ else cfg.clearance)
331
+ if hard_gates and clr is not None and clr < cfg.clearance - 1e-6:
332
+ clr_hard += 1
333
+ if hard_gates and min(info.get("ang_a", ANCHOR_MIN_DEG),
334
+ info.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9:
335
+ ang_hard += 1
336
+ total += (4000 * info.get("press", 0) + 40 * info.get("cross", 0) +
337
+ PAIR_SEP_W * info.get("sep_def", 0.0) +
338
+ (200.0 if info.get("kind") == "wrap" else 0.0) +
339
+ clr_pen +
340
+ 0.01 * sum(math.dist(info["points"][k], info["points"][k + 1])
341
+ for k in range(len(info["points"]) - 1)))
342
+ # CI hard-gate feedback: heavy penalty when crossing pairs > 2 (certificate gate: crossings <= 2)
343
+ total += 1500.0 * max(0, router.final_crossing_pairs(routes) - 2)
344
+ total += 1500.0 * clr_hard
345
+ total += 1500.0 * ang_hard
346
+ # t_c6ab760d breathing room: card gap >=36px target (soft penalty, first tier)
347
+ ids = list(nodes_px)
348
+ if len(ids) > 1:
349
+ mg = min(rect_gap(*nodes_px[u], *nodes_px[v])
350
+ for i2, u in enumerate(ids) for v in ids[i2 + 1:])
351
+ total += 8.0 * max(0.0, GAP_TARGET - mg)
352
+ return total, routes, router
353
+
354
+
355
+ GAP_TARGET = 36.0 # breathing room: card-gap target (jacky's 14 hand-drawn samples averaged +35.8px)
356
+
357
+
358
+ def _interleave_count(edges, order):
359
+ """Topological interleaving-pair count (a proxy for the crossing lower bound that routing cannot avoid under a fixed row assignment).
360
+
361
+ Two independent edges (no shared endpoint) whose row intervals overlap by
362
+ >=1 row and whose in-row orders are reversed at both endpoints ->
363
+ an interleaving pair. Equal in-row order (same column) is not counted.
364
+ O(m^2) pure arithmetic, deterministic.
365
+ """
366
+ pos, row_of = {}, {}
367
+ for r, vs in enumerate(order):
368
+ for k, v in enumerate(vs):
369
+ pos[v] = k
370
+ row_of[v] = r
371
+ f = 0
372
+ for i in range(len(edges)):
373
+ a1, b1 = edges[i]
374
+ for j in range(i + 1, len(edges)):
375
+ a2, b2 = edges[j]
376
+ if {a1, b1} & {a2, b2}:
377
+ continue
378
+ r11, r12 = row_of[a1], row_of[b1]
379
+ r21, r22 = row_of[a2], row_of[b2]
380
+ lo1, hi1 = min(r11, r12), max(r11, r12)
381
+ lo2, hi2 = min(r21, r22), max(r21, r22)
382
+ if min(hi1, hi2) - max(lo1, lo2) < 1:
383
+ continue
384
+ t1 = a1 if r11 < r12 else b1
385
+ q1 = b1 if r11 < r12 else a1
386
+ t2 = a2 if r21 < r22 else b2
387
+ q2 = b2 if r21 < r22 else a2
388
+ if pos[t1] == pos[t2] or pos[q1] == pos[q2]:
389
+ continue
390
+ if (pos[t1] > pos[t2]) != (pos[q1] > pos[q2]):
391
+ f += 1
392
+ return f
393
+
394
+
395
+ def _order_score(edges, order):
396
+ """Reorder objective: interleaving pairs primary, row-distance*column-distance edge length secondary (keeps things compact on ties)."""
397
+ pos, row_of = {}, {}
398
+ for r, vs in enumerate(order):
399
+ for k, v in enumerate(vs):
400
+ pos[v] = k
401
+ row_of[v] = r
402
+ lensum = 0
403
+ for a, b in edges:
404
+ lensum += abs(pos[a] - pos[b]) + abs(row_of[a] - row_of[b])
405
+ return _interleave_count(edges, order) * 1000.0 + lensum
406
+
407
+
408
+ def _reorder_rows(edges, rows):
409
+ """Deterministic (row, in-row order) local search: minimizes topological interleaving pairs (routing-quality rework).
410
+
411
+ Neighborhood = all same-row swaps + all single-node cross-row moves
412
+ (keeping >=1 node per row).
413
+ Steepest descent to a local optimum; then a deterministic perturbation
414
+ (take the best non-improving neighbor) and descend again,
415
+ REORDER_CYCLES rounds in total, tracking the global best throughout.
416
+ No RNG; same input, same output.
417
+ """
418
+ REORDER_CYCLES = 16
419
+ order = [list(vs) for vs in rows]
420
+
421
+ def neighbors(cur):
422
+ out = []
423
+ for r, vs in enumerate(cur):
424
+ for i in range(len(vs)):
425
+ for j in range(i + 1, len(vs)):
426
+ c2 = [list(v) for v in cur]
427
+ c2[r][i], c2[r][j] = c2[r][j], c2[r][i]
428
+ out.append(c2)
429
+ for r, vs in enumerate(cur):
430
+ for i, v in enumerate(vs):
431
+ for r2, vs2 in enumerate(cur):
432
+ if r2 == r or len(cur[r]) < 2:
433
+ continue
434
+ for p in range(len(vs2) + 1):
435
+ c2 = [list(v) for v in cur]
436
+ c2[r].pop(i)
437
+ c2[r2].insert(p, v)
438
+ out.append(c2)
439
+ return out
440
+
441
+ def descend(cur):
442
+ cur_sc = _order_score(edges, cur)
443
+ while True:
444
+ best, best_sc = None, cur_sc
445
+ for cand in neighbors(cur):
446
+ sc = _order_score(edges, cand)
447
+ if sc < best_sc - 1e-9:
448
+ best, best_sc = cand, sc
449
+ if best is None:
450
+ return cur, cur_sc
451
+ cur, cur_sc = best, best_sc
452
+
453
+ best, best_sc = descend(order)
454
+ cur = order
455
+ for _ in range(REORDER_CYCLES):
456
+ # deterministic perturbation: the lowest-scoring non-improving neighbor of the current solution (first one on ties)
457
+ cur_sc = _order_score(edges, cur)
458
+ best_n, best_nsc = None, None
459
+ for cand in neighbors(cur):
460
+ sc = _order_score(edges, cand)
461
+ if best_n is None or sc < best_nsc - 1e-9:
462
+ best_n, best_nsc = cand, sc
463
+ if best_n is None:
464
+ break
465
+ cur, cur_sc = descend(best_n)
466
+ if cur_sc < best_sc - 1e-9:
467
+ best, best_sc = cur, cur_sc
468
+ return best
469
+
470
+
471
+ def _repack_x(order, widths, cfg):
472
+ """Repack x in the reordered in-row order (24px snap, same caliber as construct, deterministic)."""
473
+ snap = cfg.snap_regular
474
+ pos = {}
475
+ for vs in order:
476
+ xs, cursor = [], 0
477
+ for v in vs:
478
+ xs.append(cursor + widths[v] / 2 + cfg.min_gap)
479
+ cursor += widths[v] + cfg.min_gap * 2
480
+ total = xs[-1] + widths[vs[-1]] / 2 + cfg.min_gap
481
+ shift = (max(0, _content_w(order, widths, cfg)) - total) / 2
482
+ for v, x in zip(vs, xs):
483
+ pos[v] = int(round((x + shift) / snap)) * snap
484
+ return pos
485
+
486
+
487
+ def _polish(nodes, edges, rows, pos, y, widths, card_h, cfg, rounds=2,
488
+ hard_gates=False):
489
+ """Local search: per-node +/-24/+/-48 horizontal shifts (preserving same-row clearance), scored by routing ground-truth total cost.
490
+
491
+ A supplementary refinement over CP-SAT global placement - it explicitly
492
+ feeds "routing feasibility" back into the placement layer.
493
+ Deterministic: fixed traversal order + fixed perturbation set.
494
+ """
495
+ def nodes_px(p):
496
+ return {v: (p[v], y[v], widths[v], card_h) for v in nodes}
497
+
498
+ best_pos = dict(pos)
499
+ best_score, best_routes, _ = _route_total(nodes_px(best_pos), edges, cfg,
500
+ hard_gates, quality=hard_gates)
501
+ row_of = {v: r for r, vs in enumerate(rows) for v in vs}
502
+ for _ in range(rounds):
503
+ improved = False
504
+ for v in sorted(nodes):
505
+ row = [u for u in rows[row_of[v]] if u != v]
506
+ for dx in (24, -24, 48, -48, 72, -72):
507
+ nx = best_pos[v] + dx
508
+ if any(abs(nx - best_pos[u]) <
509
+ (widths[v] + widths[u]) / 2 + cfg.min_gap for u in row):
510
+ continue
511
+ if nx < cfg.margin or nx > _content_w(rows, widths, cfg) - cfg.margin:
512
+ continue
513
+ cand = dict(best_pos)
514
+ cand[v] = nx
515
+ sc, routes, _ = _route_total(nodes_px(cand), edges, cfg,
516
+ hard_gates, quality=hard_gates)
517
+ if sc < best_score - 1e-6:
518
+ best_pos, best_score, best_routes = cand, sc, routes
519
+ improved = True
520
+ # adjacent in-row swaps (mechanizing the "a human can see a better spot" class of moves)
521
+ for vs in rows:
522
+ for i in range(len(vs) - 1):
523
+ u, w = vs[i], vs[i + 1]
524
+ cand = dict(best_pos)
525
+ cand[u], cand[w] = best_pos[w], best_pos[u]
526
+ sc, routes, _ = _route_total(nodes_px(cand), edges, cfg,
527
+ hard_gates, quality=hard_gates)
528
+ if sc < best_score - 1e-6:
529
+ best_pos, best_score, best_routes = cand, sc, routes
530
+ improved = True
531
+ if not improved:
532
+ break
533
+ return best_pos, best_score, best_routes
534
+
535
+
536
+ def _manual_waypoints(manual, edges):
537
+ """--from-layout per-edge waypoints (t_fad260bd): {(a,b): [[x,y],...]}.
538
+
539
+ The export artifact of the demo-2d bend handles ("user anchoring").
540
+ Validation: (a,b) must be an edge of the graph and
541
+ every point a pair of numbers; coordinates are rounded (same caliber as
542
+ nodes); consecutive duplicate points <0.5px apart are
543
+ deduplicated; if empty after dedup it counts as not anchored.
544
+ Deterministic: same input, same output.
545
+ """
546
+ out = {}
547
+ raw = manual.get("edges")
548
+ if raw is None:
549
+ return out
550
+ if not isinstance(raw, list):
551
+ raise ValueError("manual layout edges must be a [{a,b,waypoints}] list")
552
+ eset = set(edges)
553
+ for it in raw:
554
+ if not isinstance(it, dict):
555
+ raise ValueError(f"unrecognized manual layout edge entry: {it!r}")
556
+ a, b = it.get("a"), it.get("b")
557
+ if (a, b) not in eset:
558
+ raise ValueError(f"manual layout waypoints reference an edge not in the graph: {a}->{b}")
559
+ wps = it.get("waypoints")
560
+ if wps is None:
561
+ continue
562
+ if not isinstance(wps, list):
563
+ raise ValueError(f"waypoints of edge {a}->{b} must be a [[x,y],...] list")
564
+ pts = []
565
+ for p in wps:
566
+ if not isinstance(p, (list, tuple)) or len(p) < 2:
567
+ raise ValueError(f"unrecognized waypoint format for edge {a}->{b}: {p!r}")
568
+ try:
569
+ x, y = float(p[0]), float(p[1])
570
+ except (TypeError, ValueError):
571
+ raise ValueError(f"waypoint coordinates of edge {a}->{b} are not numeric: {p!r}")
572
+ if not (math.isfinite(x) and math.isfinite(y)):
573
+ raise ValueError(f"waypoint coordinates of edge {a}->{b} are not finite: {p!r}")
574
+ q = (int(round(x)), int(round(y)))
575
+ if not pts or math.dist(pts[-1], q) > 0.5:
576
+ pts.append(q)
577
+ if pts:
578
+ out[(a, b)] = pts
579
+ return out
580
+
581
+
582
+ def _manual_positions(nodes, widths, manual):
583
+ """--from-layout: consume a manual layout (a layout.json-compatible subset), returns (pos, y, rows, meta).
584
+
585
+ Manual coordinates are rounded and aligned to the 2px grid (matching the
586
+ geometry self-check 6b caliber; snap effects are recorded honestly);
587
+ card widths are recomputed from labels via textmetrics (same caliber as the
588
+ automatic pipeline). The id set must match the graph exactly
589
+ (missing/extra nodes -> ValueError, which the CLI turns into an honest error).
590
+ """
591
+ mn = manual.get("nodes") or {}
592
+ if not isinstance(mn, dict) or not mn:
593
+ raise ValueError("manual layout is missing nodes (node id -> x,y)")
594
+ def _xy(entry):
595
+ if isinstance(entry, dict):
596
+ return entry.get("x"), entry.get("y")
597
+ if isinstance(entry, (list, tuple)) and len(entry) >= 2:
598
+ return entry[0], entry[1]
599
+ raise ValueError(f"unrecognized manual layout node format: {entry!r}")
600
+ missing = [v for v in nodes if v not in mn]
601
+ extra = [v for v in mn if v not in set(nodes)]
602
+ if missing or extra:
603
+ raise ValueError(f"manual layout node set does not match the graph: missing {missing}, extra {extra}")
604
+ pos, y, snapped = {}, {}, []
605
+ for v in nodes:
606
+ xv, yv = _xy(mn[v])
607
+ try:
608
+ xi, yi = int(round(float(xv))), int(round(float(yv)))
609
+ except (TypeError, ValueError):
610
+ raise ValueError(f"manual layout node {v} coordinates are not numeric: {xv!r},{yv!r}")
611
+ if xi % 2 or yi % 2:
612
+ snapped.append(v)
613
+ pos[v] = xi - (xi % 2) # align to the 2px grid (geometry self-check 6b caliber)
614
+ y[v] = yi - (yi % 2)
615
+ rows = [[v for v in nodes if y[v] == yy] for yy in sorted({y[v] for v in nodes})]
616
+ meta = {"source": manual.get("source"),
617
+ "manual_viewBox": manual.get("viewBox"),
618
+ "snapped_to_2px": sorted(snapped)}
619
+ return pos, y, rows, meta
620
+
621
+
622
+ def _manual_edge_styles(manual, edges):
623
+ """--from-layout per-edge forced line styles (v0.7.7): {(a,b): style}.
624
+
625
+ The export artifact of the demo-2d per-edge style cycle
626
+ (auto/straight/arc/free). Validation mirrors _manual_waypoints: (a,b)
627
+ must be a graph edge; style must be one of the four tokens; "auto"
628
+ entries are dropped (engine default). "free" is carried by waypoints
629
+ (kind=waypoints path), not by this dict.
630
+ """
631
+ out = {}
632
+ raw = manual.get("edges")
633
+ if raw is None:
634
+ return out
635
+ if not isinstance(raw, list):
636
+ raise ValueError("manual layout edges must be a [{a,b,style}] list")
637
+ eset = set(edges)
638
+ for it in raw:
639
+ if not isinstance(it, dict):
640
+ raise ValueError(f"unrecognized manual layout edge entry: {it!r}")
641
+ a, b = it.get("a"), it.get("b")
642
+ if (a, b) not in eset:
643
+ raise ValueError(f"manual layout styles reference an edge not in the graph: {a}->{b}")
644
+ st = it.get("style")
645
+ if st is None:
646
+ continue
647
+ if st not in ("auto", "straight", "arc", "free"):
648
+ raise ValueError(f"unrecognized line style for edge {a}->{b}: {st!r}")
649
+ if st in ("straight", "arc"):
650
+ out[(a, b)] = st
651
+ return out
652
+
653
+
654
+ def _forced_arc_route(nodes_px, a, b, clearance):
655
+ """Deterministic forced-arc geometry (v0.7.7 style replay, Python side).
656
+
657
+ Same rule as the demo-2d drag layer: anchors = box borders facing each
658
+ other; curvature = chord-adaptive k (routing.adaptive_arc_k); side = the
659
+ sign whose sampled arc keeps more distance from unrelated boxes (tie ->
660
+ +1). Returns (points, ctrl)."""
661
+ from .routing import border_point, quad_points, adaptive_arc_k, dist_point_rect
662
+ A, B = nodes_px[a], nodes_px[b]
663
+ pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0, B[0] - A[0], B[1] - A[1])
664
+ pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0, A[0] - B[0], A[1] - B[1])
665
+ vx, vy = pb[0] - pa[0], pb[1] - pa[1]
666
+ dist = math.hypot(vx, vy)
667
+ k = adaptive_arc_k(dist)
668
+ n = max(dist, 1e-6)
669
+ ux, uy = -vy / n, vx / n
670
+ best = None
671
+ for s in (1, -1):
672
+ ctrl = [(pa[0] + pb[0]) / 2 + ux * k * dist * s,
673
+ (pa[1] + pb[1]) / 2 + uy * k * dist * s]
674
+ pts = quad_points(pa, ctrl, pb)
675
+ mclr = None
676
+ for v, rect in nodes_px.items():
677
+ if v in (a, b):
678
+ continue
679
+ for (px, py) in pts:
680
+ d = dist_point_rect(px, py, rect[0], rect[1],
681
+ rect[2] / 2.0, rect[3] / 2.0)
682
+ mclr = d if mclr is None else min(mclr, d)
683
+ key = (-(mclr if mclr is not None else 1e9), s)
684
+ if best is None or key < best[0]:
685
+ best = (key, pts, ctrl)
686
+ return best[1], best[2]
687
+
688
+
689
+ def _ring_trial(plan, nodes, widths, card_h, cfg, why=None):
690
+ """Ring placement + routing trial: adopted only if every hard-gate proxy passes; otherwise None (fall back to layered, byte-for-byte unchanged).
691
+
692
+ why: optional dict — receives the trial-gate outcome reason (mode
693
+ decision trace).
694
+
695
+ The proxy caliber shares its source with the delivered certificate:
696
+ crossings (router theory) <=2, box press=0, edge-box clearance>=clearance,
697
+ mutual-pair separation >=4px, card gap >=16, anchor smoothing >=20deg.
698
+ The final word is certificate_auto's recheck on delivered coordinates (this
699
+ layer is only the deterministic adopt/fallback trial gate).
700
+ """
701
+ from . import ringlayout
702
+ from .routing import (min_pair_dist, sample_polyline, ANCHOR_MIN_DEG)
703
+
704
+ def _why(reason):
705
+ if why is not None:
706
+ why["reason"] = reason
707
+ return None
708
+ adj, _ = ringlayout._adjacency(EDGES_REF[0])
709
+ try:
710
+ pos, y, meta = ringlayout.place_ring(plan, widths, card_h, cfg, adj,
711
+ EDGES_REF[0])
712
+ except ringlayout.InfeasibleRing:
713
+ return _why("trial: constructed placement infeasible (InfeasibleRing)")
714
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
715
+ # card gap >=16 (place_ring places at 18; a pairwise recheck is the backstop)
716
+ vs = sorted(nodes)
717
+ for i in range(len(vs)):
718
+ for j in range(i + 1, len(vs)):
719
+ a, b = vs[i], vs[j]
720
+ gap_x = abs(pos[a] - pos[b]) - (widths[a] + widths[b]) / 2
721
+ gap_y = abs(y[a] - y[b]) - card_h
722
+ if max(gap_x, gap_y) < cfg.min_gap - 2: # rect_gap caliber (>=16 gate)
723
+ return _why("trial gate: card gap < 16 (rect_gap caliber)")
724
+ router = Router(nodes_px, EDGES_REF[0], cfg.clearance, cfg.pitch)
725
+ routes = router.route_all()
726
+ if router.final_crossing_pairs(routes) > 2:
727
+ return _why("trial gate: router-theory crossings > 2")
728
+ eset = set(EDGES_REF[0])
729
+ for i, info in routes.items():
730
+ if info.get("press", 0):
731
+ return _why("trial gate: box press != 0")
732
+ clr = info.get("min_clear")
733
+ if clr is None or clr < cfg.clearance:
734
+ return _why("trial gate: edge-box clearance < 16px")
735
+ # anchor smoothing proxy (same caliber as check item 4)
736
+ pts = info["points"]
737
+ for v, p_idx, n_idx in ((EDGES_REF[0][i][0], 0, 1),
738
+ (EDGES_REF[0][i][1], len(pts) - 1, len(pts) - 2)):
739
+ p = nodes_px[v]
740
+ a0 = pts[p_idx]
741
+ if (abs(abs(a0[0] - p[0]) - p[2] / 2) > abs(abs(a0[1] - p[1]) - p[3] / 2) + 1e-9
742
+ and abs(abs(a0[1] - p[1]) - p[3] / 2) > 0.75):
743
+ return _why("trial gate: anchor not on the box edge")
744
+ dx, dy = pts[n_idx][0] - a0[0], pts[n_idx][1] - a0[1]
745
+ n = math.hypot(dx, dy)
746
+ if n < 1e-9:
747
+ return _why("trial gate: degenerate anchor segment")
748
+ on_x = abs(abs(a0[0] - p[0]) - p[2] / 2) <= abs(abs(a0[1] - p[1]) - p[3] / 2)
749
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
750
+ c = abs(dx * tx + dy * ty) / n
751
+ if math.degrees(math.acos(max(0.0, min(1.0, c)))) < ANCHOR_MIN_DEG:
752
+ return _why("trial gate: anchor smoothing < 20deg")
753
+ # mutual-pair separation >=4px (F1 caliber)
754
+ for i in range(len(EDGES_REF[0])):
755
+ for j in range(i + 1, len(EDGES_REF[0])):
756
+ if EDGES_REF[0][i] != (EDGES_REF[0][j][1], EDGES_REF[0][j][0]):
757
+ continue
758
+ md = min_pair_dist(sample_polyline(routes[i]["points"]),
759
+ sample_polyline(routes[j]["points"]))
760
+ if md is None or md < 4.0:
761
+ return _why("trial gate: mutual-pair separation < 4px")
762
+ if why is not None:
763
+ why["reason"] = "trial gate passed"
764
+ plan = dict(plan)
765
+ plan["placed"] = (pos, y, meta)
766
+ return plan
767
+
768
+
769
+ def _constructed_gates(nodes_px, edges, routes, cfg, constructed):
770
+ """Unified hard-gate proxy over constructed routes (closed arcs / mirror-symmetry arcs) and routed routes (t_c6ab760d).
771
+
772
+ Same caliber as _ring_trial: press=0, edge-box clearance>=clearance,
773
+ anchors on box edges with smoothing
774
+ >=ANCHOR_MIN_DEG, card gap >= min_gap-2, mutual-pair separation >=4px,
775
+ crossings <=2.
776
+ Returns True = gate passed.
777
+ """
778
+ from .routing import (dist_point_rect, min_pair_dist, sample_polyline,
779
+ ANCHOR_MIN_DEG)
780
+ # card gap (rect_gap caliber, >= min_gap-2)
781
+ vs = sorted(nodes_px)
782
+ for i in range(len(vs)):
783
+ for j in range(i + 1, len(vs)):
784
+ a, b = vs[i], vs[j]
785
+ ax, ay, aw, ah = nodes_px[a]
786
+ bx, by, bw, bh = nodes_px[b]
787
+ gap_x = abs(ax - bx) - (aw + bw) / 2
788
+ gap_y = abs(ay - by) - (ah + bh) / 2
789
+ if max(gap_x, gap_y) < cfg.min_gap - 2:
790
+ return False
791
+ if _final_pairs(nodes_px, edges, routes, cfg) > 2:
792
+ return False
793
+ for i, info in routes.items():
794
+ a, b = edges[i]
795
+ pts = info["points"]
796
+ # same caliber as check3: dense-sampled clearance recheck (a backstop for chord effects)
797
+ dense = sample_polyline([tuple(p) for p in pts], step=4.0)
798
+ for v, (cx, cy, w, h) in nodes_px.items():
799
+ if v in (a, b):
800
+ continue
801
+ for px, py in dense:
802
+ if dist_point_rect(px, py, cx, cy, w / 2, h / 2) < cfg.clearance:
803
+ return False
804
+ # anchor on box edge + smoothing
805
+ for v, p_idx, n_idx in ((a, 0, 1), (b, len(pts) - 1, len(pts) - 2)):
806
+ cx, cy, w, h = nodes_px[v]
807
+ a0 = pts[p_idx]
808
+ if (abs(abs(a0[0] - cx) - w / 2) > abs(abs(a0[1] - cy) - h / 2) + 1e-9
809
+ and abs(abs(a0[1] - cy) - h / 2) > 0.75):
810
+ return False
811
+ dx, dy = pts[n_idx][0] - a0[0], pts[n_idx][1] - a0[1]
812
+ n = math.hypot(dx, dy)
813
+ if n < 1e-9:
814
+ return False
815
+ on_x = abs(abs(a0[0] - cx) - w / 2) <= abs(abs(a0[1] - cy) - h / 2)
816
+ tx, ty = (0.0, 1.0) if on_x else (1.0, 0.0)
817
+ c = abs(dx * tx + dy * ty) / n
818
+ if math.degrees(math.acos(max(0.0, min(1.0, c)))) < ANCHOR_MIN_DEG:
819
+ return False
820
+ # mutual-pair separation >=4px
821
+ for i in range(len(edges)):
822
+ for j in range(i + 1, len(edges)):
823
+ if edges[i] != (edges[j][1], edges[j][0]):
824
+ continue
825
+ md = min_pair_dist(sample_polyline(routes[i]["points"]),
826
+ sample_polyline(routes[j]["points"]))
827
+ if md is None or md < 4.0:
828
+ return False
829
+ return True
830
+
831
+
832
+ def _final_pairs(nodes_px, edges, routes, cfg):
833
+ """Crossing edge-pair count after merging constructed routes (same caliber as Router.final_crossing_pairs)."""
834
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
835
+ return router.final_crossing_pairs(routes)
836
+
837
+
838
+ def _sym_trial(plan, nodes, widths, card_h, cfg, why=None):
839
+ """Bowtie-pair symmetric placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
840
+
841
+ why: optional dict — receives the trial-gate outcome reason (mode
842
+ decision trace).
843
+ """
844
+ from . import symlayout
845
+ edges = EDGES_REF[0]
846
+ try:
847
+ pos, y, meta, constructed = symlayout.place_bowtie(plan, widths,
848
+ card_h, cfg, edges)
849
+ except symlayout.InfeasibleBowtie:
850
+ if why is not None:
851
+ why["reason"] = ("trial: constructed placement infeasible "
852
+ "(InfeasibleBowtie)")
853
+ return None
854
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
855
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
856
+ routes = router.route_all()
857
+ for i, (a, b) in enumerate(edges):
858
+ cr = constructed.get((a, b))
859
+ if cr is not None:
860
+ routes[i] = dict(cr)
861
+ # line-style consistency hard rule: no vg/poly polylines inside the symmetric structure (arc > straight > polyline)
862
+ for i, info in routes.items():
863
+ if info["kind"] in ("vg", "poly", "wrap"):
864
+ if why is not None:
865
+ why["reason"] = ("trial gate: polyline route inside the "
866
+ "symmetric structure (line-type consistency "
867
+ "hard rule)")
868
+ return None
869
+ if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
870
+ if why is not None:
871
+ why["reason"] = ("trial gate: hard-gate proxy failed "
872
+ "(crossings<=2 / press=0 / clearance>=16 / "
873
+ "smooth anchors / mutual separation / card gap)")
874
+ return None
875
+ if why is not None:
876
+ why["reason"] = "trial gate passed"
877
+ return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
878
+
879
+
880
+ def _bowtie_trial(plan, nodes, widths, card_h, cfg, why=None):
881
+ """Bowtie-vertex symmetric placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
882
+
883
+ why: optional dict — receives the trial-gate outcome reason (mode
884
+ decision trace).
885
+
886
+ t_7fe64f08 (aesthetics v3): graphs with exactly 2 simple loops sharing
887
+ exactly 1 node (the wage-price-spiral
888
+ family, benchmarked on jacky's hand-drawn samples) -> bowtie-vertex
889
+ symmetric placement; all-straight-line construction,
890
+ line-style consistency hard rule same as symmetric (vg/poly/wrap polylines
891
+ forbidden).
892
+ """
893
+ from . import bowtielayout
894
+ edges = EDGES_REF[0]
895
+ try:
896
+ pos, y, meta, constructed = bowtielayout.place_bowtie_vertex(
897
+ plan, widths, card_h, cfg, edges)
898
+ except bowtielayout.InfeasibleBowtieVertex:
899
+ if why is not None:
900
+ why["reason"] = ("trial: constructed placement infeasible "
901
+ "(InfeasibleBowtieVertex)")
902
+ return None
903
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
904
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
905
+ routes = router.route_all()
906
+ for i, (a, b) in enumerate(edges):
907
+ cr = constructed.get((a, b))
908
+ if cr is not None:
909
+ routes[i] = dict(cr)
910
+ # line-style consistency hard rule: no vg/poly polylines inside the symmetric structure (arc > straight > polyline)
911
+ for i, info in routes.items():
912
+ if info["kind"] in ("vg", "poly", "wrap"):
913
+ if why is not None:
914
+ why["reason"] = ("trial gate: polyline route inside the "
915
+ "symmetric structure (line-type consistency "
916
+ "hard rule)")
917
+ return None
918
+ if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
919
+ if why is not None:
920
+ why["reason"] = ("trial gate: hard-gate proxy failed "
921
+ "(crossings<=2 / press=0 / clearance>=16 / "
922
+ "smooth anchors / mutual separation / card gap)")
923
+ return None
924
+ if why is not None:
925
+ why["reason"] = "trial gate passed"
926
+ return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
927
+
928
+
929
+ def _spine_trial(plan, nodes, widths, card_h, cfg, why=None):
930
+ """Spine-wing placement + routing trial gate: adopted only if the gate passes; otherwise None (fallback, byte-for-byte unchanged).
931
+
932
+ why: optional dict — receives the trial-gate outcome reason (mode
933
+ decision trace).
934
+
935
+ t_8f211f74 (v0.7.1): hub pair on the spine + midline wing band + mirrored
936
+ clusters beyond the wings
937
+ (the classic-cld-growth-and-burden family, benchmarked on jacky's
938
+ hand-drawn samples); spine edges
939
+ are injected as constructed vertical straight lines, the rest go through
940
+ the Router candidate pool.
941
+ """
942
+ from . import spinewinglayout
943
+ edges = EDGES_REF[0]
944
+ try:
945
+ pos, y, meta, constructed = spinewinglayout.place_spine_wing(
946
+ plan, widths, card_h, cfg, edges)
947
+ except spinewinglayout.InfeasibleSpineWing:
948
+ if why is not None:
949
+ why["reason"] = ("trial: constructed placement infeasible "
950
+ "(InfeasibleSpineWing)")
951
+ return None
952
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
953
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
954
+ routes = router.route_all()
955
+ for i, (a, b) in enumerate(edges):
956
+ cr = constructed.get((a, b))
957
+ if cr is not None:
958
+ routes[i] = dict(cr)
959
+ if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
960
+ if why is not None:
961
+ why["reason"] = ("trial gate: hard-gate proxy failed "
962
+ "(crossings<=2 / press=0 / clearance>=16 / "
963
+ "smooth anchors / mutual separation / card gap)")
964
+ return None
965
+ if why is not None:
966
+ why["reason"] = "trial gate passed"
967
+ return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
968
+
969
+
970
+ def _comb_trial(plan, nodes, widths, card_h, cfg, why=None):
971
+ """Comb-bilateral placement + routing trial gate: adopted only if every
972
+ route is a straight line (the comb contract: column spine vertical +
973
+ hub fan straight) AND the unified hard-gate proxy passes; otherwise None
974
+ (fall back to the next tier, byte-for-byte unchanged).
975
+
976
+ v0.7.7 (t_be5b4175): K_{m,2} bilateral family — two non-adjacent hubs,
977
+ every source connected to both, pendant trees on the source side.
978
+ Geometric ground truth = jacky's hand-pulled HDD supply-chain layout.
979
+ """
980
+ from . import comblayout
981
+ edges = EDGES_REF[0]
982
+ try:
983
+ pos, y, meta, constructed = comblayout.place_comb(plan, widths,
984
+ card_h, cfg)
985
+ except comblayout.InfeasibleComb:
986
+ if why is not None:
987
+ why["reason"] = ("trial: constructed placement infeasible "
988
+ "(InfeasibleComb — both fan tiers exhausted)")
989
+ return None
990
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
991
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
992
+ routes = router.route_all()
993
+ # v0.7.8 tier-1 wide-card fan: hub edges constructed between
994
+ # facing-vertical-border anchors replace the routed candidates here (the
995
+ # same injection channel the delivery layer uses); the contract check
996
+ # and the unified hard gates below then review the injected geometry
997
+ # like any other route.
998
+ if constructed:
999
+ idx = {e: i for i, e in enumerate(edges)}
1000
+ for key, cr in constructed.items():
1001
+ i = idx.get(key)
1002
+ if i is None:
1003
+ continue # mirrored direction key absent from the
1004
+ # declared edge list — nothing to replace
1005
+ routes[i] = {"kind": cr["kind"],
1006
+ "points": [list(p) for p in cr["points"]],
1007
+ "press": 0, "min_clear": None, "cross": 0,
1008
+ "cross_edges": 0, "ang_a": 90.0, "ang_b": 90.0}
1009
+ for i, info in routes.items():
1010
+ if info["kind"] != "line":
1011
+ if why is not None:
1012
+ why["reason"] = ("trial gate: comb contract broken — "
1013
+ f"non-straight route ({info['kind']}) in "
1014
+ "the hub fan")
1015
+ return None # comb contract: all-straight fan
1016
+ if not _constructed_gates(nodes_px, edges, routes, cfg, {}):
1017
+ if why is not None:
1018
+ why["reason"] = ("trial gate: hard-gate proxy failed "
1019
+ "(crossings<=2 / press=0 / clearance>=16 / "
1020
+ "smooth anchors / mutual separation / card gap)")
1021
+ return None
1022
+ if why is not None:
1023
+ why["reason"] = "trial gate passed (all-straight fan contract held)"
1024
+ plan = dict(plan)
1025
+ plan["placed"] = (pos, y, meta)
1026
+ plan["constructed"] = {k: dict(v) for k, v in constructed.items()}
1027
+ return plan
1028
+
1029
+
1030
+ def _column_trial(plan, nodes, widths, card_h, cfg, why=None):
1031
+ """Column placement + routing trial gate: adopted only if the gate passes; otherwise None (fall back to layered, byte-for-byte unchanged).
1032
+
1033
+ why: optional dict — receives the trial-gate outcome reason (mode
1034
+ decision trace).
1035
+ """
1036
+ from . import columnlayout
1037
+ from .routing import min_pair_dist, sample_polyline
1038
+ edges = EDGES_REF[0]
1039
+ adj = columnlayout._adjacency(edges)
1040
+ try:
1041
+ pos, y, meta, constructed = columnlayout.place_column(
1042
+ plan, widths, card_h, cfg, adj, edges)
1043
+ except columnlayout.InfeasibleColumn:
1044
+ if why is not None:
1045
+ why["reason"] = ("trial: constructed placement infeasible "
1046
+ "(InfeasibleColumn)")
1047
+ return None
1048
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
1049
+ router = Router(nodes_px, edges, cfg.clearance, cfg.pitch)
1050
+ routes = router.route_all()
1051
+ for i, (a, b) in enumerate(edges):
1052
+ cr = constructed.get((a, b))
1053
+ if cr is not None:
1054
+ routes[i] = dict(cr)
1055
+ if not _constructed_gates(nodes_px, edges, routes, cfg, constructed):
1056
+ if why is not None:
1057
+ why["reason"] = ("trial gate: hard-gate proxy failed "
1058
+ "(crossings<=2 / press=0 / clearance>=16 / "
1059
+ "smooth anchors / mutual separation / card gap)")
1060
+ return None
1061
+ if why is not None:
1062
+ why["reason"] = "trial gate passed"
1063
+ return {"placed": (pos, y, meta, constructed), "deg0": plan["deg0"]}
1064
+
1065
+
1066
+ def layout(graph, config: AutoConfig = None, linestyle: str = "straight",
1067
+ manual: dict = None):
1068
+ """auto recipe entry point: graph -> a v4-compatible layout dict (with polyline/arc routing + metadata).
1069
+
1070
+ manual: a --from-layout manual layout ({nodes: {id: x,y}, viewBox, source}).
1071
+ When given, layering/placement/polish are skipped and the manual
1072
+ coordinates go through the same routing + delivery pipeline (hard-gate
1073
+ caliber unchanged, scored honestly). With manual=None (default) the code
1074
+ path is byte-for-byte identical to history.
1075
+
1076
+ t_af83815d loop-dominant ring placement: with manual=None the deterministic
1077
+ trigger detection (ringlayout.detect_ring) runs first;
1078
+ a hit whose routing trial passes the gate -> regular-polygon slots for the
1079
+ main ring;
1080
+ no hit / trial failure -> the existing layered path (byte-for-byte
1081
+ unchanged).
1082
+ """
1083
+ global EDGES_REF
1084
+ cfg = config or AutoConfig()
1085
+ EDGES_REF = [tuple((e.a, e.b) for e in graph.edges)]
1086
+ nodes = graph.node_ids()
1087
+ adj = graph.adj_undirected()
1088
+ core = [v for v in nodes if adj[v]]
1089
+ deg0 = [v for v in nodes if not adj[v]]
1090
+
1091
+ # ① box-boundary ground truth (layout side): real font metrics + conservative padding
1092
+ widths, heights = {}, {}
1093
+ for v in nodes:
1094
+ nd = graph.id2node()[v]
1095
+ w, h = textmetrics.card_size(nd.label, nd.value, cfg.fs_title, cfg.fs_value,
1096
+ min_w=cfg.card_min_w,
1097
+ value_pad=cfg.value_pad_extra)
1098
+ widths[v], heights[v] = w, h
1099
+ card_h = max(heights.values()) if heights else cfg.card_h
1100
+
1101
+ # ②③ placement: --from-layout manual coordinates, or automatic layering + placement
1102
+ _routes_final = None
1103
+ constructed = {}
1104
+ from_meta = None
1105
+ # t_cbfca790 mode decision trace: every rule evaluation in the deterministic
1106
+ # priority chain is recorded (rule, tier, triggered, reason, trial gate) so
1107
+ # the certificate can show WHY a placement mode was picked (review report
1108
+ # item 5: reduce the trigger-template black-box feel). Recording only — the
1109
+ # decision logic below is untouched, so non-hit paths stay byte-identical.
1110
+ _mode_rules = []
1111
+ if manual is not None:
1112
+ pos, y, rows, from_meta = _manual_positions(nodes, widths, manual)
1113
+ deg0 = []
1114
+ engine, pos_meta = "manual", {}
1115
+ else:
1116
+ # ②'' layout aesthetics v2 (t_c6ab760d, jacky's 2026-11-05 direction for the whole graph): mode priority = bowtie-pair symmetry
1117
+ # (the learning-flywheel family) -> column (long chains / few loops, main ring <=4) -> ring (loop-dominant small graphs, t_af83815d)
1118
+ # -> the existing layered path.
1119
+ # Every new mode carries a trial gate: silent fallback when the gate fails, and the fallback path is byte-for-byte unchanged.
1120
+ def _rule_hit(name, tier, det_ok, plan, det_why, trial_why=None):
1121
+ """Record one priority-chain rule evaluation (trace only)."""
1122
+ entry = {"tier": tier, "rule": name,
1123
+ "triggered": plan is not None,
1124
+ "reason": (det_why or {}).get("reason")}
1125
+ if det_ok and plan is None and trial_why is not None:
1126
+ entry["trial_gate"] = "fail"
1127
+ entry["reason"] = (entry["reason"] + " — " +
1128
+ (trial_why or {}).get("reason", ""))
1129
+ elif plan is not None:
1130
+ entry["trial_gate"] = "pass"
1131
+ else:
1132
+ entry["trial_gate"] = None
1133
+ _mode_rules.append(entry)
1134
+ return plan
1135
+
1136
+ sym_plan = None
1137
+ bw_plan = None
1138
+ sw_plan = None
1139
+ comb_plan = None
1140
+ column_plan = None
1141
+ ring_plan = None
1142
+ _why_sym, _why_bw, _why_sw = {}, {}, {}
1143
+ _why_comb, _why_col, _why_ring = {}, {}, {}
1144
+ _tw_sym, _tw_bw, _tw_sw = {}, {}, {}
1145
+ _tw_comb, _tw_col, _tw_ring = {}, {}, {}
1146
+ _chain_cap = len(nodes) <= 12
1147
+ if not _chain_cap:
1148
+ _cap_reason = (f"n={len(nodes)} > 12 — structural modes cap at "
1149
+ "12 nodes")
1150
+ for _nm in ("symmetric (shared-edge bowtie)", "bowtie-vertex",
1151
+ "spine-wing", "column-chain", "comb-bilateral",
1152
+ "ring-cycle"):
1153
+ _mode_rules.append({"tier": 0, "rule": _nm,
1154
+ "triggered": False,
1155
+ "reason": f"not evaluated: {_cap_reason}",
1156
+ "trial_gate": None})
1157
+ if _chain_cap:
1158
+ from . import symlayout as _symlayout
1159
+ _sym = _symlayout.detect_bowtie(graph, why=_why_sym)
1160
+ if _sym is not None:
1161
+ sym_plan = _sym_trial(_sym, nodes, widths, card_h, cfg,
1162
+ why=_tw_sym)
1163
+ _rule_hit("symmetric (shared-edge bowtie)", 1, _sym is not None,
1164
+ sym_plan, _why_sym, _tw_sym)
1165
+ # t_7fe64f08 (aesthetics v3) bowtie-vertex (two loops sharing a single node): topologically mutually exclusive with the shared-edge bowtie
1166
+ # (symmetric) and tied with it at the top tier; detection enumerates simple loops (the declared table mixes in 2-node pseudo-loops
1167
+ # and misses derived loops, so it is unusable).
1168
+ if sym_plan is None and _chain_cap:
1169
+ from . import bowtielayout as _bowtielayout
1170
+ _bw = _bowtielayout.detect_bowtie_vertex(graph, why=_why_bw)
1171
+ if _bw is not None:
1172
+ bw_plan = _bowtie_trial(_bw, nodes, widths, card_h, cfg,
1173
+ why=_tw_bw)
1174
+ _rule_hit("bowtie-vertex", 1, _bw is not None, bw_plan,
1175
+ _why_bw, _tw_bw)
1176
+ if bw_plan is None and sym_plan is None and _chain_cap:
1177
+ from . import spinewinglayout as _spinewinglayout
1178
+ _sw = _spinewinglayout.detect_spine_wing(graph, why=_why_sw)
1179
+ if _sw is not None:
1180
+ sw_plan = _spine_trial(_sw, nodes, widths, card_h, cfg,
1181
+ why=_tw_sw)
1182
+ _rule_hit("spine-wing", 2, _sw is not None, sw_plan,
1183
+ _why_sw, _tw_sw)
1184
+ if (bw_plan is None and sym_plan is None and sw_plan is None
1185
+ and _chain_cap):
1186
+ from . import ringlayout as _ringlayout
1187
+ from . import columnlayout as _columnlayout
1188
+ from . import comblayout as _comblayout
1189
+ _ring_detect = _ringlayout.detect_ring(graph, why=_why_ring)
1190
+ # column is detected only when ring would not take the pure-cycle/ratio branch (cover-triggered graphs with a main ring <=4 go
1191
+ # to column; large rings with k>=5 still go to ring)
1192
+ if _ring_detect is None or _ring_detect["trigger"] == "cover":
1193
+ _col = _columnlayout.detect_column(graph, why=_why_col)
1194
+ if _col is not None:
1195
+ column_plan = _column_trial(_col, nodes, widths, card_h,
1196
+ cfg, why=_tw_col)
1197
+ _rule_hit("column-chain", 3, _col is not None, column_plan,
1198
+ _why_col, _tw_col)
1199
+ else:
1200
+ _mode_rules.append({
1201
+ "tier": 3, "rule": "column-chain", "triggered": False,
1202
+ "reason": ("not evaluated: ring detection would take the "
1203
+ f"{_ring_detect['trigger']} branch "
1204
+ "(pure-cycle/ratio graphs go to ring-cycle)"),
1205
+ "trial_gate": None})
1206
+ # v0.7.7 comb-bilateral (K_{m,2} bilateral hubs) sits between
1207
+ # column and ring (preempts ring: these graphs score a high
1208
+ # non-bridge ratio with a 4-cycle "main ring" and historically
1209
+ # misfired the ring ratio branch). Against column it applies a
1210
+ # salience rule: the bilateral reading wins only when the source
1211
+ # column is strictly longer than the best column spine (in the
1212
+ # established column-chain families the spine weaves
1213
+ # hub-source-hub-source and is at least as salient — zero drift).
1214
+ _comb = _comblayout.detect_comb(graph, why=_why_comb)
1215
+ if _comb is not None and _col is not None \
1216
+ and len(_comb["column"]) <= len(_col.get("spine") or ()):
1217
+ _mode_rules.append({
1218
+ "tier": 2, "rule": "comb-bilateral", "triggered": False,
1219
+ "reason": ("detected, but not adopted: the comb source "
1220
+ "column is not strictly longer than the best "
1221
+ "column spine (salience rule — the column "
1222
+ "reading wins)"),
1223
+ "trial_gate": None})
1224
+ _comb = None
1225
+ if _comb is not None:
1226
+ comb_plan = _comb_trial(_comb, nodes, widths, card_h, cfg,
1227
+ why=_tw_comb)
1228
+ _rule_hit("comb-bilateral", 2, True, comb_plan,
1229
+ _why_comb, _tw_comb)
1230
+ if column_plan is None and comb_plan is None and _ring_detect is not None:
1231
+ ring_plan = _ring_trial(_ring_detect, nodes, widths, card_h,
1232
+ cfg, why=_tw_ring)
1233
+ _rule_hit("ring-cycle", 4, True, ring_plan,
1234
+ _why_ring, _tw_ring)
1235
+ elif _ring_detect is None:
1236
+ _mode_rules.append({"tier": 4, "rule": "ring-cycle",
1237
+ "triggered": False,
1238
+ "reason": _why_ring.get("reason"),
1239
+ "trial_gate": None})
1240
+ elif column_plan is not None or comb_plan is not None:
1241
+ _mode_rules.append({"tier": 4, "rule": "ring-cycle",
1242
+ "triggered": False,
1243
+ "reason": ("detected, but a higher-tier "
1244
+ "plan was adopted — trial not "
1245
+ "run"),
1246
+ "trial_gate": None})
1247
+ if not any(p is not None for p in (sym_plan, bw_plan, sw_plan,
1248
+ comb_plan, column_plan,
1249
+ ring_plan)):
1250
+ _mode_rules.append({"tier": 5, "rule": "layered (existing path)",
1251
+ "triggered": True,
1252
+ "reason": ("fallback: no structural mode "
1253
+ "triggered — layered layering + "
1254
+ "placement (byte-stable default)"),
1255
+ "trial_gate": None})
1256
+ constructed = {}
1257
+ if sym_plan is not None:
1258
+ spos, sy, smeta, sconstructed = sym_plan["placed"]
1259
+ pos, y = spos, sy
1260
+ deg0 = list(sym_plan["deg0"])
1261
+ rows = [[v for v in nodes if y[v] == yy]
1262
+ for yy in sorted({y[v] for v in nodes})]
1263
+ engine = "sym-construct"
1264
+ pos_meta = smeta
1265
+ constructed = sconstructed
1266
+ elif bw_plan is not None:
1267
+ bpos, by, bmeta, bconstructed = bw_plan["placed"]
1268
+ pos, y = bpos, by
1269
+ deg0 = list(bw_plan["deg0"])
1270
+ rows = [[v for v in nodes if y[v] == yy]
1271
+ for yy in sorted({y[v] for v in nodes})]
1272
+ engine = "bowtie-construct"
1273
+ pos_meta = bmeta
1274
+ constructed = bconstructed
1275
+ elif sw_plan is not None:
1276
+ wpos, wy, wmeta, wconstructed = sw_plan["placed"]
1277
+ pos, y = wpos, wy
1278
+ deg0 = list(sw_plan["deg0"])
1279
+ rows = [[v for v in nodes if y[v] == yy]
1280
+ for yy in sorted({y[v] for v in nodes})]
1281
+ engine = "spine-construct"
1282
+ pos_meta = wmeta
1283
+ constructed = wconstructed
1284
+ elif comb_plan is not None:
1285
+ cpos, cy, cmeta = comb_plan["placed"]
1286
+ pos, y = cpos, cy
1287
+ deg0 = list(comb_plan["deg0"]) if "deg0" in comb_plan else \
1288
+ [v for v in nodes if not adj[v]]
1289
+ rows = [[v for v in nodes if y[v] == yy]
1290
+ for yy in sorted({y[v] for v in nodes})]
1291
+ engine = "comb-construct"
1292
+ pos_meta = cmeta
1293
+ # v0.7.8: tier-1 fan lines ride the generic constructed-route
1294
+ # injection channel (frozen out of coincide/midband/fan_spread,
1295
+ # hard gates already rechecked by the trial)
1296
+ constructed = comb_plan.get("constructed") or {}
1297
+ elif column_plan is not None:
1298
+ cpos, cy, cmeta, cconstructed = column_plan["placed"]
1299
+ pos, y = cpos, cy
1300
+ deg0 = list(column_plan["deg0"])
1301
+ rows = [[v for v in nodes if y[v] == yy]
1302
+ for yy in sorted({y[v] for v in nodes})]
1303
+ engine = "column-construct"
1304
+ pos_meta = cmeta
1305
+ constructed = cconstructed
1306
+ elif ring_plan is not None:
1307
+ rpos, ry, rmeta = ring_plan["placed"]
1308
+ pos, y = rpos, ry
1309
+ deg0 = list(ring_plan["deg0"])
1310
+ rows = [[v for v in nodes if y[v] == yy]
1311
+ for yy in sorted({y[v] for v in nodes})]
1312
+ engine = "ring-construct"
1313
+ pos_meta = rmeta
1314
+ else:
1315
+ # ② layering (cycle breaking + longest path + empty-layer compression)
1316
+ _, dag = _cycle_break(core, EDGES_REF[0])
1317
+ layer = _layers(core, dag)
1318
+ nrow = max(layer.values()) + 1 if core else 1
1319
+ rows = [[v for v in core if layer[v] == r] for r in range(nrow)]
1320
+ rows = [r for r in rows if r]
1321
+
1322
+ # ③ placement
1323
+ pos, engine, pos_meta = _solve_positions(core, rows, widths, cfg, linestyle)
1324
+
1325
+ # y coordinates (even rows -> the foundation of the regularity gate)
1326
+ y = {v: cfg.top + r * cfg.pitch for r, vs in enumerate(rows) for v in vs}
1327
+ # parking strip for isolated nodes: evenly laid out at the top (24px column snap for small graphs, consistent with the regularity gate)
1328
+ if deg0:
1329
+ total = sum(widths[v] for v in deg0) + cfg.min_gap * (len(deg0) + 1)
1330
+ cw = _content_w(rows, widths, cfg)
1331
+ cur = max(cfg.margin, (cw - total) / 2)
1332
+ snap = cfg.snap_regular if len(nodes) <= 10 else cfg.grid
1333
+ for v in deg0:
1334
+ pos[v] = int(round((cur + widths[v] / 2) / snap)) * snap
1335
+ y[v] = cfg.park_y
1336
+ cur += widths[v] + cfg.min_gap
1337
+
1338
+ # ③b local polish (small graphs <=10 nodes): hill-climbing horizontal shifts on routing ground-truth total cost, explicitly
1339
+ # feeding routing feasibility (press/clearance/crossings) back into the placement layer
1340
+ if len(core) <= 10:
1341
+ pos, _sc, _rt = _polish(core, EDGES_REF[0], rows, pos, y,
1342
+ widths, card_h, cfg)
1343
+ # -- WS3 routing-quality rework (random dense graphs: check1 crossings>2 / check3 clearance<16): when a routing hard gate fails, the
1344
+ # layout bottleneck is not the router (the graph is planar but the placement pushes topological interleaving pairs to 3+, and greedy
1345
+ # routing can only dodge some of them) but the "row assignment + in-row order". Trigger a deterministic (row, order) reorder -> 24px
1346
+ # repack -> hard-gate polish, and pick the better of it and the original placement under the same evaluation caliber (never worse
1347
+ # than the baseline). Gated trigger: layouts whose hard gates already pass (the entire classic domain, existing passing cases) get
1348
+ # zero perturbation, output byte-for-byte unchanged.
1349
+ _r0 = Router({v: (pos[v], y[v], widths[v], card_h) for v in nodes},
1350
+ EDGES_REF[0], cfg.clearance, cfg.pitch)
1351
+ _routes0 = _r0.route_all()
1352
+ _pairs0 = _r0.final_crossing_pairs(_routes0)
1353
+ _clrbad0 = sum(1 for _i in _routes0.values()
1354
+ if _i.get("min_clear") is not None
1355
+ and _i["min_clear"] < cfg.clearance - 1e-6)
1356
+ _angbad0 = sum(1 for _i in _routes0.values()
1357
+ if min(_i.get("ang_a", ANCHOR_MIN_DEG),
1358
+ _i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
1359
+ _pressbad0 = sum(1 for _i in _routes0.values() if _i.get("press", 0))
1360
+ if _pairs0 > 2 or _clrbad0 or _angbad0 or _pressbad0:
1361
+ # The router layer's second move: joint rerouting of crossing pairs (breaks the mutual-local-optimum deadlock). quality=True is what
1362
+ # expands the stair candidate pool - gated path only; the normal path (including polish's _route_total) keeps the historical
1363
+ # candidate pool, zero perturbation for passing cases.
1364
+ _rq = Router({v: (pos[v], y[v], widths[v], card_h) for v in nodes},
1365
+ EDGES_REF[0], cfg.clearance, cfg.pitch, quality=True)
1366
+ _routesA = _rq.joint_reroute(dict(_routes0))
1367
+ _pairsA = _rq.final_crossing_pairs(_routesA)
1368
+ _clrbadA = sum(1 for _i in _routesA.values()
1369
+ if _i.get("min_clear") is not None
1370
+ and _i["min_clear"] < cfg.clearance - 1e-6)
1371
+ _angbadA = sum(1 for _i in _routesA.values()
1372
+ if min(_i.get("ang_a", ANCHOR_MIN_DEG),
1373
+ _i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
1374
+ _pressbadA = sum(1 for _i in _routesA.values() if _i.get("press", 0))
1375
+ _gate0 = (_pairs0, _clrbad0, _angbad0, _pressbad0)
1376
+ _best = (_gate0, None, None, None, _routes0)
1377
+ if (_pairsA, _clrbadA, _angbadA, _pressbadA) < _best[0]:
1378
+ _best = ((_pairsA, _clrbadA, _angbadA, _pressbadA), pos, y, rows, _routesA)
1379
+ # The layout layer's second move: deterministic (row, order) reorder -> 24px repack -> hard-gate polish
1380
+ # -> routing -> joint reroute
1381
+ _order2 = _reorder_rows(EDGES_REF[0], rows)
1382
+ _pos2 = _repack_x(_order2, widths, cfg)
1383
+ _y2 = dict(y)
1384
+ for _r, _vs in enumerate(_order2):
1385
+ for _v in _vs:
1386
+ _y2[_v] = cfg.top + _r * cfg.pitch
1387
+ _pos2, _sc2, _rt2 = _polish(core, EDGES_REF[0], _order2, _pos2,
1388
+ _y2, widths, card_h, cfg,
1389
+ hard_gates=True)
1390
+ # t_fce6f438: isolated nodes are not in rows (delivered on the top parking strip); the gated reorder reorders only core -
1391
+ # parking-strip coordinates are carried back as-is, so the Router's all-node keys stay complete (legacy gap: on graphs
1392
+ # with isolated nodes the gated path raised KeyError on first trigger)
1393
+ for _v in deg0:
1394
+ _pos2.setdefault(_v, pos[_v])
1395
+ _r2 = Router({v: (_pos2[v], _y2[v], widths[v], card_h)
1396
+ for v in nodes}, EDGES_REF[0], cfg.clearance, cfg.pitch,
1397
+ quality=True)
1398
+ _rt2 = _r2.route_all()
1399
+ _rt2 = _r2.joint_reroute(dict(_rt2))
1400
+ _pairs2 = _r2.final_crossing_pairs(_rt2)
1401
+ _clrbad2 = sum(1 for _i in _rt2.values()
1402
+ if _i.get("min_clear") is not None
1403
+ and _i["min_clear"] < cfg.clearance - 1e-6)
1404
+ _angbad2 = sum(1 for _i in _rt2.values()
1405
+ if min(_i.get("ang_a", ANCHOR_MIN_DEG),
1406
+ _i.get("ang_b", ANCHOR_MIN_DEG)) < ANCHOR_MIN_DEG - 1e-9)
1407
+ _pressbad2 = sum(1 for _i in _rt2.values() if _i.get("press", 0))
1408
+ if (_pairs2, _clrbad2, _angbad2, _pressbad2) < _best[0]:
1409
+ _best = ((_pairs2, _clrbad2, _angbad2, _pressbad2),
1410
+ _pos2, _y2, _order2, _rt2)
1411
+ if _best[1] is None:
1412
+ _routes_final = _best[4] # placement unchanged but the routes were swapped for better ones
1413
+ elif _best[0] < _gate0:
1414
+ pos, y, rows = _best[1], _best[2], _best[3]
1415
+ _routes_final = _best[4]
1416
+
1417
+ # ④ routing (straight/arc first-class, boxes do not press lines) + WS3 theoretical caliber (final-route crossing pairs)
1418
+ nodes_px = {v: (pos[v], y[v], widths[v], card_h) for v in nodes}
1419
+ router = Router(nodes_px, EDGES_REF[0], cfg.clearance, cfg.pitch)
1420
+ routes = router.route_all()
1421
+ if _routes_final is not None:
1422
+ routes = _routes_final
1423
+ # t_c6ab760d constructed-route injection (column closed arcs / bowtie mirror-symmetry arcs): these edges skip the candidate pool and are
1424
+ # delivered with the constructed geometry (the trial gate has already rechecked the hard gates); the injection must happen before
1425
+ # final_crossing_pairs so the dual crossing accounting (check11) includes the constructed routes.
1426
+ for i, (a, b) in enumerate(EDGES_REF[0]):
1427
+ cr = constructed.get((a, b))
1428
+ if cr is None:
1429
+ continue
1430
+ info = {"kind": cr["kind"], "points": [list(p) for p in cr["points"]],
1431
+ "press": 0, "min_clear": None, "cross": 0, "cross_edges": 0,
1432
+ "ang_a": 90.0, "ang_b": 90.0}
1433
+ if cr.get("ctrl") is not None:
1434
+ info["ctrl"] = list(cr["ctrl"])
1435
+ routes[i] = info
1436
+ # t_fad260bd: user-anchored waypoint override (demo-2d bend-handle export) - these edges skip the candidate pool; the delivered route is
1437
+ # built as "anchor = box edge aimed at the first/last waypoint + waypoints as-is", and the geometry/certificate recheck scores the same
1438
+ # points honestly (no leniency). The override must happen before final_crossing_pairs, so the dual crossing accounting (check11)
1439
+ # includes the waypoints.
1440
+ manual_wps = {}
1441
+ if manual is not None:
1442
+ manual_wps = _manual_waypoints(manual, EDGES_REF[0])
1443
+ for i, (a, b) in enumerate(EDGES_REF[0]):
1444
+ wps = manual_wps.get((a, b))
1445
+ if not wps:
1446
+ continue
1447
+ A, B = nodes_px[a], nodes_px[b]
1448
+ pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0,
1449
+ wps[0][0] - A[0], wps[0][1] - A[1])
1450
+ pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0,
1451
+ wps[-1][0] - B[0], wps[-1][1] - B[1])
1452
+ pts = [list(pa)] + [[float(px), float(py)] for px, py in wps] + [list(pb)]
1453
+ dedup = [pts[0]]
1454
+ for q in pts[1:]:
1455
+ if math.dist(dedup[-1], q) > 0.5:
1456
+ dedup.append(q)
1457
+ routes[i] = {"kind": "waypoints", "points": dedup, "press": 0,
1458
+ "min_clear": None, "cross": 0, "cross_edges": 0,
1459
+ "ang_a": 0.0, "ang_b": 0.0}
1460
+ if manual_wps and from_meta is not None:
1461
+ from_meta["waypoint_edges"] = len(manual_wps)
1462
+ # v0.7.7 per-edge forced styles (demo-2d style cycle export): "straight"
1463
+ # = direct line between box-border anchors facing each other; "arc" =
1464
+ # the deterministic forced-arc rule (_forced_arc_route). "free" is
1465
+ # carried by waypoints above. These edges skip the candidate pool; the
1466
+ # geometry/certificate recheck scores them honestly (no leniency).
1467
+ manual_styles = {}
1468
+ if manual is not None:
1469
+ manual_styles = _manual_edge_styles(manual, EDGES_REF[0])
1470
+ for i, (a, b) in enumerate(EDGES_REF[0]):
1471
+ if manual_wps.get((a, b)):
1472
+ continue
1473
+ st = manual_styles.get((a, b))
1474
+ if st == "straight":
1475
+ A, B = nodes_px[a], nodes_px[b]
1476
+ pa = border_point(A[0], A[1], A[2] / 2.0, A[3] / 2.0,
1477
+ B[0] - A[0], B[1] - A[1])
1478
+ pb = border_point(B[0], B[1], B[2] / 2.0, B[3] / 2.0,
1479
+ A[0] - B[0], A[1] - B[1])
1480
+ routes[i] = {"kind": "line", "points": [list(pa), list(pb)],
1481
+ "press": 0, "min_clear": None, "cross": 0,
1482
+ "cross_edges": 0, "ang_a": 0.0, "ang_b": 0.0}
1483
+ elif st == "arc":
1484
+ pts, ctrl = _forced_arc_route(nodes_px, a, b, cfg.clearance)
1485
+ routes[i] = {"kind": "arc", "points": [list(p) for p in pts],
1486
+ "press": 0, "min_clear": None, "cross": 0,
1487
+ "cross_edges": 0, "ang_a": 0.0, "ang_b": 0.0,
1488
+ "ctrl": list(ctrl)}
1489
+ if manual_styles and from_meta is not None:
1490
+ from_meta["styled_edges"] = len(manual_styles)
1491
+ # t_fce6f438: directed rerouting for line-line visual coincidence - a backstop fix for fan-out edges hugging the same corridor (stair/lane
1492
+ # shared lanes). Constructed routes and user-anchored waypoint edges are untouched; only edges involved in coincident pairs are rerouted,
1493
+ # on the condition that the hard gates do not regress (press/clearance/anchor/crossing count).
1494
+ _frozen = set()
1495
+ for i, (a, b) in enumerate(EDGES_REF[0]):
1496
+ if constructed.get((a, b)) is not None or manual_wps.get((a, b)) \
1497
+ or manual_styles.get((a, b)):
1498
+ _frozen.add(i)
1499
+ routes = router.coincide_reroute(routes, frozen=_frozen)
1500
+ # t_7fe64f08 connection-point caliber: delivery-layer anchors are clamped to the edge-midpoint +/-1/4 band (corners excluded, per-edge
1501
+ # hard-gate recheck); multiple anchors on the same side of the same node are then evenly spread by incidence angle (no stacking).
1502
+ # t_abe038c1: the enhanced channel (ctrl-following stair + in-band local rerouting of mutual pairs) is enabled only for spine-wing
1503
+ # placement - the ring/bowtie baselines have registered out-of-band anchors (mutual-pair baseline fallback), and the fix would change
1504
+ # their delivered bytes (including the viewBox dx shift knock-on), conflicting with this card's "the other 18 graphs byte-for-byte zero
1505
+ # drift" hard gate -> this card fixes only spine-wing (classic-cld); the other engines get their CI anchor gate locked to the v0.7.2
1506
+ # baseline ratio against regression, and out-of-band fixes go to a later card.
1507
+ routes = router.midband_reanchor(routes, frozen=_frozen,
1508
+ enhanced=(engine == "spine-construct"))
1509
+ routes = router.fan_spread(routes, frozen=_frozen)
1510
+ # t_c4b1bfa5 rework (1): mutual-pair "straight-feasible" candidates join the cost allocation (§WS3 polyline = last resort - when a
1511
+ # straight line is feasible the polyline does not steal the score). The poly side lanes produced by the in-band backstop
1512
+ # (_reroute_pair_band) are rechecked here: if a straight candidate among the 4 edge-midpoint anchors of the two boxes is feasible and
1513
+ # scores better overall, replace it (quickfix->crowding measured: a 298px polyline -> a 101px natural straight line).
1514
+ # Enabled only in the spine-construct domain (classic-cld the only hit): the other engines never enter the new code path, output
1515
+ # byte-for-byte unchanged (structural zero drift).
1516
+ if engine == "spine-construct":
1517
+ routes = router.mutual_straight_reroute(routes, frozen=_frozen)
1518
+ # t_94a585da: mutual-pair lens arrival channel -- flip the pair's shared
1519
+ # arc bulge when that turns both arrows into near-normal arrivals
1520
+ # (gate-guarded; untouched routes returned byte-for-byte otherwise).
1521
+ routes = router.mutual_arrival_flip(routes, frozen=_frozen)
1522
+ # t_51392037: default endpoint snapping to the 16 standard per-box anchor
1523
+ # set (anchors.py caliber) -- jacky 2026-11-09: the standard connection
1524
+ # points are the DEFAULT attachment caliber of every rendered endpoint,
1525
+ # not only the interactive layer. Gate-checked nearest-anchor assignment
1526
+ # with per-(node, anchor) occupancy + fixpoint sweeps (see
1527
+ # Router.anchor_snap for the algorithm path); runs across every auto
1528
+ # engine, frozen constructed/waypoint edges included -- a static render
1529
+ # may not show any off-grid endpoint.
1530
+ _density = len(EDGES_REF[0]) / max(len(nodes), 1)
1531
+ _snap_deferred = None
1532
+ if _density >= ANCHOR_SNAP_DENSE_E_OVER_N:
1533
+ # t_51392037 finalize (jacky 2026-11-09 ruling "C+B"): density-gated
1534
+ # snap probe. A static scalar density threshold cannot separate the
1535
+ # classic corpus (classic-cld E/N = 13/8 = 1.62, snaps clean -- 21/21
1536
+ # green) from the random dense n10 corpus (E/N = 16/10 = 1.60, snap
1537
+ # conflicts with the detour-poly routing: measured on n10-i05
1538
+ # visual_crossings 4 / 1 endpoint unanchored / line-line coincidence),
1539
+ # and the failing case's max degree (4) is LOWER than classic-cld's
1540
+ # (7). So the density threshold switches snap into PROBE mode: apply
1541
+ # the snap on a working copy, re-evaluate the hard gates (visual
1542
+ # crossings + line-line coincidence + full anchor membership); keep
1543
+ # the snapped routes only at zero regressions, otherwise revert to
1544
+ # the pre-snap routes and record an honest deferral (the certificate
1545
+ # reports the deferral with its measured reason and the gate stays
1546
+ # measured -- no gate fudging; dense-snap specialization is a
1547
+ # follow-up card). Low-density graphs keep the unconditional snap.
1548
+ _pre_pairs = router.final_crossing_pairs(routes)
1549
+ _pre_coincide = len(router._route_coincide_pairs(routes))
1550
+ _pre_routes = {i: _clone_route(routes[i]) for i in routes}
1551
+ _snap_report = router.anchor_snap(routes)
1552
+ _regress = []
1553
+ _post_pairs = router.final_crossing_pairs(routes)
1554
+ if _post_pairs > _pre_pairs:
1555
+ _regress.append(f"crossings {_pre_pairs}->{_post_pairs}")
1556
+ _post_coincide = len(router._route_coincide_pairs(routes))
1557
+ if _post_coincide > _pre_coincide:
1558
+ _regress.append(f"line_coincidence {_pre_coincide}->{_post_coincide}")
1559
+ if _snap_report["failures"]:
1560
+ _regress.append(f"{len(_snap_report['failures'])} endpoint(s) unanchored")
1561
+ if _regress:
1562
+ routes = _pre_routes
1563
+ _snap_deferred = {"density_en": round(_density, 2),
1564
+ "reason": _regress,
1565
+ "moved_before_revert": _snap_report["moved"]}
1566
+ print(f"[stdraw] anchor_snap DEFERRED (dense, E/N={_density:.2f}): "
1567
+ f"{'; '.join(_regress)} -- pre-snap routes kept", file=sys.stderr)
1568
+ else:
1569
+ print(f"[stdraw] anchor_snap: {_snap_report['moved']} endpoints snapped "
1570
+ f"(dense probe, E/N={_density:.2f}, zero gate regressions)",
1571
+ file=sys.stderr)
1572
+ else:
1573
+ _snap_report = router.anchor_snap(routes)
1574
+ if _snap_report["failures"]:
1575
+ print(f"[stdraw] anchor_snap: {_snap_report['moved']} endpoints snapped, "
1576
+ f"{len(_snap_report['failures'])} endpoint(s) could not be placed "
1577
+ f"on a standard anchor: {_snap_report['failures'][:5]}",
1578
+ file=sys.stderr)
1579
+ # t_51392037: re-run the lens arrival channel AFTER snapping -- snap moves
1580
+ # lens arc endpoints/ctrl, so the arrival-normal caliber (<=10 deg) and the
1581
+ # pair separation may degrade. The flip re-derives ctrl only (endpoints
1582
+ # stay on their anchors -> anchors_standard membership preserved) and is
1583
+ # strict-improvement + hard-gate guarded; enhanced_gates switches to the
1584
+ # anchor-snap gate accounting (see Router.mutual_arrival_flip). Skipped
1585
+ # on deferral: the reverted routes already passed the pre-snap flip above,
1586
+ # and re-flipping unsnapped routes under the enhanced accounting would
1587
+ # perturb the honest pre-snap state.
1588
+ if _snap_deferred is None:
1589
+ routes = router.mutual_arrival_flip(routes, enhanced_gates=True)
1590
+ router_pairs = router.final_crossing_pairs(routes)
1591
+
1592
+ # t_c6ab760d R/B loop coloring: edge -> the set of declared-loop kinds it belongs to (deterministic rules in docs/layout-modes.md):
1593
+ # a single loop in a single color (R=amber/B=sky blue); multiple loops of the same color take that color; multiple loops of different
1594
+ # colors or no loop -> neutral (rendering layer slate); polarity (+/-) data unchanged.
1595
+ _eset_dir = {(e.a, e.b) for e in graph.edges}
1596
+ _edge_loops = defaultdict(set)
1597
+ for lp in getattr(graph, "loops", []):
1598
+ seq = list(lp.nodes)
1599
+ kind = getattr(lp, "kind", None)
1600
+ if not kind:
1601
+ continue
1602
+ for i in range(len(seq)):
1603
+ a, b = seq[i], seq[(i + 1) % len(seq)]
1604
+ if (a, b) in _eset_dir:
1605
+ _edge_loops[(a, b)].add(kind)
1606
+ elif (b, a) in _eset_dir:
1607
+ _edge_loops[(b, a)].add(kind)
1608
+ _LOOP_COLORS = {"R": "#d97706", "B": "#0284c7"} # R=amber, B=sky blue (online convention)
1609
+
1610
+ edges_out = []
1611
+ for i, (a, b) in enumerate(EDGES_REF[0]):
1612
+ info = routes[i]
1613
+ kinds = sorted(_edge_loops.get((a, b), ()))
1614
+ loop_color = (_LOOP_COLORS[kinds[0]] if len(set(kinds)) == 1
1615
+ else None)
1616
+ e = {"a": a, "b": b, "polarity": getattr(graph.edges[i], "polarity", "+"),
1617
+ "loop_kinds": kinds,
1618
+ "loop_color": loop_color,
1619
+ "route": {"kind": info["kind"],
1620
+ "points": [[round(px, 1), round(py, 1)]
1621
+ for px, py in info["points"]]},
1622
+ "anchors": {"a": list(info["points"][0]), "b": list(info["points"][-1])},
1623
+ "arrow": [round(info["points"][-1][0], 1), round(info["points"][-1][1], 1)],
1624
+ "route_cross_edges": int(info.get("cross_edges", 0))} # diagnostic value from the evaluation period
1625
+ if info["kind"] == "arc":
1626
+ e["route"]["arc"] = True
1627
+ e["route"]["ctrl"] = [round(c, 1) for c in info.get("ctrl", info["points"][len(info["points"]) // 2])]
1628
+ if info["kind"] == "vg":
1629
+ # stash raw corner/arc parameters; the SVG path is generated after the dx shift (see finalize below)
1630
+ e["_vg"] = ([list(p) for p in info["vg_corners"]],
1631
+ [dict(f) for f in info["vg_fillets"]])
1632
+ if info["kind"] == "waypoints":
1633
+ # t_fad260bd: user-anchored bends (the via is generated on delivered coordinates after the dx/dy shift, see finalize below
1634
+ # - isomorphic to vg's _vg handling)
1635
+ e["_wp"] = [[float(px), float(py)] for px, py in info["points"]]
1636
+ edges_out.append(e)
1637
+
1638
+ xs_all = [pos[v] - widths[v] / 2 for v in nodes] + [pos[v] + widths[v] / 2 for v in nodes]
1639
+ ys_all = [y[v] - card_h / 2 for v in nodes] + [y[v] + card_h / 2 for v in nodes]
1640
+
1641
+ # F3 rework (t_02e60e33, bullwhip check7 fail): an arc bulge can cross the node-box outer envelope (measured: inventory->forecast
1642
+ # bulged 7.5px left past the viewBox left edge, while since v0.2 the viewBox is computed from node boxes only). Edge geometry (sampled
1643
+ # points plus arc ctrl - a quadratic-Bezier convex-hull upper bound, conservative) must enter the shift amounts and the W/H
1644
+ # computation together with the node boxes; routing-layer decisions are untouched, the viewBox is only widened at the delivery layer,
1645
+ # constructively consistent with check7's "all content inside the viewBox" validation caliber (which reads the delivered route.points
1646
+ # directly).
1647
+ def _edge_geom_bounds():
1648
+ exs, eys = [], []
1649
+ for e in edges_out:
1650
+ for px, py in e["route"]["points"]:
1651
+ exs.append(px)
1652
+ eys.append(py)
1653
+ c = e["route"].get("ctrl")
1654
+ if c:
1655
+ exs.append(c[0])
1656
+ eys.append(c[1])
1657
+ return exs, eys
1658
+
1659
+ exs0, eys0 = _edge_geom_bounds()
1660
+ min_x = min(xs_all + exs0) - cfg.margin / 2
1661
+ dx = max(0, int(math.ceil(cfg.margin / 2 - min_x)))
1662
+ dx = ((dx + 23) // 24) * 24 # shift aligned to 24px, keeping the column snap/grid unchanged
1663
+ min_y = min(ys_all + eys0) - cfg.margin / 2
1664
+ dy = max(0, int(math.ceil(cfg.margin / 2 - min_y)))
1665
+ dy = ((dy + 7) // 8) * 8 # 8px alignment: keeps the 2px grid and the H rounding caliber
1666
+ if dx or dy:
1667
+ for e in edges_out:
1668
+ e["route"]["points"] = [[px + dx, py + dy] for px, py in e["route"]["points"]]
1669
+ e["anchors"] = {"a": [e["anchors"]["a"][0] + dx, e["anchors"]["a"][1] + dy],
1670
+ "b": [e["anchors"]["b"][0] + dx, e["anchors"]["b"][1] + dy]}
1671
+ e["arrow"][0] += dx
1672
+ e["arrow"][1] += dy
1673
+ if "ctrl" in e["route"]: # bugfix: arc control points must shift with dx/dy
1674
+ e["route"]["ctrl"][0] += dx
1675
+ e["route"]["ctrl"][1] += dy
1676
+ for v in nodes:
1677
+ pos[v] += dx
1678
+ y[v] += dy
1679
+ # t_c6ab760d: absolute axis coordinates in the placement metadata shift in sync (symmetry axis / column axis)
1680
+ if isinstance(pos_meta, dict):
1681
+ if isinstance(pos_meta.get("symmetry"), dict):
1682
+ pos_meta["symmetry"]["axis_x"] = \
1683
+ pos_meta["symmetry"].get("axis_x", 0) + dx
1684
+ pos_meta["symmetry"]["axis_y"] = \
1685
+ pos_meta["symmetry"].get("axis_y", 0) + dy
1686
+ if isinstance(pos_meta.get("column"), dict):
1687
+ pos_meta["column"]["axis_x"] = \
1688
+ pos_meta["column"].get("axis_x", 0) + dx
1689
+ # t_8f211f74 spine-wing: the vertical axis / midline axis shift in sync with the translation
1690
+ if isinstance(pos_meta.get("spine_wing"), dict):
1691
+ pos_meta["spine_wing"]["axis_x"] = \
1692
+ pos_meta["spine_wing"].get("axis_x", 0) + dx
1693
+ pos_meta["spine_wing"]["axis_y"] = \
1694
+ pos_meta["spine_wing"].get("axis_y", 0) + dy
1695
+ # v0.7.7 comb-bilateral: the column axis / hub row shift in sync
1696
+ if isinstance(pos_meta.get("comb"), dict):
1697
+ pos_meta["comb"]["axis_x"] = \
1698
+ pos_meta["comb"].get("axis_x", 0) + dx
1699
+ pos_meta["comb"]["hub_y"] = \
1700
+ pos_meta["comb"].get("hub_y", 0) + dy
1701
+
1702
+ # W/H must be taken on the final post-shift coordinates. bugfix (a v0.2.0 leftover exposed by CI): the original implementation computed
1703
+ # W from the pre-shift xs_all; with dx>0 the content overflowed the viewBox after the right shift (analytic box widths are narrower
1704
+ # without fonts -> the min left edge violates and triggers a large dx -> the right edge spills). F3 rework addendum: max also includes
1705
+ # edge geometry, so arc/vg bulges must not cross the right/bottom edges.
1706
+ xs_all = [pos[v] - widths[v] / 2 for v in nodes] + [pos[v] + widths[v] / 2 for v in nodes]
1707
+ ys_all = [y[v] - card_h / 2 for v in nodes] + [y[v] + card_h / 2 for v in nodes]
1708
+ exs1, eys1 = _edge_geom_bounds()
1709
+ W = int(math.ceil(max(xs_all + exs1))) + cfg.margin
1710
+ H = int(math.ceil(max(ys_all + eys1))) + cfg.margin
1711
+ W = ((W + 7) // 8) * 8
1712
+ H = ((H + 7) // 8) * 8
1713
+
1714
+ # WS3 finalize: vg routes generate the exact SVG path (L+A commands) on the delivered post-shift coordinates
1715
+ from .visibility import route_svg_d as _route_svg_d
1716
+ for e in edges_out:
1717
+ vg = e.pop("_vg", None)
1718
+ if vg:
1719
+ corners, fillets = vg
1720
+ corners = [[p[0] + dx, p[1] + dy] for p in corners]
1721
+ fillets = [dict(f, ta=[f["ta"][0] + dx, f["ta"][1] + dy],
1722
+ tb=[f["tb"][0] + dx, f["tb"][1] + dy],
1723
+ center=[f["center"][0] + dx, f["center"][1] + dy])
1724
+ for f in fillets]
1725
+ e["route"]["path_d"] = _route_svg_d(corners, fillets)
1726
+ e["route"]["via"] = [[round(px, 1), round(py, 1)] for px, py in corners]
1727
+ wp = e.pop("_wp", None)
1728
+ if wp:
1729
+ # t_fad260bd: user-anchored bends enter delivery (post-shift coordinates; rendering draws a polyline)
1730
+ e["route"]["via"] = [[round(p[0] + dx, 1), round(p[1] + dy, 1)]
1731
+ for p in wp[1:-1]]
1732
+
1733
+ if manual is not None:
1734
+ row_y_out = [yy + dy for yy in sorted({y[v] for v in nodes})]
1735
+ pitch_out = None
1736
+ elif engine in ("ring-construct", "comb-construct"):
1737
+ # ring/comb placement have no uniform-row semantics: rowY records the
1738
+ # deduplicated order of actual y values; pitch does not apply
1739
+ row_y_out = [yy + dy for yy in sorted({y[v] for v in nodes})]
1740
+ pitch_out = None
1741
+ else:
1742
+ row_y_out = [dy + cfg.top + r * cfg.pitch for r in range(len(rows))]
1743
+ pitch_out = cfg.pitch
1744
+ # t_cbfca790: assemble the mode decision trace (certificate mode_decision_trace).
1745
+ # The anchor-band legacy exemption (v0.7.3 registered baselines): the
1746
+ # enhanced mid-band re-anchor + mutual-straight reroute channels run only
1747
+ # for spine-wing; every other engine keeps its registered v0.7.2 baseline
1748
+ # in-band ratio (CI BASELINE_BAND lock) instead of the enhanced fix — that
1749
+ # registered exemption is disclosed here rather than silently applied.
1750
+ _engine_placement = {"sym-construct": "symmetric",
1751
+ "bowtie-construct": "bowtie-vertex",
1752
+ "spine-construct": "spine-wing",
1753
+ "comb-construct": "comb-bilateral",
1754
+ "column-construct": "column-chain",
1755
+ "ring-construct": "ring-cycle",
1756
+ "manual": "manual"}
1757
+ _final_placement = ((pos_meta.get("placement")
1758
+ if isinstance(pos_meta, dict) else None)
1759
+ or _engine_placement.get(engine, "layered"))
1760
+ if engine == "manual":
1761
+ _mode_trace = {"engine": "manual", "placement": None, "manual": True,
1762
+ "rules": [],
1763
+ "fallback": {"used": False, "reason": None},
1764
+ "legacy_band_exemption": {
1765
+ "triggered": False,
1766
+ "detail": ("manual layout: the auto mode chain did "
1767
+ "not run; routing hard gates still "
1768
+ "apply, graded honestly")}}
1769
+ else:
1770
+ _legacy = engine != "spine-construct"
1771
+ _mode_trace = {
1772
+ "engine": engine, "placement": _final_placement, "manual": False,
1773
+ "rules": _mode_rules,
1774
+ "fallback": {"used": engine not in _engine_placement,
1775
+ "reason": ("no structural mode triggered — layered "
1776
+ "path" if engine not in _engine_placement
1777
+ else None)},
1778
+ "legacy_band_exemption": {
1779
+ "triggered": _legacy,
1780
+ "detail": (f"{_final_placement}: keeps the registered v0.7.2 "
1781
+ "anchor-band baseline ratios (CI BASELINE_BAND "
1782
+ "lock); the enhanced mid-band re-anchor + "
1783
+ "mutual-straight reroute channels are spine-wing "
1784
+ "only"
1785
+ if _legacy else
1786
+ "spine-wing: 100% in-band enforced (enhanced "
1787
+ "re-anchor channel active, no legacy exemption)")},
1788
+ }
1789
+ v4 = {
1790
+ "viewBox": [0, 0, W, H],
1791
+ "nodes": {v: {"x": pos[v], "y": y[v], "w": widths[v]} for v in nodes},
1792
+ "edges": edges_out,
1793
+ "bars": {},
1794
+ "meta": {
1795
+ "rows": len(row_y_out),
1796
+ "rowY": row_y_out,
1797
+ "pitch": pitch_out,
1798
+ "deg0": deg0,
1799
+ "cardH": card_h,
1800
+ "fs": [cfg.fs_title, cfg.fs_value],
1801
+ "recipe": "auto-v2",
1802
+ "line_style": linestyle,
1803
+ "position_engine": engine,
1804
+ # t_cbfca790: deterministic mode-decision audit trail (which rule
1805
+ # hit, why, trial gate, legacy band exemption) — see the certificate
1806
+ # field of the same name.
1807
+ "mode_decision_trace": _mode_trace,
1808
+ # --from-layout metadata (written only on the manual path; the auto path is byte-for-byte unchanged)
1809
+ **({} if from_meta is None else {"from_layout": from_meta}),
1810
+ "clearance": CLEARANCE,
1811
+ "router_cross_pairs": router_pairs,
1812
+ # t_51392037 finalize: honest dense-snap deferral record (None
1813
+ # when snap applied) -- certificate annotates the gate with it.
1814
+ "anchor_snap_deferred": _snap_deferred,
1815
+ "solver_available": _has_solver(),
1816
+ **pos_meta,
1817
+ "routing_math": ("visibility-graph Dijkstra shortest path (clearance-"
1818
+ "inflated obstacles) + tangent-arc fillet smoothing "
1819
+ "(G1, clearance-verified); crossing dual-caliber: "
1820
+ "router theory vs Liang-Barsky delivered geometry"),
1821
+ "method": ("layered + crossing-minimization (CP-SAT if available) "
1822
+ "with straight/arc first-class routing and visibility-graph "
1823
+ "detour; hard gates: box-over-line=0, edge-box clearance>=16px"),
1824
+ },
1825
+ }
1826
+ from .delivery import stamp_coordinates_only
1827
+ return stamp_coordinates_only(v4)