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/layoutdiff.py ADDED
@@ -0,0 +1,424 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Layout diff (aesthetics-collection loop, t_229b61ef): three-layer comparison of the auto layout vs the manual layout.
3
+
4
+ Human drags -> machine diff -> AI distills aesthetic rules -> feeds back into the objective function; this module is the "machine diff" stage,
5
+ outputting both machine-readable (JSON) and human-readable (stdout table) formats:
6
+
7
+ 1) Node level: per-node displacement vector (dx,dy,|d|) + direction classification (toward loop center / toward column alignment /
8
+ toward symmetry; |d| < 4px counts as "not moved");
9
+ 2) Structural level: before/after table of key metrics -- crossings / card presses (1px accounting) / row alignment rate /
10
+ minimum card gap / spacing CV / average edge length / symmetry-axis error / loop cohesion
11
+ (auto vs manual columns + delta);
12
+ 3) Semantic level (when --cld/.json carries a graph): loops and roles (on-ring / branch) of moved nodes,
13
+ whether they end up closer to their main ring after the move; per-loop cohesion changes.
14
+
15
+ Accounting (all reproducible; definitions are also written into the output definitions block):
16
+ - Edge geometry: polylines take route.points; arcs (arc+ctrl) are discretized into 24 quadratic Bezier segments;
17
+ - Crossings: for edge pairs sharing no endpoint, any segment intersection counts as 1 pair (same accounting as certificate crossings);
18
+ - Card presses: edge sample points (4px step) entering unrelated card rectangles (+1px margin), deduplicated by edge x card;
19
+ - Row alignment rate: grouped by y difference <= 4px, in-group count / n (metrics.ROW_EPS accounting);
20
+ - Spacing CV: std/mean of nearest-neighbor center distances (same accounting as the certificate);
21
+ - Symmetry-axis error: vertical axis x = mean(x_i); for node i, the distance from its mirror point (2a-x_i, y_i) to the nearest
22
+ other node is err_i; symmetry-axis error = mean(err_i) (smaller means more mirror symmetry);
23
+ - Loop cohesion: average pairwise distance of on-ring nodes / ring diameter (largest pairwise distance); smaller means more compact.
24
+ """
25
+ import argparse
26
+ import json
27
+ import math
28
+ import os
29
+ import sys
30
+
31
+
32
+ # ── Geometry primitives (independent recomputation, same accounting as metrics.py/routing.py) ──
33
+ EPS = 1e-9
34
+ ROW_EPS = 4
35
+
36
+
37
+ def _seg_cross(p1, p2, p3, p4):
38
+ def orient(a, b, c):
39
+ v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
40
+ return 0 if abs(v) < EPS else (1 if v > 0 else -1)
41
+ d1, d2 = orient(p3, p4, p1), orient(p3, p4, p2)
42
+ d3, d4 = orient(p1, p2, p3), orient(p1, p2, p4)
43
+ if d1 != d2 and d3 != d4 and d1 != 0 and d3 != 0:
44
+ return True
45
+ if d1 == 0 and d2 == 0 and d3 == 0 and d4 == 0:
46
+ def on(a, b, c):
47
+ return (min(a[0], b[0]) - EPS <= c[0] <= max(a[0], b[0]) + EPS and
48
+ min(a[1], b[1]) - EPS <= c[1] <= max(a[1], b[1]) + EPS)
49
+ if on(p3, p4, p1) or on(p3, p4, p2) or on(p1, p2, p3) or on(p1, p2, p4):
50
+ near = [q for q in (p3, p4) for p in (p1, p2)
51
+ if abs(q[0] - p[0]) < EPS and abs(q[1] - p[1]) < EPS]
52
+ return len(near) == 0
53
+ return False
54
+
55
+
56
+ def _dist_point_rect(px, py, cx, cy, hw, hh):
57
+ dx = max(abs(px - cx) - hw, 0.0)
58
+ dy = max(abs(py - cy) - hh, 0.0)
59
+ return math.hypot(dx, dy)
60
+
61
+
62
+ def _quad_points(p0, ctrl, p1, n=24):
63
+ out = []
64
+ for k in range(n + 1):
65
+ t = k / n
66
+ u = 1 - t
67
+ out.append((u * u * p0[0] + 2 * u * t * ctrl[0] + t * t * p1[0],
68
+ u * u * p0[1] + 2 * u * t * ctrl[1] + t * t * p1[1]))
69
+ return out
70
+
71
+
72
+ def _sample(pts, step=4.0):
73
+ out = []
74
+ for i in range(len(pts) - 1):
75
+ (x1, y1), (x2, y2) = pts[i], pts[i + 1]
76
+ d = math.hypot(x2 - x1, y2 - y1)
77
+ n = max(1, int(d / step))
78
+ for k in range(n):
79
+ t = k / n
80
+ out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
81
+ out.append(tuple(pts[-1]))
82
+ return out
83
+
84
+
85
+ # ── Layout loading and metrics ──
86
+ def _load_layout(path):
87
+ with open(path, encoding="utf-8") as f:
88
+ lay = json.load(f)
89
+ if "nodes" not in lay or "edges" not in lay:
90
+ raise SystemExit(f"{path}: not a layout.json (missing nodes/edges)")
91
+ return lay
92
+
93
+
94
+ def _edge_polys(lay):
95
+ """Edge list [(a,b)] and discretized polylines (arcs as 24 Bezier segments)."""
96
+ edges, polys = [], []
97
+ for e in lay["edges"]:
98
+ r = e.get("route", {})
99
+ pts = [tuple(p) for p in r.get("points", [])]
100
+ if r.get("arc") and r.get("ctrl") and len(pts) >= 2:
101
+ pts = _quad_points(pts[0], tuple(r["ctrl"]), pts[-1], 24)
102
+ edges.append((e["a"], e["b"]))
103
+ polys.append(pts)
104
+ return edges, polys
105
+
106
+
107
+ def _symmetry_err(pos):
108
+ """Symmetry-axis error: vertical axis x=mean(x); mean_i min_{j!=i} dist(mirror_i, p_j)."""
109
+ if len(pos) < 2:
110
+ return 0.0
111
+ axis = sum(p[0] for p in pos.values()) / len(pos)
112
+ errs = []
113
+ for v, (x, y) in pos.items():
114
+ mx = 2 * axis - x
115
+ best = min(math.hypot(mx - q[0], y - q[1])
116
+ for w, q in pos.items() if w != v)
117
+ errs.append(best)
118
+ return sum(errs) / len(errs)
119
+
120
+
121
+ def _node_sym_err(pos, v):
122
+ axis = sum(p[0] for p in pos.values()) / len(pos)
123
+ x, y = pos[v]
124
+ mx = 2 * axis - x
125
+ return min(math.hypot(mx - q[0], y - q[1]) for w, q in pos.items() if w != v)
126
+
127
+
128
+ def layout_metrics(lay, graph=None):
129
+ """Key metrics of a single layout (independent recomputation with certificate accounting + symmetry/cohesion extensions)."""
130
+ nodes = lay["nodes"]
131
+ card_h = lay.get("meta", {}).get("cardH", 44)
132
+ ids = list(nodes)
133
+ pos = {v: (nodes[v]["x"], nodes[v]["y"]) for v in ids}
134
+ boxes = {v: (nodes[v]["x"], nodes[v]["y"],
135
+ nodes[v].get("w", 140) / 2, card_h / 2) for v in ids}
136
+ edges, polys = _edge_polys(lay)
137
+
138
+ crossings = 0
139
+ for i in range(len(edges)):
140
+ for j in range(i + 1, len(edges)):
141
+ a1, b1 = edges[i]
142
+ a2, b2 = edges[j]
143
+ if a1 in (a2, b2) or b1 in (a2, b2):
144
+ continue
145
+ hit = False
146
+ for s1 in zip(polys[i][:-1], polys[i][1:]):
147
+ for s2 in zip(polys[j][:-1], polys[j][1:]):
148
+ if _seg_cross(s1[0], s1[1], s2[0], s2[1]):
149
+ hit = True
150
+ break
151
+ if hit:
152
+ break
153
+ if hit:
154
+ crossings += 1
155
+
156
+ occ = 0
157
+ for i, (a, b) in enumerate(edges):
158
+ pts = _sample(polys[i])
159
+ for v in ids:
160
+ if v in (a, b):
161
+ continue
162
+ cx, cy, hw, hh = boxes[v]
163
+ if any(_dist_point_rect(px, py, cx, cy, hw + 1, hh + 1) <= 0
164
+ for (px, py) in pts):
165
+ occ += 1
166
+
167
+ total_len = 0.0
168
+ for pts in polys:
169
+ total_len += sum(math.dist(pts[k], pts[k + 1])
170
+ for k in range(len(pts) - 1))
171
+ avg_len = total_len / len(polys) if polys else 0.0
172
+
173
+ ysorted = sorted(p[1] for p in pos.values())
174
+ aligned, i = 0, 0
175
+ while i < len(ysorted):
176
+ j = i
177
+ while j + 1 < len(ysorted) and ysorted[j + 1] - ysorted[j] <= ROW_EPS:
178
+ j += 1
179
+ if j > i:
180
+ aligned += j - i + 1
181
+ i = j + 1
182
+ row_rate = aligned / len(ids) if ids else 0.0
183
+
184
+ gaps = []
185
+ for a in range(len(ids)):
186
+ for b in range(a + 1, len(ids)):
187
+ va, vb = boxes[ids[a]], boxes[ids[b]]
188
+ dx = abs(va[0] - vb[0]) - (va[2] + vb[2])
189
+ dy = abs(va[1] - vb[1]) - (va[3] + vb[3])
190
+ gaps.append(math.hypot(max(dx, 0.0), max(dy, 0.0)))
191
+ min_gap = min(gaps) if gaps else 0.0
192
+
193
+ nn = []
194
+ for a in ids:
195
+ nn.append(min(math.dist(pos[a], pos[b]) for b in ids if b != a))
196
+ mean_nn = sum(nn) / len(nn) if nn else 0.0
197
+ cv = (math.sqrt(sum((d - mean_nn) ** 2 for d in nn) / len(nn)) / mean_nn
198
+ if nn and mean_nn else 0.0)
199
+
200
+ m = {
201
+ "crossings": crossings,
202
+ "edge_card_press_1px": occ,
203
+ "row_align_rate": round(row_rate, 3),
204
+ "min_card_gap": round(min_gap, 1),
205
+ "spacing_cv": round(cv, 3),
206
+ "avg_edge_length": round(avg_len, 1),
207
+ "symmetry_axis_err_px": round(_symmetry_err(pos), 1),
208
+ }
209
+
210
+ # loop cohesion (when a graph is given): average pairwise distance of on-ring nodes / ring diameter
211
+ if graph is not None:
212
+ loops = {}
213
+ for lp in graph.loops:
214
+ members = [v for v in lp.nodes if v in pos]
215
+ if len(members) < 2:
216
+ continue
217
+ ds = [math.dist(pos[a], pos[b])
218
+ for ii, a in enumerate(members) for b in members[ii + 1:]]
219
+ dia = max(ds)
220
+ loops[lp.id] = {
221
+ "cohesion": round((sum(ds) / len(ds)) / dia, 3) if dia > EPS else None,
222
+ "mean_pairwise_px": round(sum(ds) / len(ds), 1),
223
+ "diameter_px": round(dia, 1),
224
+ }
225
+ m["loops"] = loops
226
+ return m
227
+
228
+
229
+ # ── Three-layer diff ──
230
+ def diff_layouts(auto, manual, graph=None, auto_path="", manual_path=""):
231
+ an, mn = auto["nodes"], manual["nodes"]
232
+ missing = [v for v in an if v not in mn]
233
+ extra = [v for v in mn if v not in an]
234
+ if missing or extra:
235
+ raise SystemExit(f"layout node sets do not match: missing {missing}, extra {extra}")
236
+ apos = {v: (an[v]["x"], an[v]["y"]) for v in an}
237
+ mpos = {v: (mn[v]["x"], mn[v]["y"]) for v in mn}
238
+
239
+ loops_of = {}
240
+ if graph is not None:
241
+ for lp in graph.loops:
242
+ for v in lp.nodes:
243
+ loops_of.setdefault(v, []).append(lp.id)
244
+
245
+ # column-alignment reference: an x shared by >= 3 nodes (within +/-2px) in manual counts as a column
246
+ def _columns(pos):
247
+ xs = sorted(p[0] for p in pos.values())
248
+ cols, cur = [], [xs[0]]
249
+ for x in xs[1:]:
250
+ if x - cur[-1] <= 2:
251
+ cur.append(x)
252
+ else:
253
+ cols.append(cur)
254
+ cur = [x]
255
+ cols.append(cur)
256
+ return [sum(c) / len(c) for c in cols if len(c) >= 3]
257
+
258
+ mcols = _columns(mpos)
259
+
260
+ node_level = []
261
+ for v in sorted(an):
262
+ dx = mpos[v][0] - apos[v][0]
263
+ dy = mpos[v][1] - apos[v][1]
264
+ d = math.hypot(dx, dy)
265
+ item = {"node": v, "dx": dx, "dy": dy, "|d|": round(d, 1),
266
+ "tags": []}
267
+ if d < 4:
268
+ item["tags"] = ["not moved (<4px)"]
269
+ node_level.append(item)
270
+ continue
271
+ # toward loop center: the angle between the displacement and (loop center (auto) - original position) is small (projection ratio >= 0.6)
272
+ for lid in loops_of.get(v, []):
273
+ members = [w for w in
274
+ next(l for l in graph.loops if l.id == lid).nodes
275
+ if w in apos]
276
+ c = (sum(apos[w][0] for w in members) / len(members),
277
+ sum(apos[w][1] for w in members) / len(members))
278
+ vec = (c[0] - apos[v][0], c[1] - apos[v][1])
279
+ proj = (dx * vec[0] + dy * vec[1]) / (d * math.hypot(*vec)) \
280
+ if math.hypot(*vec) > EPS else 0.0
281
+ if proj >= 0.6:
282
+ item["tags"].append(f"toward loop center ({lid})")
283
+ elif proj <= -0.6:
284
+ item["tags"].append(f"away from loop center ({lid})")
285
+ # toward column alignment: manual x falls into some column (within +/-2px) while auto x does not
286
+ if any(abs(mpos[v][0] - col) <= 2 for col in mcols) and \
287
+ not any(abs(apos[v][0] - col) <= 2 for col in mcols):
288
+ item["tags"].append("toward column alignment")
289
+ # toward symmetry: the node's symmetry error drops significantly (below 70%)
290
+ if _node_sym_err(mpos, v) < 0.7 * _node_sym_err(apos, v):
291
+ item["tags"].append("toward symmetric position")
292
+ if not item["tags"]:
293
+ item["tags"] = ["free displacement"]
294
+ node_level.append(item)
295
+
296
+ ma, mm = layout_metrics(auto, graph), layout_metrics(manual, graph)
297
+ structural = {}
298
+ for k in ("crossings", "edge_card_press_1px", "row_align_rate",
299
+ "min_card_gap", "spacing_cv", "avg_edge_length",
300
+ "symmetry_axis_err_px"):
301
+ structural[k] = {"auto": ma[k], "manual": mm[k],
302
+ "delta": round(mm[k] - ma[k], 3)}
303
+
304
+ semantic = None
305
+ if graph is not None:
306
+ sem_nodes = []
307
+ for it in node_level:
308
+ v = it["node"]
309
+ if it["tags"] == ["not moved (<4px)"] or not loops_of.get(v):
310
+ if it["tags"] != ["not moved (<4px)"] and not loops_of.get(v):
311
+ sem_nodes.append({"node": v, "role": "branch",
312
+ "loops": []})
313
+ continue
314
+ entries = []
315
+ for lid in loops_of[v]:
316
+ members = [w for w in
317
+ next(l for l in graph.loops if l.id == lid).nodes
318
+ if w in apos]
319
+ peers_a = [w for w in members if w != v]
320
+ peers_m = peers_a
321
+ da = (sum(math.dist(apos[v], apos[w]) for w in peers_a)
322
+ / len(peers_a)) if peers_a else 0.0
323
+ dm = (sum(math.dist(mpos[v], mpos[w]) for w in peers_m)
324
+ / len(peers_m)) if peers_m else 0.0
325
+ entries.append({"loop": lid,
326
+ "closer_to_loop_center_after": dm < da,
327
+ "dist_auto_px": round(da, 1),
328
+ "dist_manual_px": round(dm, 1)})
329
+ sem_nodes.append({"node": v, "role": "on-ring",
330
+ "loops": loops_of[v], "loop_moves": entries})
331
+ semantic = {
332
+ "nodes": sem_nodes,
333
+ "loops": {
334
+ lid: {"cohesion_auto": ma["loops"].get(lid, {}).get("cohesion"),
335
+ "cohesion_manual": mm["loops"].get(lid, {}).get("cohesion"),
336
+ "mean_pairwise_delta_px": round(
337
+ (mm["loops"].get(lid, {}).get("mean_pairwise_px", 0) -
338
+ ma["loops"].get(lid, {}).get("mean_pairwise_px", 0)), 1)}
339
+ for lid in ma.get("loops", {})}
340
+
341
+ }
342
+
343
+ return {
344
+ "tool": "stdraw layout-diff",
345
+ "auto": auto_path,
346
+ "manual": manual_path,
347
+ "node_level": node_level,
348
+ "structural": structural,
349
+ "semantic": semantic,
350
+ "definitions": {
351
+ "moved": "|d| >= 4px; everything else counts as not moved",
352
+ "crossings": "edge pairs sharing no endpoint count 1 per any segment intersection (same caliber as the certificate); "
353
+ "arcs discretized as 24 quadratic Bezier segments",
354
+ "edge_card_press_1px": "edge sample points (4px) entering unrelated card rectangles (+1px), deduplicated per edge x card",
355
+ "row_align_rate": "grouped counting with y difference <= 4px / n",
356
+ "symmetry_axis_err_px": "axis x=mean(x_i); mean_i min_{j!=i} dist(mirror_i, p_j), "
357
+ "smaller means more mirror-symmetric",
358
+ "loop_cohesion": "average pairwise distance of on-ring nodes / ring diameter (smaller means more cohesive)",
359
+ "delta": "manual - auto",
360
+ },
361
+ }
362
+
363
+
364
+ def _human(diff):
365
+ out = []
366
+ out.append(f"layout diff: {diff['auto']} (auto) vs {diff['manual']} (manual)")
367
+ out.append("")
368
+ out.append("-- node level (displacement vectors + orientation) --")
369
+ for it in diff["node_level"]:
370
+ out.append(f" {it['node']:<24} dx={it['dx']:>5} dy={it['dy']:>5} "
371
+ f"|d|={it['|d|']:>6} {'; '.join(it['tags'])}")
372
+ out.append("")
373
+ out.append("-- structural level (auto -> manual, delta = manual - auto) --")
374
+ for k, v in diff["structural"].items():
375
+ out.append(f" {k:<22} {v['auto']:>10} → {v['manual']:>10} Δ={v['delta']}")
376
+ if diff.get("semantic"):
377
+ out.append("")
378
+ out.append("-- semantic level (loop membership and cohesion) --")
379
+ for n in diff["semantic"]["nodes"]:
380
+ if not n.get("loops") and n["role"] != "branch":
381
+ continue
382
+ moves = "; ".join(
383
+ f"{m['loop']}:{m['dist_auto_px']}→{m['dist_manual_px']}px"
384
+ f"({'closer' if m['closer_to_loop_center_after'] else 'farther'})"
385
+ for m in n.get("loop_moves", [])) or "-"
386
+ out.append(f" {n['node']:<24} [{n['role']}] {moves}")
387
+ for lid, lp in diff["semantic"]["loops"].items():
388
+ out.append(f" loop {lid}: cohesion {lp['cohesion_auto']} -> "
389
+ f"{lp['cohesion_manual']}, mean pairwise distance delta="
390
+ f"{lp['mean_pairwise_delta_px']}px")
391
+ return "\n".join(out)
392
+
393
+
394
+ def main(argv=None):
395
+ ap = argparse.ArgumentParser(
396
+ prog="stdraw layout-diff",
397
+ description="three-layer diff of the auto layout vs the manual layout (data source of the aesthetics-collection loop)")
398
+ ap.add_argument("auto", help="automatic layout layout.json")
399
+ ap.add_argument("manual", help="manual layout layout.json (or an exported subset)")
400
+ ap.add_argument("--cld", default=None,
401
+ help="graph source (.cld/.json), enables the semantic layer (loop membership / cohesion)")
402
+ ap.add_argument("--out", default="layout-diff.json",
403
+ help="machine-readable JSON output path (default ./layout-diff.json; "
404
+ "empty string skips writing)")
405
+ a = ap.parse_args(argv)
406
+
407
+ graph = None
408
+ if a.cld:
409
+ from .__main__ import load_graph
410
+ graph = load_graph(a.cld)
411
+
412
+ auto = _load_layout(a.auto)
413
+ manual = _load_layout(a.manual)
414
+ d = diff_layouts(auto, manual, graph, a.auto, a.manual)
415
+ print(_human(d))
416
+ if a.out:
417
+ with open(a.out, "w", encoding="utf-8") as f:
418
+ json.dump(d, f, ensure_ascii=False, indent=1)
419
+ print(f"\nmachine-readable JSON -> {a.out}")
420
+ return 0
421
+
422
+
423
+ if __name__ == "__main__":
424
+ sys.exit(main())
stdraw/loops.py ADDED
@@ -0,0 +1,145 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Loop semantic audit (lint layer) -- unique capability: catches "declared ring not closed" and "polarity mislabeled".
3
+
4
+ Consumes only the Graph model (does not parse HTML or any other host format).
5
+
6
+ Verifies three things:
7
+ 1. Closure of declared rings: using only that ring's tagged edges, can the walk start at the first node, pass through all ring nodes, and return to it;
8
+ 2. R/B polarity edge count: parity of negative-polarity tagged edges <-> declared kind (R = even / reinforcing, B = odd / balancing) consistency;
9
+ 3. Independent enumeration: all simple loops actually present in the graph (DFS depth cap), compared against the declared set --
10
+ declared-but-nonexistent and existing-but-undeclared are both reported honestly.
11
+
12
+ Outputs a report dict (can go into the certificate JSON); the lint verdict does not affect layout, only the quality layer.
13
+ """
14
+ from collections import defaultdict
15
+ from typing import Dict, List
16
+
17
+ from .model import Graph
18
+
19
+ MAX_DFS_DEPTH = 12 # DFS depth cap for declared-ring closure
20
+ MAX_ENUM_DEPTH = 10 # length cap for independently enumerated loops
21
+
22
+
23
+ def loops_audit(graph: Graph) -> dict:
24
+ out_adj = defaultdict(list) # a -> [(b, polarity)]
25
+ edge_loops: Dict[tuple, List[str]] = {}
26
+ for e in graph.edges:
27
+ pol = "-" if e.polarity == "-" else "+"
28
+ out_adj[e.a].append((e.b, pol))
29
+ edge_loops.setdefault((e.a, e.b), []).extend(e.loops)
30
+
31
+ declared = {lp.id: lp for lp in graph.loops}
32
+ report = {"loops": [], "problems": [], "extra_cycles": []}
33
+
34
+ # ── 1+2. Audit each declared ring ──
35
+ for lid, lp in declared.items():
36
+ sub = defaultdict(list)
37
+ neg = 0
38
+ for e in graph.edges:
39
+ if lid in e.loops:
40
+ sub[e.a].append((e.b, e.polarity))
41
+ if e.polarity == "-":
42
+ neg += 1
43
+ start = lp.nodes[0] if lp.nodes else None
44
+ found = None
45
+
46
+ def dfs(cur, path, seen_edges):
47
+ nonlocal found
48
+ if found or len(path) > MAX_DFS_DEPTH:
49
+ return
50
+ for (nxt, _pol) in sub[cur]:
51
+ if (cur, nxt) in seen_edges:
52
+ continue
53
+ if nxt == start and len(path) >= 2:
54
+ found = path + [start]
55
+ return
56
+ if nxt in path:
57
+ continue
58
+ dfs(nxt, path + [nxt], seen_edges | {(cur, nxt)})
59
+
60
+ if start:
61
+ dfs(start, [start], set())
62
+ closed = found is not None
63
+ parity_kind = "B" if neg % 2 else "R"
64
+ kind_ok = (not lp.kind) or (lp.kind == parity_kind)
65
+ item = {
66
+ "id": lid, "label": lp.label, "declared_kind": lp.kind,
67
+ "nodes": lp.nodes, "closed": closed,
68
+ "walk": found,
69
+ "neg_edges": neg, "parity_kind": parity_kind,
70
+ "kind_consistent": kind_ok,
71
+ }
72
+ if not closed:
73
+ report["problems"].append(
74
+ f"loop {lid} ({lp.label}) not closed: tagged edges cannot walk from {start} back to itself")
75
+ if not kind_ok:
76
+ report["problems"].append(
77
+ f"loop {lid} ({lp.label}) declares {lp.kind} but polarity parity is actually {parity_kind}"
78
+ f" (negative edges {neg})")
79
+ report["loops"].append(item)
80
+
81
+ # case where a declared ring has no tagged edges
82
+ for lid, lp in declared.items():
83
+ tagged = sum(1 for e in graph.edges if lid in e.loops)
84
+ if tagged == 0:
85
+ report["problems"].append(f"loop {lid} ({lp.label}) has no edges tagged with it")
86
+
87
+ # ── 3. Independently enumerate all simple loops ──
88
+ # loops are stored as canonical directed tuples (path order with the smallest node rotated to the front): a frozenset would fold
89
+ # "different directed loops with the same node set" into one, and its iteration order drifts with PYTHONHASHSEED,
90
+ # which once caused neg_edges/kind to swap between two renders of loops_audit.json (t_6b2efc40).
91
+ cycles = set()
92
+
93
+ def canon_cycle(path):
94
+ return min(tuple(path[i:] + path[:i]) for i in range(len(path)))
95
+
96
+ def walk(cur, start, path, seen_e):
97
+ if len(path) > MAX_ENUM_DEPTH:
98
+ return
99
+ for (nxt, _pol) in out_adj[cur]:
100
+ if (cur, nxt) in seen_e:
101
+ continue
102
+ if nxt == start and len(path) >= 2:
103
+ cycles.add(canon_cycle(path))
104
+ continue
105
+ if nxt in path:
106
+ continue
107
+ walk(nxt, start, path + [nxt], seen_e | {(cur, nxt)})
108
+
109
+ for n in list(out_adj):
110
+ walk(n, n, [n], set())
111
+
112
+ def cycle_kind(cyc):
113
+ negs = 0
114
+ for i in range(len(cyc)):
115
+ a, b = cyc[i], cyc[(i + 1) % len(cyc)]
116
+ pols = [e.polarity for e in graph.edges
117
+ if (e.a, e.b) in ((a, b), (b, a))]
118
+ if pols and "-" in pols:
119
+ negs += 1
120
+ return "B" if negs % 2 else "R", negs
121
+
122
+ declared_node_sets = [frozenset(lp.nodes) for lp in graph.loops]
123
+ for cyc in sorted(cycles): # tuple order = deterministic total order, no tie ambiguity
124
+ kind, negs = cycle_kind(cyc)
125
+ cset = frozenset(cyc)
126
+ match = next((lp.id for lp, s in zip(graph.loops, declared_node_sets)
127
+ if s == cset), None)
128
+ report["extra_cycles"].append({
129
+ "nodes": sorted(cyc), "kind": kind, "neg_edges": negs,
130
+ "matches_declared": match})
131
+ if match is None:
132
+ report["problems"].append(
133
+ f"undeclared cycle {'->'.join(cyc)} ({kind}, negative edges {negs})")
134
+
135
+ report["summary"] = {
136
+ "declared": len(declared),
137
+ "closed": sum(1 for it in report["loops"] if it["closed"]),
138
+ "kind_consistent": sum(1 for it in report["loops"] if it["kind_consistent"]),
139
+ "enumerated": len(cycles),
140
+ "undeclared": sum(1 for c in report["extra_cycles"]
141
+ if c["matches_declared"] is None),
142
+ "problems": len(report["problems"]),
143
+ }
144
+ report["pass"] = report["summary"]["problems"] == 0
145
+ return report