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/ringlayout.py ADDED
@@ -0,0 +1,475 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Loop-dominated ring placement (2026-11-05 product decision, t_af83815d).
3
+
4
+ > "When a balancing loop forms, a ring helps people understand the cycle
5
+ > better, and the figure is more beautiful too."
6
+
7
+ Trigger detection (graph-theoretic metrics, not feelings; fully
8
+ deterministic):
9
+ 1. Small graph: 3 <= n <= 12 (n includes isolated nodes);
10
+ 2. Main ring: a simple loop covering >= n-2 nodes exists; or ring
11
+ edges (non-bridge edges) make up >= 0.9 of undirected edges;
12
+ 3. Pure ring (single connected component, all degree 2): take that
13
+ ring directly (reusing the WS2 cycle regular-polygon slot
14
+ construction; the construction itself unchanged).
15
+
16
+ Main-ring selection: candidates = declared loops (DSL loops that form
17
+ a simple loop, keeping the declared order — R/B polarity badges stay
18
+ readable around the ring) ∪ graph-theoretically enumerated simple
19
+ loops (canonical-form dedup). Among candidates covering >= n-2,
20
+ selection is deterministic by "zero chord crossings first -> most
21
+ coverage -> canonical-form lexicographic order".
22
+
23
+ Placement (place_ring):
24
+ - Main-ring nodes go into regular-polygon slots in ring order
25
+ (same-source construction as placement._place_cycle: evenly
26
+ divided angles i·2π/k − π/2, quantized to the 2px grid; radius
27
+ adapts to adjacent card-width needs, upper bound constrained by
28
+ the check8 1280x800 rendering-ratio tier);
29
+ - Residual nodes (chord tails / external variables): toward the
30
+ centroid direction of ring neighbors, a fixed fractional in-ring
31
+ candidate order; on card-gap conflicts fall back to the near side
32
+ outside the ring; isolated nodes park in the top parking strip;
33
+ - Radius need above the upper bound -> InfeasibleRing (the caller
34
+ falls back to the existing layered path, byte-for-byte
35
+ unchanged).
36
+
37
+ Routing/certificate: delegated to the existing Router and
38
+ certificate_auto; the ring-mode metric criteria (equal-spacing CV on
39
+ the ring + regular-polygon template fitting error, replacing row
40
+ alignment / column snapping) switch inside metrics/check on
41
+ meta.placement == "ring-cycle".
42
+ """
43
+ import math
44
+ from collections import defaultdict
45
+
46
+ MAX_RING_N = 12 # trigger upper bound: small graphs
47
+ COVER_SLACK = 2 # main ring covers >= n - 2
48
+ CYCLE_EDGE_RATIO = 0.90 # ring-edge (non-bridge) ratio threshold (OR branch)
49
+ DFS_STEP_CAP = 2_000_000 # simple-loop enumeration step cap (deterministic: operation-count criterion)
50
+ RADIUS_MIN = 150
51
+ RADIUS_MAX = 320 # radius upper bound for check8 rendering ratio >= 0.85 (2r+card+edge geometry <= ~780px)
52
+ ADJ_GAP = 60 # center-distance margin between adjacent ring cards (>= 16 gate + routing channel)
53
+
54
+
55
+ class InfeasibleRing(Exception):
56
+ """Ring slot infeasible (radius need above the upper bound) — the caller falls back to the existing layered path."""
57
+
58
+
59
+ def _adjacency(edges):
60
+ adj = defaultdict(set)
61
+ pairs = set()
62
+ for a, b in edges:
63
+ adj[a].add(b)
64
+ adj[b].add(a)
65
+ pairs.add((a, b) if a < b else (b, a))
66
+ return adj, pairs
67
+
68
+
69
+ def _canonical(seq):
70
+ """Canonical form of a ring sequence: lexicographically smallest over all rotations x both directions (for dedup/ordering)."""
71
+ best = None
72
+ for s in (list(seq), list(seq)[::-1]):
73
+ for i in range(len(s)):
74
+ rot = s[i:] + s[:i]
75
+ if best is None or rot < best:
76
+ best = rot
77
+ return tuple(best)
78
+
79
+
80
+ def _seg_cross(p1, p2, p3, p4):
81
+ """Whether two segments properly cross (endpoint touching / collinear overlap excluded; straight-line proxy convention)."""
82
+
83
+ def orient(a, b, c):
84
+ v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
85
+ if abs(v) < 1e-9:
86
+ return 0
87
+ return 1 if v > 0 else -1
88
+
89
+ o1, o2 = orient(p1, p2, p3), orient(p1, p2, p4)
90
+ o3, o4 = orient(p3, p4, p1), orient(p3, p4, p2)
91
+ return o1 * o2 < 0 and o3 * o4 < 0
92
+
93
+
94
+ def _crossing_count(segments):
95
+ """Count of proper pairwise crossings (segments: (p1, p2, endpoint_ids); shared endpoints skipped)."""
96
+ n = len(segments)
97
+ c = 0
98
+ for i in range(n):
99
+ for j in range(i + 1, n):
100
+ a1, b1, e1 = segments[i]
101
+ a2, b2, e2 = segments[j]
102
+ if e1[0] in e2 or e1[1] in e2:
103
+ continue
104
+ if _seg_cross(a1, b1, a2, b2):
105
+ c += 1
106
+ return c
107
+
108
+
109
+ class _CapExceeded(Exception):
110
+ pass
111
+
112
+
113
+ def _enumerate_cycles(nodes, adj):
114
+ """Enumerate all simple loops (canonical-form dedup; step-cap protection — stop at the cap and use what was found)."""
115
+ steps = [0]
116
+ found = {}
117
+
118
+ def dfs(start, v, path, seen):
119
+ steps[0] += 1
120
+ if steps[0] > DFS_STEP_CAP:
121
+ raise _CapExceeded
122
+ for w in sorted(adj[v]):
123
+ if w == start and len(path) >= 3:
124
+ can = _canonical(path)
125
+ if can not in found:
126
+ found[can] = can
127
+ elif w not in seen:
128
+ seen.add(w)
129
+ dfs(start, w, path + [w], seen)
130
+ seen.remove(w)
131
+
132
+ try:
133
+ for s in sorted(nodes):
134
+ dfs(s, s, [s], {s})
135
+ except _CapExceeded:
136
+ pass
137
+ return [list(c) for c in found.values()]
138
+
139
+
140
+ def _declared_loop_cycles(graph, adj):
141
+ """Declared loops that form simple loops. Returns [(loop_id, kind, declared loop order)]."""
142
+ out = []
143
+ for lp in graph.loops:
144
+ seq = list(lp.nodes)
145
+ if len(seq) < 3 or len(set(seq)) != len(seq):
146
+ continue
147
+ if not all(seq[(i + 1) % len(seq)] in adj[seq[i]] for i in range(len(seq))):
148
+ continue
149
+ out.append((lp.id, getattr(lp, "kind", None), seq))
150
+ return out
151
+
152
+
153
+ def _is_pure_cycle(nodes, adj):
154
+ if len(nodes) < 3 or any(len(adj[v]) != 2 for v in nodes):
155
+ return False
156
+ seen, stack = {min(nodes)}, [min(nodes)]
157
+ while stack:
158
+ x = stack.pop()
159
+ for w in adj[x]:
160
+ if w not in seen:
161
+ seen.add(w)
162
+ stack.append(w)
163
+ return len(seen) == len(nodes)
164
+
165
+
166
+ def _pure_cycle_order(nodes, adj):
167
+ """Pure-ring order: smallest id as start, first step toward the smaller neighbor (deterministic)."""
168
+ start = min(nodes)
169
+ order = [start]
170
+ prev, cur = None, start
171
+ while True:
172
+ nxts = sorted(w for w in adj[cur] if w != prev)
173
+ if not nxts:
174
+ return None
175
+ step = nxts[0]
176
+ if step == start:
177
+ break
178
+ order.append(step)
179
+ prev, cur = cur, step
180
+ if len(order) > len(nodes):
181
+ return None
182
+ return order if len(order) == len(nodes) else None
183
+
184
+
185
+ def _non_bridge_ratio(nodes, adj, pairs):
186
+ """Ring-edge ratio = non-bridge edges / undirected edge count (a bridge = removing it increases the component count)."""
187
+ def ncomp(adj2):
188
+ seen, c = set(), 0
189
+ for s in nodes:
190
+ if s in seen:
191
+ continue
192
+ c += 1
193
+ st = [s]
194
+ seen.add(s)
195
+ while st:
196
+ x = st.pop()
197
+ for y in adj2[x]:
198
+ if y not in seen:
199
+ seen.add(y)
200
+ st.append(y)
201
+ return c
202
+
203
+ base = ncomp(adj)
204
+ nb = 0
205
+ for e in sorted(pairs):
206
+ adj2 = defaultdict(set)
207
+ for v in nodes:
208
+ for w in adj[v]:
209
+ if ((v, w) if v < w else (w, v)) != e:
210
+ adj2[v].add(w)
211
+ adj2[w].add(v)
212
+ if ncomp(adj2) == base:
213
+ nb += 1
214
+ return nb / len(pairs) if pairs else 0.0
215
+
216
+
217
+ def _chord_segments(ring, edges):
218
+ """Unit-circle straight segments of in-ring chord edges (ring-ring non-ring edges) (crossing proxy)."""
219
+ rset = set(ring)
220
+ rpos = {v: (math.cos(2 * math.pi * i / len(ring) - math.pi / 2),
221
+ math.sin(2 * math.pi * i / len(ring) - math.pi / 2))
222
+ for i, v in enumerate(ring)}
223
+ cyc_pairs = {(ring[i], ring[(i + 1) % len(ring)]) for i in range(len(ring))}
224
+ cyc_pairs |= {(b, a) for a, b in cyc_pairs}
225
+ segs, seen = [], set()
226
+ for a, b in edges:
227
+ if a not in rset or b not in rset:
228
+ continue
229
+ p = (a, b) if a < b else (b, a)
230
+ if p in cyc_pairs or p in seen:
231
+ continue
232
+ seen.add(p)
233
+ segs.append((rpos[a], rpos[b], (a, b)))
234
+ return segs
235
+
236
+
237
+ def detect_ring(graph, why=None):
238
+ """Trigger detection + main-ring selection. Returns a ring plan dict or
239
+ None (no trigger).
240
+
241
+ plan = {"ring": [ring order], "k": int, "declared_loop": id|None,
242
+ "polarity": "R"|"B"|None, "trigger": "pure-cycle"|"cover"|"ratio",
243
+ "residuals": [non-ring non-isolated nodes], "deg0": [isolated nodes],
244
+ "chord_crossings": int}
245
+
246
+ why: optional dict — receives a deterministic one-line reason for the
247
+ outcome (mode decision trace, certificate mode_decision_trace field).
248
+ """
249
+ nodes = list(graph.node_ids())
250
+ n = len(nodes)
251
+ if n < 3 or n > MAX_RING_N:
252
+ if why is not None:
253
+ why["reason"] = (f"n={n} (need 3..{MAX_RING_N})")
254
+ return None
255
+ edges = [(e.a, e.b) for e in graph.edges]
256
+ adj, pairs = _adjacency(edges)
257
+ core = [v for v in nodes if adj[v]]
258
+ deg0 = [v for v in nodes if not adj[v]]
259
+
260
+ if _is_pure_cycle(core, adj):
261
+ seq = _pure_cycle_order(core, adj)
262
+ if seq:
263
+ if why is not None:
264
+ why["reason"] = f"pure cycle, all degree-2, k=n={n}"
265
+ return {"ring": seq, "k": len(seq), "declared_loop": None,
266
+ "polarity": None, "trigger": "pure-cycle",
267
+ "residuals": [], "deg0": deg0,
268
+ "chord_crossings": 0}
269
+
270
+ cands = [] # [(ring order, loop_id, kind)]
271
+ for lid, kind, seq in _declared_loop_cycles(graph, adj):
272
+ cands.append((seq, lid, kind))
273
+ for seq in _enumerate_cycles(core, adj):
274
+ cands.append((seq, None, None))
275
+ if not cands:
276
+ if why is not None:
277
+ why["reason"] = ("no simple loop (declared or enumerated) — "
278
+ "not loop-dominated")
279
+ return None
280
+
281
+ ratio = _non_bridge_ratio(nodes, adj, pairs)
282
+ cover_ok = any(len(s) >= n - COVER_SLACK for s, _, _ in cands)
283
+ if not (cover_ok or ratio >= CYCLE_EDGE_RATIO):
284
+ if why is not None:
285
+ why["reason"] = (f"not loop-dominated: main-ring coverage < "
286
+ f"n-{COVER_SLACK} and non-bridge edge ratio "
287
+ f"{ratio:.2f} < {CYCLE_EDGE_RATIO}")
288
+ return None
289
+
290
+ eligible = [(s, lid, pol) for s, lid, pol in cands
291
+ if len(s) >= n - COVER_SLACK] or cands
292
+ if not cover_ok:
293
+ # v0.7.7 ratio-branch guard (K_{m,2} misfire): a high non-bridge
294
+ # ratio whose best ring is only k=4 is the bilateral-comb signature
295
+ # (two hubs + two sources forming a 4-cycle, everything else hanging
296
+ # off it) — that family belongs to comb-bilateral placement, not
297
+ # ring. Non-comb graphs keep the historical ratio behavior (e.g.
298
+ # classic-cld: ratio trigger with k=4 is legitimate). Probe with the
299
+ # comb detector to draw the line structurally, not by k alone.
300
+ kmax = max(len(s) for s, _, _ in eligible)
301
+ if kmax <= 4:
302
+ from .comblayout import detect_comb
303
+ if detect_comb(graph) is not None:
304
+ if why is not None:
305
+ why["reason"] = ("ratio branch with best ring k<=4 and "
306
+ "the bilateral-comb signature — that "
307
+ "family belongs to comb-bilateral")
308
+ return None
309
+ best = None
310
+ for seq, lid, pol in eligible:
311
+ cross = _crossing_count(_chord_segments(seq, edges))
312
+ # Main ring = max-coverage ring (the truly dominant loop); same coverage: zero chord crossings first -> lexicographic
313
+ key = (-len(seq), cross, _canonical(seq))
314
+ if best is None or key < best[0]:
315
+ best = (key, seq, lid, pol, cross)
316
+ _, ring, lid, pol, cross = best
317
+ residuals = [v for v in core if v not in set(ring)]
318
+ if why is not None:
319
+ why["reason"] = (f"loop-dominated (trigger="
320
+ f"{'cover' if cover_ok else 'ratio'}), main ring "
321
+ f"k={len(ring)}, chord crossings={cross}")
322
+ return {"ring": list(ring), "k": len(ring), "declared_loop": lid,
323
+ "polarity": pol, "trigger": "cover" if cover_ok else "ratio",
324
+ "residuals": residuals, "deg0": deg0, "chord_crossings": cross}
325
+
326
+
327
+ def _ring_radius(k, ring_widths, n_residuals=0):
328
+ """Regular k-gon radius: adjacent card center distance >= (w_u+w_v)/2 +
329
+ ADJ_GAP; above the upper bound is infeasible.
330
+
331
+ With n_residuals > 0 the radius is raised to make room inside the
332
+ ring (+40px per residual node, within the upper bound).
333
+ """
334
+ need = 0.0
335
+ for i in range(k):
336
+ wu = ring_widths.get(i, 96)
337
+ wv = ring_widths.get((i + 1) % k, 96)
338
+ need = max(need, (wu + wv) / 2 + ADJ_GAP)
339
+ r = need / (2 * math.sin(math.pi / k))
340
+ r = max(r, RADIUS_MIN + 40 * n_residuals)
341
+ if r > RADIUS_MAX:
342
+ raise InfeasibleRing(f"radius requirement {r:.0f}px exceeds the upper bound {RADIUS_MAX}")
343
+ return max(RADIUS_MIN, int(math.ceil(r / 2) * 2))
344
+
345
+
346
+ # Deterministic residual candidate family: in-ring fractions x angle offsets (fixed order: near-center first, zero offset first)
347
+ _INNER_FS = (0.42, 0.34, 0.50, 0.26, 0.58, 0.18, 0.66)
348
+ _ANGLE_DS = (0.0, 20.0, -20.0, 40.0, -40.0, 60.0, -60.0, 90.0, -90.0, 120.0, -120.0)
349
+
350
+
351
+ def place_ring(plan, widths, card_h, cfg, adj, edges):
352
+ """Ring placement -> ({v: x}, {v: y}, meta). All-integer 2px grid,
353
+ deterministic.
354
+
355
+ Residual assignment: candidates = in-ring fraction x angle offset
356
+ (ring-neighbor centroid at 0°); each residual (id order) takes the
357
+ collision-free candidate with "fewest new straight-proxy crossings
358
+ -> smallest angle offset -> fraction closest to 0.42"; if all
359
+ in-ring candidates collide, try the near side outside the ring;
360
+ still none -> InfeasibleRing. edges: used for straight-proxy
361
+ crossing scoring (ring edges + chords + spokes of already-placed
362
+ residuals).
363
+ """
364
+ ring, k = plan["ring"], plan["k"]
365
+ ring_widths = {i: widths.get(v, 96) for i, v in enumerate(ring)}
366
+ r = _ring_radius(k, ring_widths, len(plan["residuals"]))
367
+ cx = int(r // 2) * 2
368
+ cy = int((r + card_h // 2 + 40) // 2) * 2
369
+ pos, y, angles = {}, {}, {}
370
+ for i, v in enumerate(ring):
371
+ ang = 2 * math.pi * i / k - math.pi / 2
372
+ angles[v] = ang
373
+ pos[v] = int(round((cx + r * math.cos(ang)) / 2) * 2)
374
+ y[v] = int(round((cy + r * math.sin(ang)) / 2) * 2)
375
+
376
+ def _rect(v, px, py):
377
+ w = widths.get(v, 96)
378
+ return (px - w / 2, py - card_h / 2, px + w / 2, py + card_h / 2)
379
+
380
+ placed = {v: _rect(v, pos[v], y[v]) for v in ring}
381
+
382
+ def _free(v, px, py, gap=18):
383
+ rx0, ry0, rx1, ry1 = _rect(v, px, py)
384
+ for ux0, uy0, ux1, uy1 in placed.values():
385
+ if rx0 < ux1 + gap and ux0 < rx1 + gap and ry0 < uy1 + gap and uy0 < ry1 + gap:
386
+ return False
387
+ return True
388
+
389
+ # Straight-proxy segments (for crossing scoring): ring edges + in-ring chords
390
+ segs = []
391
+ rpos = {v: (pos[v], y[v]) for v in ring}
392
+ for i in range(k):
393
+ a, b = ring[i], ring[(i + 1) % k]
394
+ segs.append((rpos[a], rpos[b], frozenset((a, b))))
395
+ cyc_pairs = {(ring[i], ring[(i + 1) % k]) for i in range(k)}
396
+ cyc_pairs |= {(b, a) for a, b in cyc_pairs}
397
+ rset = set(ring)
398
+ seen_e = set()
399
+ for a, b in edges:
400
+ if a in rset and b in rset:
401
+ p = (a, b) if a < b else (b, a)
402
+ if p not in cyc_pairs and p not in seen_e:
403
+ seen_e.add(p)
404
+ segs.append((rpos[a], rpos[b], frozenset((a, b))))
405
+
406
+ def _add_cross(px, py, v):
407
+ """New straight-proxy crossings added by placing v at candidate (px,py) (against placed neighbors)."""
408
+ c = 0
409
+ for u in sorted(adj[v]):
410
+ if u not in pos:
411
+ continue
412
+ seg = ((px, py), (pos[u], y[u]), frozenset((v, u)))
413
+ for q1, q2, eids in segs:
414
+ if v in eids or u in eids:
415
+ continue
416
+ if _seg_cross(seg[0], seg[1], q1, q2):
417
+ c += 1
418
+ return c
419
+
420
+ residual_pos = {}
421
+ for v in sorted(plan["residuals"]):
422
+ nbrs = [angles[u] for u in sorted(adj[v]) if u in angles]
423
+ ang0 = (math.atan2(sum(math.sin(a) for a in nbrs),
424
+ sum(math.cos(a) for a in nbrs))
425
+ if nbrs else 0.0)
426
+ best = None
427
+ for zone in ("inner", "outer"):
428
+ for f in (_INNER_FS if zone == "inner"
429
+ else (1.0 + (card_h / 2 + 40 + 40 * i) / r for i in range(3))):
430
+ for d in _ANGLE_DS:
431
+ ang = ang0 + math.radians(d)
432
+ px = int(round((cx + f * r * math.cos(ang)) / 2) * 2)
433
+ py = int(round((cy + f * r * math.sin(ang)) / 2) * 2)
434
+ if not _free(v, px, py):
435
+ continue
436
+ cross = _add_cross(px, py, v)
437
+ key = (cross, abs(d), abs(f - 0.42) if zone == "inner" else 1.0 + abs(f - 1.3),
438
+ px, py)
439
+ if best is None or key < best[0]:
440
+ best = (key, px, py, zone, f)
441
+ if best is not None and best[0][0] == 0 and zone == "inner":
442
+ break # zero crossings inside the ring already; no need to scan further
443
+ if best is not None:
444
+ break
445
+ if best is None:
446
+ raise InfeasibleRing(f"residual node {v} has no collision-free candidate")
447
+ _, px, py, zone, f = best
448
+ pos[v], y[v] = px, py
449
+ placed[v] = _rect(v, px, py)
450
+ residual_pos[v] = [zone, round(f, 3)]
451
+ for u in sorted(adj[v]):
452
+ if u in pos:
453
+ segs.append(((px, py), (pos[u], y[u]), frozenset((v, u))))
454
+ if plan["deg0"]:
455
+ total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
456
+ cur = max(cfg.margin, cx - total / 2)
457
+ for v in plan["deg0"]:
458
+ pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
459
+ y[v] = cfg.park_y
460
+ cur += widths[v] + 18
461
+
462
+ meta = {
463
+ "placement": "ring-cycle",
464
+ "ring": {
465
+ "nodes": list(ring),
466
+ "radius": r,
467
+ "center": [cx, cy],
468
+ "declared_loop": plan.get("declared_loop"),
469
+ "polarity": plan.get("polarity"),
470
+ "trigger": plan.get("trigger"),
471
+ "chord_crossings": plan.get("chord_crossings", 0),
472
+ "residuals": {v: info for v, info in residual_pos.items()},
473
+ },
474
+ }
475
+ return pos, y, meta