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.
@@ -0,0 +1,1004 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Exact crossing-number layer (WS2): OIL routing ILP + planarization-oracle adjudication + B&B squeeze.
3
+
4
+ Mathematical framework (full proofs in docs/crossing-exact.md):
5
+
6
+ 1. Safe reductions (each carries a cr-preservation proof):
7
+ - Self-loop removal (self-loops drawn locally, 0 crossings); connected-component decomposition (cr is additive);
8
+ - Iterative deletion of degree-<=1 vertices (the leaf is reattached inside a face incident to its neighbor with 0 crossings);
9
+ - Bridge decomposition (cr is additive over 2-edge-connected blocks).
10
+
11
+ 2. Upper bound (UB): greedy maximal planar subgraph U + simultaneous-optimal insertion into (U, Pi).
12
+ An insertion solution verified by the oracle is a real drawing -> the UB is always true.
13
+
14
+ 3. Decomposition theorem (docs section 2): cr(G) = min over planar subgraphs U, all planar embeddings Pi of U,
15
+ of the minimum crossings when inserting E\\U into Pi simultaneously.
16
+ B&B enumeration (docs section 3): candidates F = E\\U with |F| <= 2*UB-2 provably cover the optimum;
17
+ per candidate: G-F planar + 2-connected + all embeddings (rotation-system enumeration + Euler face-count criterion) plus
18
+ the decision "a **realizable** solution with cost <= UB-1 exists". Complete coverage -> cr=UB is proven.
19
+
20
+ 4. Insertion subproblem (for fixed (U, Pi)): **CP-SAT proposal + oracle adjudication**.
21
+ - ILP (superset modeling, slack is harmless): each F edge = a unit-flow
22
+ source->sink path on the dual graph of the triangulated embedding (each side at most once, good-drawing accounting; adjacent edges forbidden to cross);
23
+ c_{e,f} = F-F mutual-crossing indicator (<= 1; must share a triangle).
24
+ - Adjudication: build the **planarized graph H** from (paths, c) (U + arc paths + crossing points merged into single vertices),
25
+ H planar <=> the solution is realizable (two-direction proof in docs section 4). H planar -> the solution is real, its cost is the crossing count;
26
+ not planar -> a no-good clause eliminates exactly that solution; re-solve.
27
+ - Correctness does not depend on any hand-crafted topology constraint set: the ILP is a superset of the realizable set, the oracle is ground truth;
28
+ the loop terminates at the "optimal realizable solution" or "no realizable solution at that cost". Both are exact.
29
+ - Determinism: num_workers=1 + fixed seed + an upper bound on the no-good loop count (honest degradation when exceeded).
30
+
31
+ 5. Lower bound (LB, globally valid, always <= cr): greedy extraction of Kuratowski counterexamples (edge-disjoint) +
32
+ z-ILP (each Kuratowski subgraph has at least one marked edge); cr >= ceil(marked edges / 2).
33
+ When LB == UB optimality is provable regardless of coverage completeness.
34
+
35
+ 6. No ortools / core size > 30 / enumeration over cap -> constructive degradation, proven_optimal=false,
36
+ gap labeled honestly.
37
+ """
38
+ import itertools
39
+ import math
40
+ from collections import defaultdict
41
+
42
+
43
+ # ────────────────────────── Simple undirected view of the graph ──────────────────────────
44
+
45
+
46
+ def undirected_edges(graph):
47
+ """(a,b) undirected edge list: self-loops removed, parallel edges kept (parallel edges do not cross each other in a good drawing)."""
48
+ edges, selfloops = [], []
49
+ for e in graph.edges:
50
+ if e.a == e.b:
51
+ selfloops.append((e.a, e.b))
52
+ continue
53
+ edges.append((e.a, e.b))
54
+ return edges, selfloops
55
+
56
+
57
+ def _adjacent(e1, e2):
58
+ return bool({e1[0], e1[1]} & {e2[0], e2[1]})
59
+
60
+
61
+ # ────────────────────────── Safe reductions (cr-preserving) ──────────────────────────
62
+
63
+
64
+ def _iter_degree_prune(nodes, edges):
65
+ nodes, edges = list(nodes), list(edges)
66
+ changed = True
67
+ while changed:
68
+ changed = False
69
+ deg = defaultdict(int)
70
+ for a, b in edges:
71
+ deg[a] += 1
72
+ deg[b] += 1
73
+ drop = {v for v in nodes if deg[v] <= 1}
74
+ if drop:
75
+ nodes = [v for v in nodes if v not in drop]
76
+ edges = [(a, b) for a, b in edges if a not in drop and b not in drop]
77
+ changed = True
78
+ return nodes, edges
79
+
80
+
81
+ def _components(nodes, edges):
82
+ adj = defaultdict(set)
83
+ for a, b in edges:
84
+ adj[a].add(b)
85
+ adj[b].add(a)
86
+ seen, comps = set(), []
87
+ for v in nodes:
88
+ if v in seen:
89
+ continue
90
+ stack, comp = [v], []
91
+ seen.add(v)
92
+ while stack:
93
+ x = stack.pop()
94
+ comp.append(x)
95
+ for w in adj[x]:
96
+ if w not in seen:
97
+ seen.add(w)
98
+ stack.append(w)
99
+ comps.append(comp)
100
+ return comps
101
+
102
+
103
+ def _bridges_split(nodes, edges):
104
+ """Tarjan bridges -> list of 2-edge-connected blocks [(nodes, edges)] (single-edge bridge blocks with cr=0 are discarded)."""
105
+ adj = defaultdict(list)
106
+ for i, (a, b) in enumerate(edges):
107
+ adj[a].append((b, i))
108
+ adj[b].append((a, i))
109
+ disc, low, is_bridge = {}, {}, [False] * len(edges)
110
+ parent, depth = {}, {}
111
+ timer = [0]
112
+ for root in nodes:
113
+ if root in disc:
114
+ continue
115
+ disc[root] = low[root] = timer[0]
116
+ timer[0] += 1
117
+ depth[root] = 0
118
+ stack = [(root, -1, iter(adj[root]))]
119
+ while stack:
120
+ v, pe, it = stack[-1]
121
+ advanced = False
122
+ for w, ei in it:
123
+ if ei == pe:
124
+ continue
125
+ if w not in disc:
126
+ parent[w] = (v, ei)
127
+ disc[w] = low[w] = timer[0]
128
+ timer[0] += 1
129
+ depth[w] = depth[v] + 1
130
+ stack.append((w, ei, iter(adj[w])))
131
+ advanced = True
132
+ break
133
+ elif ei != parent.get(v, (None, -1))[1]:
134
+ low[v] = min(low[v], disc[w])
135
+ if not advanced:
136
+ stack.pop()
137
+ if stack:
138
+ pv = stack[-1][0]
139
+ low[pv] = min(low[pv], low[v])
140
+ if low[v] > disc[pv]:
141
+ is_bridge[parent[v][1]] = True
142
+ nbedges = [i for i in range(len(edges)) if not is_bridge[i]]
143
+ adj2 = defaultdict(list)
144
+ for i in nbedges:
145
+ a, b = edges[i]
146
+ adj2[a].append(i)
147
+ adj2[b].append(i)
148
+ seen_e, blocks = set(), []
149
+ for i in nbedges:
150
+ if i in seen_e:
151
+ continue
152
+ bl_edges, bl_nodes, stack = [], set(), [i]
153
+ seen_e.add(i)
154
+ while stack:
155
+ ei = stack.pop()
156
+ x, y = edges[ei]
157
+ bl_edges.append(edges[ei])
158
+ bl_nodes.update((x, y))
159
+ for nxt in adj2[x] + adj2[y]:
160
+ if nxt not in seen_e:
161
+ seen_e.add(nxt)
162
+ stack.append(nxt)
163
+ blocks.append((sorted(bl_nodes), bl_edges))
164
+ return blocks
165
+
166
+
167
+ def safe_reduce(graph):
168
+ """Safe-reduction main entry -> (cores, info). cr(graph) = sum of cr(cores[i])."""
169
+ edges, selfloops = undirected_edges(graph)
170
+ nodes = graph.node_ids()
171
+ cores = []
172
+ for comp in _components(nodes, edges):
173
+ cset = set(comp)
174
+ ce = [(a, b) for a, b in edges if a in cset and b in cset]
175
+ n2, e2 = _iter_degree_prune(comp, ce)
176
+ if not e2:
177
+ continue
178
+ for bn, be in _bridges_split(n2, e2):
179
+ if be:
180
+ cores.append((bn, be))
181
+ return cores, {"selfloops_removed": len(selfloops), "cores": len(cores)}
182
+
183
+
184
+ # ────────────────────────── Embedding utilities ──────────────────────────
185
+
186
+
187
+ def _faces_from_rotation(rotation, nodes, edges):
188
+ """Rotation system -> face list + planarity criterion (face count == m - n + 2, Euler criterion for connected graphs).
189
+
190
+ The successor of dart (u->v) is (v->x), where x is the cyclic successor of u in rotation[v]."""
191
+ nxt = {}
192
+ for v in nodes:
193
+ rot = list(rotation[v])
194
+ for i, w in enumerate(rot):
195
+ nxt[(w, v)] = rot[(i + 1) % len(rot)]
196
+ seen, faces = set(), []
197
+ for v0 in nodes:
198
+ for w0 in rotation[v0]:
199
+ if (v0, w0) in seen:
200
+ continue
201
+ face, v, w = [], v0, w0
202
+ while (v, w) not in seen:
203
+ seen.add((v, w))
204
+ face.append(v)
205
+ w2 = nxt[(v, w)]
206
+ v, w = w, w2
207
+ faces.append(face)
208
+ n, m = len(nodes), len(edges)
209
+ return faces, len(faces) == m - n + 2
210
+
211
+
212
+ def _enum_rotations(nodes, edges, cap=20000):
213
+ """Enumerate all planar embeddings (rotation systems) of a connected graph.
214
+
215
+ The first element at each vertex is fixed (folding rotations are equivalent); the first vertex additionally requires second element < last element
216
+ (folding the global mirror -- the mirror image has the same insertion cost); at the leaves the Euler face-count criterion verifies planarity.
217
+ Returns (embeddings, capped)."""
218
+ deg, neigh = defaultdict(int), defaultdict(set)
219
+ for a, b in edges:
220
+ deg[a] += 1
221
+ deg[b] += 1
222
+ neigh[a].add(b)
223
+ neigh[b].add(a)
224
+ vs = sorted(nodes, key=lambda v: (-deg[v], v))
225
+ orders = {}
226
+ for v in vs:
227
+ ns = sorted(neigh[v])
228
+ cand = [[ns[0]] + list(p) for p in itertools.permutations(ns[1:])]
229
+ if v == vs[0] and len(ns) >= 3:
230
+ cand = [r for r in cand if r[1] < r[-1]]
231
+ orders[v] = cand
232
+ embeddings, seen = [], set()
233
+ count, capped = [0], [False]
234
+
235
+ def dfs(i, cur):
236
+ if capped[0]:
237
+ return
238
+ if i == len(vs):
239
+ count[0] += 1
240
+ if count[0] > cap:
241
+ capped[0] = True
242
+ return
243
+ faces, ok = _faces_from_rotation(cur, nodes, edges)
244
+ if not ok:
245
+ return
246
+ key = tuple((v, tuple(cur[v])) for v in vs)
247
+ if key not in seen:
248
+ seen.add(key)
249
+ embeddings.append({v: list(cur[v]) for v in nodes})
250
+ return
251
+ v = vs[i]
252
+ for rot in orders[v]:
253
+ cur[v] = rot
254
+ dfs(i + 1, cur)
255
+ cur[v] = None
256
+
257
+ dfs(0, {v: None for v in nodes})
258
+ return embeddings, capped[0]
259
+
260
+
261
+ # ────────────────────────── Triangulation structures ──────────────────────────
262
+
263
+
264
+ def _split_face(face):
265
+ """Face walk with repeated vertices -> list of simple cycles (cut-vertex pocket decomposition).
266
+
267
+ Bridges/cut vertices make the same geometric face pass through a cut vertex multiple times: walk w is split at repeated vertex v;
268
+ the inner cycle w[i:j] and the outer cycle w[:i]+w[j:] are both valid closed walks (adjacent pairs are darts of the original
269
+ walk), recursing until no repeated vertices remain. In each returned cycle the first vertex never reappears."""
270
+ walks, out = [face], []
271
+ while walks:
272
+ w = walks.pop()
273
+ first = {}
274
+ cut = None
275
+ for i, v in enumerate(w):
276
+ if v in first:
277
+ cut = (first[v], i)
278
+ break
279
+ first[v] = i
280
+ if cut is None:
281
+ out.append(w)
282
+ continue
283
+ i, j = cut
284
+ walks.append(w[i:j]) # inner cycle (v ... up to just before returning to v; v only at the front)
285
+ if i > 0 or j < len(w):
286
+ walks.append(w[:i] + w[j:]) # outer cycle (inner segment removed; still a closed walk)
287
+ return out
288
+
289
+
290
+ class Triangulation:
291
+ """Triangulation of a planar embedding: triangle list + sides (real-edge sides / virtual-chord sides).
292
+
293
+ sides[i] = {kind: 'edge'|'chord', nodes: (x, y), payload, t1, t2}
294
+ 'edge': real-edge side -- crossing it counts 1 crossing; payload = index of that edge in the full-graph edge list
295
+ 'chord': virtual chord inside a face -- crossing it is free; payload = (face_id, p, q)
296
+ tri_sides[t] = the 3 side ids of triangle t; node_tris[v] = triangles containing vertex v.
297
+ Faces with repeated vertices (bridge/cut-vertex pockets) are first split into simple cycles by _split_face.
298
+ """
299
+
300
+ def __init__(self, faces, global_index):
301
+ self.triangles = []
302
+ self.sides = []
303
+ self.node_tris = defaultdict(list)
304
+ self.tri_sides = defaultdict(list)
305
+ edge_side, chord_sides = {}, {}
306
+ simple_faces = []
307
+ leaf_pairs = [] # 2-cycles (leaf pockets): [u, v], leaf hanging off its neighbor
308
+ for face in faces:
309
+ for w in _split_face(face):
310
+ if len(w) < 3:
311
+ if len(w) == 2:
312
+ leaf_pairs.append(w)
313
+ continue
314
+ simple_faces.append(w)
315
+
316
+ def real_side(x, y):
317
+ key = tuple(sorted((x, y)))
318
+ if key not in edge_side:
319
+ edge_side[key] = len(self.sides)
320
+ self.sides.append({"kind": "edge", "nodes": (x, y),
321
+ "payload": global_index[key],
322
+ "t1": None, "t2": None})
323
+ return edge_side[key]
324
+
325
+ def chord_side(fi, p, q):
326
+ key = (fi,) + tuple(sorted((p, q)))
327
+ if key not in chord_sides:
328
+ chord_sides[key] = len(self.sides)
329
+ self.sides.append({"kind": "chord", "nodes": (p, q),
330
+ "payload": key[1:], "t1": None, "t2": None})
331
+ return chord_sides[key]
332
+
333
+ for fi, face in enumerate(simple_faces):
334
+ if len(face) < 3:
335
+ continue # bigon face (parallel-edge remnant): no triangle
336
+ k = len(face)
337
+ v0 = face[0]
338
+ tris_f = []
339
+ for j in range(1, k - 1):
340
+ t = len(self.triangles)
341
+ self.triangles.append((v0, face[j], face[j + 1]))
342
+ for v in self.triangles[-1]:
343
+ self.node_tris[v].append(t)
344
+ tris_f.append(t)
345
+ for j in range(len(tris_f)):
346
+ jf = j + 1
347
+ t = tris_f[j]
348
+ if jf == 1:
349
+ self.tri_sides[t].append(real_side(v0, face[1]))
350
+ else:
351
+ self.tri_sides[t].append(chord_side(fi, v0, face[jf]))
352
+ self.tri_sides[t].append(real_side(face[jf], face[jf + 1]))
353
+ if jf + 1 == k - 1:
354
+ self.tri_sides[t].append(real_side(v0, face[k - 1]))
355
+ else:
356
+ self.tri_sides[t].append(chord_side(fi, v0, face[jf + 1]))
357
+ use = defaultdict(list)
358
+ for t, sids in self.tri_sides.items():
359
+ for sid in sids:
360
+ use[sid].append(t)
361
+ for sid, ts in use.items():
362
+ ts = sorted(set(ts))
363
+ if len(ts) != 2:
364
+ raise AssertionError(f"side {sid} has {len(ts)} adjacent triangles != 2")
365
+ self.sides[sid]["t1"], self.sides[sid]["t2"] = ts
366
+ # Leaf vertices (degree 1: 2-cycle pockets are skipped, no triangles) -> inherit the neighbor's triangle set.
367
+ # Semantics = the cr-preservation theorem's "reattach the leaf inside a face incident to its neighbor with 0 crossings": the leaf position can be re-chosen,
368
+ # an arc leaving the leaf is equivalent to leaving any face incident to its neighbor (0-crossing hookup).
369
+ for pair in leaf_pairs:
370
+ u, v = pair
371
+ tu, tv = self.node_tris.get(u), self.node_tris.get(v)
372
+ if tu and not tv:
373
+ self.node_tris[v] = list(tu)
374
+ elif tv and not tu:
375
+ self.node_tris[u] = list(tv)
376
+ elif not tu and not tv:
377
+ merged = sorted(set(tu or []) | set(tv or []))
378
+ self.node_tris[u] = list(merged)
379
+ self.node_tris[v] = list(merged)
380
+
381
+
382
+ # ────────────────────────── OIL routing ILP + oracle adjudication ──────────────────────────
383
+
384
+
385
+ def _build_model(tri, nodes, edges, F, cp_model):
386
+ """Superset ILP: routing flow + mutual-crossing indicators (slack -- correctness is guaranteed by the oracle)."""
387
+ m = cp_model.CpModel()
388
+ nT = len(tri.triangles)
389
+ nS = len(tri.sides)
390
+ side_real = [tri.sides[s]["kind"] == "edge" for s in range(nS)]
391
+ side_edge = [tri.sides[s]["payload"] if tri.sides[s]["kind"] == "edge"
392
+ else None for s in range(nS)]
393
+ side_tris = [(tri.sides[s]["t1"], tri.sides[s]["t2"]) for s in range(nS)]
394
+ all_tris = list(range(nT))
395
+
396
+ u, sel_src, sel_end, fdir = {}, {}, {}, {}
397
+ for k, (a, b) in enumerate(F):
398
+ src_tris = tri.node_tris.get(a, all_tris)
399
+ end_tris = tri.node_tris.get(b, all_tris)
400
+ for t in all_tris:
401
+ sel_src[(k, t)] = m.NewBoolVar(f"src_{k}_{t}")
402
+ sel_end[(k, t)] = m.NewBoolVar(f"end_{k}_{t}")
403
+ m.Add(sum(sel_src[(k, t)] for t in src_tris) == 1)
404
+ m.Add(sum(sel_src[(k, t)] for t in all_tris if t not in src_tris) == 0)
405
+ m.Add(sum(sel_end[(k, t)] for t in end_tris) == 1)
406
+ m.Add(sum(sel_end[(k, t)] for t in all_tris if t not in end_tris) == 0)
407
+ fk = {}
408
+ for sid in range(nS):
409
+ g = side_edge[sid]
410
+ uv = m.NewBoolVar(f"u_{k}_{sid}")
411
+ if g is not None and _adjacent(F[k], edges[g]):
412
+ m.Add(uv == 0) # adjacent edges forbidden to cross (removable by perturbation in a good drawing)
413
+ u[(k, sid)] = uv
414
+ t1, t2 = side_tris[sid]
415
+ f0 = m.NewBoolVar(f"f_{k}_{sid}_a")
416
+ f1 = m.NewBoolVar(f"f_{k}_{sid}_b")
417
+ m.Add(f0 + f1 == uv)
418
+ fk[(sid, 0)] = f0
419
+ fk[(sid, 1)] = f1
420
+ fdir[k] = fk
421
+ for t in all_tris:
422
+ outflow = sum(fk[(sid, 0)] for sid in tri.tri_sides[t]
423
+ if side_tris[sid][0] == t) + \
424
+ sum(fk[(sid, 1)] for sid in tri.tri_sides[t]
425
+ if side_tris[sid][1] == t)
426
+ inflow = sum(fk[(sid, 1)] for sid in tri.tri_sides[t]
427
+ if side_tris[sid][0] == t) + \
428
+ sum(fk[(sid, 0)] for sid in tri.tri_sides[t]
429
+ if side_tris[sid][1] == t)
430
+ m.Add(outflow - inflow == sel_src[(k, t)] - sel_end[(k, t)])
431
+
432
+ onroute, zvars, cvar = {}, {}, {}
433
+ for k in range(len(F)):
434
+ for t in all_tris:
435
+ terms = [sel_src[(k, t)], sel_end[(k, t)]] + \
436
+ [u[(k, sid)] for sid in tri.tri_sides[t]]
437
+ o = m.NewBoolVar(f"on_{k}_{t}")
438
+ m.Add(o <= sum(terms))
439
+ for term in terms:
440
+ m.AddImplication(term, o)
441
+ onroute[(k, t)] = o
442
+ for k1 in range(len(F)):
443
+ for k2 in range(k1 + 1, len(F)):
444
+ if _adjacent(F[k1], F[k2]):
445
+ continue
446
+ c = m.NewBoolVar(f"c_{k1}_{k2}")
447
+ zl = []
448
+ for t in all_tris:
449
+ z = m.NewBoolVar(f"z_{k1}_{k2}_{t}")
450
+ m.Add(z <= onroute[(k1, t)])
451
+ m.Add(z <= onroute[(k2, t)])
452
+ zl.append(z)
453
+ m.Add(c <= sum(zl))
454
+ cvar[(k1, k2)] = c
455
+ zvars[(k1, k2)] = zl
456
+
457
+ scope = list(u.values()) + list(sel_src.values()) + \
458
+ list(sel_end.values()) + list(cvar.values())
459
+ return {"model": m, "u": u, "sel_src": sel_src, "sel_end": sel_end,
460
+ "fdir": fdir, "cvar": cvar, "zvars": zvars, "scope": scope,
461
+ "side_real": side_real, "side_edge": side_edge,
462
+ "side_tris": side_tris, "all_tris": all_tris}
463
+
464
+
465
+ def _extract_solution(tri, edges, F, F_idx, parts, solver):
466
+ """Extract (event sequences, mutual) from solution values. Event sequence[k] follows arc order:
467
+ ('x', edge idx) = crossing that real edge; ('m', pair_key) = mutual crossing with the paired arc."""
468
+ u, sel_src, sel_end = parts["u"], parts["sel_src"], parts["sel_end"]
469
+ fdir, cvar, zvars = parts["fdir"], parts["cvar"], parts["zvars"]
470
+ side_real, side_edge, side_tris = (parts["side_real"], parts["side_edge"],
471
+ parts["side_tris"])
472
+ all_tris = parts["all_tris"]
473
+ nT = len(tri.triangles)
474
+ mutual_tri = {}
475
+ for (k1, k2), c in cvar.items():
476
+ if solver.Value(c):
477
+ for i, z in enumerate(zvars[(k1, k2)]):
478
+ if solver.Value(z):
479
+ mutual_tri[(k1, k2)] = i
480
+ break
481
+ events = {}
482
+ for k, (a, b) in enumerate(F):
483
+ cur = next(t for t in all_tris if solver.Value(sel_src[(k, t)]))
484
+ end_t = next(t for t in all_tris if solver.Value(sel_end[(k, t)]))
485
+ seq = []
486
+ guard = 0
487
+ while True:
488
+ seq += [("m", pk) for pk, tt in sorted(mutual_tri.items())
489
+ if k in pk and tt == cur]
490
+ if cur == end_t:
491
+ break
492
+ guard += 1
493
+ moved = False
494
+ for sid in tri.tri_sides[cur]:
495
+ t1, t2 = side_tris[sid]
496
+ if cur not in (t1, t2):
497
+ continue
498
+ d = 0 if t1 == cur else 1
499
+ if solver.Value(fdir[k][(sid, d)]):
500
+ if side_real[sid]:
501
+ seq.append(("x", side_edge[sid]))
502
+ cur = side_tris[sid][1 - d]
503
+ moved = True
504
+ break
505
+ if not moved or guard > nT + 2:
506
+ seq.append(("stuck", None))
507
+ break
508
+ events[k] = seq
509
+ mutual = [[F_idx[k1], F_idx[k2]]
510
+ for (k1, k2), c in cvar.items() if solver.Value(c)]
511
+ return events, mutual
512
+
513
+
514
+ def _planarized_graph(nodes, edges, U_idx, F, F_idx, events, mutual):
515
+ """(paths, mutual crossings) -> planarized abstract graph H. H planar <=> the solution is realizable (docs section 4).
516
+
517
+ Returns None if a route is broken (judged unrealizable directly)."""
518
+ import networkx as nx
519
+ H = nx.Graph()
520
+ H.add_nodes_from(nodes)
521
+ for i in U_idx:
522
+ H.add_edge(*edges[i])
523
+ split = {} # edge idx -> (ga, gb) after the current subdivision; the same edge can be crossed by multiple arcs
524
+ xc = 0
525
+ arc_vertex = {}
526
+ for k, (a, b) in enumerate(F):
527
+ seq = events.get(k, [])
528
+ prev = a
529
+ for item in seq:
530
+ if item[0] == "x":
531
+ g = item[1]
532
+ x = f"X{xc}"
533
+ xc += 1
534
+ ga, gb = split.get(g, edges[g])
535
+ H.remove_edge(ga, gb)
536
+ H.add_edge(ga, x)
537
+ H.add_edge(x, gb)
538
+ split[g] = (ga, x) # crossing points are ordered along the arc path order
539
+ H.add_edge(prev, x)
540
+ prev = x
541
+ elif item[0] == "m":
542
+ pk = item[1]
543
+ if pk not in arc_vertex:
544
+ x = f"X{xc}"
545
+ xc += 1
546
+ arc_vertex[pk] = x
547
+ H.add_node(x)
548
+ x = arc_vertex[pk]
549
+ H.add_edge(prev, x)
550
+ prev = x
551
+ else:
552
+ return None
553
+ H.add_edge(prev, b)
554
+ return H
555
+
556
+
557
+ def oil_insertion(nodes, edges, U_idx, F_idx, rotation,
558
+ ub=None, minimize=True, lex=True, max_nogood=400):
559
+ """Simultaneous-optimal insertion for fixed (U, Pi) (CP-SAT proposal + planarization-oracle adjudication).
560
+
561
+ minimize=True: return the optimal realizable solution (ok/cost/routes/events/mutual);
562
+ minimize=False: decide "a realizable solution with cost <= ub exists" (ok/feasible).
563
+ why='nogood-cap' means the adjudication loop hit its cap undecided -- the caller should honestly degrade the coverage flag.
564
+ """
565
+ U_key = {}
566
+ for i in U_idx:
567
+ x, y = edges[i]
568
+ U_key[tuple(sorted((x, y)))] = i
569
+ U_edges = [edges[i] for i in U_idx]
570
+ F = [edges[i] for i in F_idx]
571
+ if not F:
572
+ return {"ok": True, "cost": 0, "routes": {}, "events": {}, "mutual": []}
573
+ faces, ok = _faces_from_rotation(rotation, nodes, U_edges)
574
+ if not ok:
575
+ return {"ok": False, "why": "rotation-not-planar"}
576
+ try:
577
+ tri = Triangulation(faces, U_key)
578
+ except AssertionError as exc:
579
+ # embedding structure anomaly (non-simple face walk, etc.) -- degrade honestly instead of crashing
580
+ return {"ok": False, "why": f"triangulation-failed: {exc}"}
581
+
582
+ try:
583
+ from ortools.sat.python import cp_model
584
+ except Exception:
585
+ return {"ok": False, "why": "no-ortools"}
586
+ import networkx as nx
587
+
588
+ parts = _build_model(tri, nodes, edges, F, cp_model)
589
+ m = parts["model"]
590
+ cost_real = [parts["u"][(k, sid)] for k in range(len(F))
591
+ for sid in range(len(tri.sides)) if parts["side_real"][sid]]
592
+ total = sum(cost_real) + sum(parts["cvar"].values())
593
+ if minimize:
594
+ if lex:
595
+ wu = sum((i + 1) * v for i, v in enumerate(cost_real))
596
+ m.Minimize(10 ** 7 * total + 10 ** 5 * sum(parts["cvar"].values()) + wu)
597
+ else:
598
+ m.Minimize(total)
599
+ else:
600
+ m.Add(total <= ub)
601
+
602
+ solver = cp_model.CpSolver()
603
+ solver.parameters.num_workers = 1 # determinism hard rule
604
+ solver.parameters.random_seed = 42
605
+ for _round in range(max_nogood + 1):
606
+ st = solver.Solve(m)
607
+ if st == cp_model.INFEASIBLE:
608
+ if not minimize:
609
+ return {"ok": False, "why": "infeasible"}
610
+ if _round == 0:
611
+ return {"ok": False, "why": "infeasible"}
612
+ return {"ok": False, "why": "exhausted"}
613
+ if st not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
614
+ return {"ok": False, "why": "unknown"}
615
+ events, mutual = _extract_solution(tri, edges, F, F_idx, parts, solver)
616
+ H = _planarized_graph(nodes, edges, U_idx, F, F_idx, events, mutual)
617
+ if H is not None and nx.is_planar(H):
618
+ if not minimize:
619
+ return {"ok": True, "feasible": True}
620
+ routes = {}
621
+ for k, (a, b) in enumerate(F):
622
+ routes[F_idx[k]] = {
623
+ "a": a, "b": b,
624
+ "crossings": [it[1] for it in events[k] if it[0] == "x"],
625
+ }
626
+ cost_x = sum(len(r["crossings"]) for r in routes.values()) + len(mutual)
627
+ return {"ok": True, "cost": cost_x, "routes": routes,
628
+ "events": {F_idx[k]: events[k] for k in events},
629
+ "mutual": mutual}
630
+ # not realizable -> a no-good clause eliminates exactly the current (routing, mutual-crossing) assignment
631
+ lits = [v.Not() if solver.Value(v) else v for v in parts["scope"]]
632
+ m.AddBoolOr(lits)
633
+ return {"ok": False, "why": "nogood-cap"}
634
+
635
+
636
+ # ────────────────────────── Heuristics / lower bound ──────────────────────────
637
+
638
+
639
+ def _heuristic_planarization(nodes, edges):
640
+ import networkx as nx
641
+ order = sorted(range(len(edges)),
642
+ key=lambda i: (min(edges[i]), max(edges[i]), i))
643
+ U = []
644
+ for i in order:
645
+ g = nx.Graph()
646
+ g.add_nodes_from(nodes)
647
+ for j in U + [i]:
648
+ g.add_edge(*edges[j])
649
+ if nx.is_planar(g):
650
+ U = U + [i]
651
+ F = [i for i in range(len(edges)) if i not in set(U)]
652
+ return U, F
653
+
654
+
655
+ def _embed_U(nodes, edges, U_idx):
656
+ import networkx as nx
657
+ g = nx.Graph()
658
+ g.add_nodes_from(nodes)
659
+ for i in U_idx:
660
+ g.add_edge(*edges[i])
661
+ planar, emb = nx.check_planarity(g)
662
+ if not planar:
663
+ return None
664
+ return {v: list(emb.neighbors_cw_order(v)) for v in g.nodes}
665
+
666
+
667
+ def _kuratowski_lb(nodes, edges):
668
+ """Global lower bound (always <= cr): greedy extraction of Kuratowski counterexamples (edge-disjoint) + z-ILP.
669
+
670
+ cr >= ceil(min marked edges / 2): in any drawing every Kuratowski subgraph must contain a crossing internally;
671
+ min marked edges <= crossed edges <= 2 * crossing count."""
672
+ import networkx as nx
673
+ g = nx.Graph()
674
+ g.add_nodes_from(nodes)
675
+ for a, b in edges:
676
+ g.add_edge(a, b)
677
+ ks = []
678
+ while g.number_of_edges() > 0:
679
+ planar, cert = nx.check_planarity(g, counterexample=True)
680
+ if planar:
681
+ break
682
+ kedges = sorted({tuple(sorted((x, y))) for x, y in cert.edges()})
683
+ if not kedges:
684
+ break
685
+ ks.append(kedges)
686
+ g.remove_edges_from(kedges)
687
+ if not ks:
688
+ return 0, []
689
+ try:
690
+ from ortools.sat.python import cp_model
691
+ except Exception:
692
+ return 0, ks
693
+ m = cp_model.CpModel()
694
+ z = {}
695
+ for a, b in edges:
696
+ z[tuple(sorted((a, b)))] = m.NewBoolVar(f"z_{a}_{b}")
697
+ for kedges in ks:
698
+ m.Add(sum(z[e] for e in kedges) >= 1)
699
+ m.Minimize(sum(z.values()))
700
+ solver = cp_model.CpSolver()
701
+ solver.parameters.num_workers = 1
702
+ solver.parameters.random_seed = 42
703
+ if solver.Solve(m) in (cp_model.OPTIMAL, cp_model.FEASIBLE):
704
+ zmin = int(round(solver.ObjectiveValue()))
705
+ return (zmin + 1) // 2, ks
706
+ return 0, ks
707
+
708
+
709
+ # ────────────────────────── Main entry ──────────────────────────
710
+
711
+
712
+ def crossing_exact(graph, max_nodes_exact=30, subset_cap=4000,
713
+ rotation_cap=200000, max_nogood=400):
714
+ """CLD Graph -> crossing-number certificate block.
715
+
716
+ crossing_number = UB (count from an oracle-verified real drawing, always true);
717
+ proven_optimal is relative to the model class in docs/crossing-exact.md;
718
+ lb/gap are honest; coverage: full/partial/planar/trivial."""
719
+ cores, red = safe_reduce(graph)
720
+ total_ub, total_lb = 0, 0
721
+ proven, coverage = True, "full"
722
+ witnesses, methods = [], []
723
+ for cnodes, cedges in cores:
724
+ try:
725
+ if len(cnodes) > max_nodes_exact or not _has_solver():
726
+ r = _core_heuristic(cnodes, cedges)
727
+ else:
728
+ r = _core_exact(cnodes, cedges, subset_cap, rotation_cap,
729
+ max_nogood)
730
+ except Exception as exc: # noqa: BLE001 -- unexpected-path fallback, honest degradation
731
+ r = {"ub": len(cedges), "lb": 0, "proven": False,
732
+ "coverage": "partial",
733
+ "method": (f"core failed ({type(exc).__name__}: {exc}) — "
734
+ "heuristic upper-bound fallback"),
735
+ "witness": None}
736
+ total_ub += r["ub"]
737
+ total_lb += r["lb"]
738
+ proven = proven and r["proven"]
739
+ if r["coverage"] == "partial":
740
+ coverage = "partial"
741
+ if r.get("witness"):
742
+ witnesses.append(r["witness"])
743
+ methods.append(r["method"])
744
+ if not cores:
745
+ coverage = "trivial"
746
+ methods = ["trivial (0 crossings by reduction)"]
747
+ return {
748
+ "crossing_number": total_ub,
749
+ "proven_optimal": proven,
750
+ "lb": total_lb,
751
+ "gap": total_ub - total_lb,
752
+ "coverage": coverage,
753
+ "method": "; ".join(dict.fromkeys(methods)),
754
+ "witness": witnesses,
755
+ "reduced": red,
756
+ }
757
+
758
+
759
+ def _has_solver():
760
+ try:
761
+ from ortools.sat.python import cp_model # noqa: F401
762
+ return True
763
+ except Exception:
764
+ return False
765
+
766
+
767
+ def _greedy_insert_ub(nodes, edges, U_idx, F_idx, rotation):
768
+ """Honest UB when oil insertion fails: insert F edges one by one into the current planarized drawing.
769
+
770
+ Each step: current planar graph P (initially = the U embedding, including subdivision crossing points of earlier F edges) -> triangulate
771
+ (with bridge/cut-vertex pocket splitting) -> 0/1-Dijkstra on the dual graph to find the minimum-crossing path between triangles containing the two
772
+ endpoints -> single-edge face-path insertion is realizable (classical theorem) -> subdivide, update P and re-embed.
773
+ After all insertions, the planarization oracle (the same H construction as Theorem C) verifies once; any step that
774
+ fails returns (None, None) for the upper-layer fallback. Not optimal, honestly proven=False."""
775
+ import heapq
776
+ import networkx as nx
777
+
778
+ def _route_and_insert(P, edge_of, a, b, xc_base):
779
+ """Insert (a,b) on the triangulated dual of P: returns (list of crossed original edge ids, new P, new edge_of)"""
780
+ planar, emb = nx.check_planarity(P)
781
+ if not planar:
782
+ return None, None, None, xc_base
783
+ rot = {v: list(emb.neighbors_cw_order(v)) for v in P.nodes}
784
+ P_edges = list(P.edges())
785
+ key = {tuple(sorted(e)): i for i, e in enumerate(P_edges)}
786
+ faces, ok = _faces_from_rotation(rot, list(P.nodes), P_edges)
787
+ if not ok:
788
+ return None, None, None, xc_base
789
+ tri = Triangulation(faces, key)
790
+ all_tris = list(range(len(tri.triangles)))
791
+ starts = tri.node_tris.get(a, all_tris) or all_tris
792
+ ends = tri.node_tris.get(b, all_tris) or all_tris
793
+ dist, parent = {}, {}
794
+ pq = []
795
+ for t in starts:
796
+ dist[t] = 0
797
+ parent[t] = None
798
+ heapq.heappush(pq, (0, t))
799
+ end_t = None
800
+ while pq:
801
+ d, t = heapq.heappop(pq)
802
+ if d > dist.get(t, 1 << 30):
803
+ continue
804
+ if t in ends:
805
+ end_t = t
806
+ break
807
+ for sid in tri.tri_sides[t]:
808
+ t1, t2 = tri.sides[sid]["t1"], tri.sides[sid]["t2"]
809
+ nt = t2 if t1 == t else t1
810
+ w = d + (1 if tri.sides[sid]["kind"] == "edge" else 0)
811
+ if w < dist.get(nt, 1 << 30):
812
+ dist[nt] = w
813
+ parent[nt] = (t, sid)
814
+ heapq.heappush(pq, (w, nt))
815
+ if end_t is None:
816
+ return None, None, None, xc_base # dual disconnected (P disconnected) -- hand off to the upper-layer fallback
817
+ crossed = [] # P edges crossed by this path (in path order)
818
+ t = end_t
819
+ while parent[t] is not None and not (t in starts and dist[t] == 0):
820
+ pt, sid = parent[t]
821
+ if tri.sides[sid]["kind"] == "edge":
822
+ crossed.append(tri.sides[sid]["payload"])
823
+ t = pt
824
+ crossed.reverse()
825
+ # Subdivide and update P: crossing points are inserted into the crossed edges in path order (same semantics as _planarized_graph).
826
+ # The two subdivision segments of a crossed edge still belong to the original edge (mapping inherited); F path segments are tagged ("F", k).
827
+ split = {}
828
+ xc_tag = [xc_base]
829
+ prev = a
830
+ for pe in crossed:
831
+ x = f"X#{xc_tag[0]}"
832
+ xc_tag[0] += 1
833
+ ga, gb = split.get(pe, P_edges[pe])
834
+ oid = edge_of.get(tuple(sorted((ga, gb))))
835
+ P.remove_edge(ga, gb)
836
+ P.add_edge(ga, x)
837
+ P.add_edge(x, gb)
838
+ edge_of[(ga, x)] = oid
839
+ edge_of[(x, gb)] = oid
840
+ split[pe] = (ga, x)
841
+ P.add_edge(prev, x)
842
+ edge_of[tuple(sorted((prev, x)))] = ("F", k)
843
+ prev = x
844
+ P.add_edge(prev, b)
845
+ edge_of[tuple(sorted((prev, b)))] = ("F", k)
846
+ orig = [edge_of.get(tuple(sorted(P_edges[pe]))) for pe in crossed]
847
+ return orig, P, edge_of, xc_tag[0]
848
+
849
+ U_key = {}
850
+ for i in U_idx:
851
+ U_key[tuple(sorted(edges[i]))] = i
852
+ U_edges = [edges[i] for i in U_idx]
853
+ faces, ok = _faces_from_rotation(rotation, nodes, U_edges)
854
+ if not ok:
855
+ return None, None
856
+ try:
857
+ Triangulation(faces, U_key) # self-check of the initial embedding structure
858
+ except AssertionError:
859
+ return None, None
860
+ P = nx.Graph()
861
+ P.add_nodes_from(nodes)
862
+ for i in U_idx:
863
+ P.add_edge(*edges[i])
864
+ edge_of = {tuple(sorted(edges[i])): i for i in U_idx}
865
+ routes, total = {}, 0
866
+ xc = 0
867
+ for k, (a, b) in enumerate([edges[i] for i in F_idx]):
868
+ orig, P, edge_of, xc = _route_and_insert(P, edge_of, a, b, xc)
869
+ if orig is None:
870
+ return None, None
871
+ routes[F_idx[k]] = {"a": a, "b": b, "crossings": orig}
872
+ total += len(orig)
873
+ if not nx.is_planar(P): # final check: P is the planarized graph H (Theorem C semantics)
874
+ return None, None
875
+ witness = {"F": list(F_idx), "routes": routes, "mutual": []}
876
+ return total, witness
877
+
878
+
879
+ def _core_heuristic(nodes, edges):
880
+ U, F = _heuristic_planarization(nodes, edges)
881
+ ub, witness, rot = None, None, None
882
+ if _has_solver():
883
+ rot = _embed_U(nodes, edges, U)
884
+ if rot is not None:
885
+ r = oil_insertion(nodes, edges, U, F, rot, minimize=True, lex=True)
886
+ if r.get("ok") and r.get("cost") is not None:
887
+ ub = r["cost"]
888
+ witness = {"F": F, "routes": r["routes"], "mutual": r["mutual"]}
889
+ if ub is None and rot is not None:
890
+ ub, witness = _greedy_insert_ub(nodes, edges, U, F, rot)
891
+ if ub is None:
892
+ ub = len(F) # last fallback when even the embedding is invalid (honestly partial, unproven)
893
+ lb, _ = _kuratowski_lb(nodes, edges)
894
+ return {"ub": ub, "lb": lb, "proven": False, "coverage": "partial",
895
+ "method": "heuristic planarization (optimality not proven)",
896
+ "witness": witness}
897
+
898
+
899
+ def _core_exact(nodes, edges, subset_cap, rotation_cap, max_nogood):
900
+ """Single-core exact layer: UB heuristic -> exhaustive B&B squeeze."""
901
+ import networkx as nx
902
+
903
+ m = len(edges)
904
+
905
+ def planar_subset(idxs):
906
+ g = nx.Graph()
907
+ g.add_nodes_from(nodes)
908
+ for i in idxs:
909
+ g.add_edge(*edges[i])
910
+ return nx.is_planar(g)
911
+
912
+ if planar_subset(range(m)):
913
+ return {"ub": 0, "lb": 0, "proven": True, "coverage": "planar",
914
+ "method": "planar (planarity witness)", "witness": None}
915
+
916
+ U, F = _heuristic_planarization(nodes, edges)
917
+ best_ub, witness = None, None
918
+ rot = _embed_U(nodes, edges, U)
919
+ if rot is not None:
920
+ r = oil_insertion(nodes, edges, U, F, rot, minimize=True, lex=True,
921
+ max_nogood=max_nogood)
922
+ if r.get("ok") and r.get("cost") is not None:
923
+ best_ub = r["cost"]
924
+ witness = {"F": F, "routes": r["routes"], "mutual": r["mutual"]}
925
+ if best_ub is None and rot is not None:
926
+ # oil did not converge (nogood-cap etc.): honest UB from greedy edge-by-edge insertion (with witness).
927
+ # Note len(F) is not a universally true upper bound (K7: F=6 < cr=9), so it must not serve as a reference value.
928
+ best_ub, witness = _greedy_insert_ub(nodes, edges, U, F, rot)
929
+ if best_ub is None:
930
+ best_ub = len(F) # last fallback when even the embedding is invalid (honestly partial, unproven)
931
+ lb, _ks = _kuratowski_lb(nodes, edges)
932
+ if lb >= best_ub:
933
+ return {"ub": best_ub, "lb": lb, "proven": True, "coverage": "full",
934
+ "method": ("exact (Kuratowski-z lower bound meets "
935
+ "planarization upper bound)"),
936
+ "witness": witness}
937
+
938
+ coverage_full = True
939
+ checked = 0
940
+ kmax = min(m, 2 * best_ub - 2)
941
+ for k in range(1, kmax + 1):
942
+ if k > 2 * best_ub - 2:
943
+ break # enumeration bound shrinks dynamically as the UB improves (still covers the optimum, docs section 3)
944
+ if (k + 1) // 2 >= best_ub:
945
+ continue
946
+ for Fc in itertools.combinations(range(m), k):
947
+ checked += 1
948
+ if checked > subset_cap:
949
+ coverage_full = False
950
+ break
951
+ fset = set(Fc)
952
+ Uc = [i for i in range(m) if i not in fset]
953
+ if not planar_subset(Uc):
954
+ continue
955
+ if not _two_connected(nodes, Uc, edges):
956
+ continue
957
+ embs, capped = _enum_rotations(
958
+ nodes, [edges[i] for i in Uc], cap=rotation_cap)
959
+ if capped:
960
+ coverage_full = False
961
+ for rot2 in embs:
962
+ r = oil_insertion(nodes, edges, Uc, Fc, rot2,
963
+ ub=best_ub - 1, minimize=False,
964
+ max_nogood=max_nogood)
965
+ if r.get("why") == "nogood-cap":
966
+ coverage_full = False
967
+ continue
968
+ if not r.get("ok"):
969
+ # why != infeasible/nogood-cap: this embedding was not fully adjudicated (triangulation
970
+ # failure / solver unknown, etc.) -- coverage incomplete, honestly mark partial
971
+ if r.get("why") != "infeasible":
972
+ coverage_full = False
973
+ continue
974
+ if r.get("feasible"):
975
+ r2 = oil_insertion(nodes, edges, Uc, Fc, rot2,
976
+ minimize=True, lex=True,
977
+ max_nogood=max_nogood)
978
+ if r2.get("ok") and r2.get("cost") is not None and \
979
+ r2["cost"] < best_ub:
980
+ best_ub = r2["cost"]
981
+ witness = {"F": list(Fc), "routes": r2["routes"],
982
+ "mutual": r2["mutual"]}
983
+ if not coverage_full:
984
+ break
985
+ if coverage_full:
986
+ proven = True
987
+ else:
988
+ proven = lb >= best_ub
989
+ return {"ub": best_ub, "lb": lb, "proven": proven,
990
+ "coverage": "full" if coverage_full else "partial",
991
+ "method": ("exact OIL (routing ILP + planarization oracle, "
992
+ "exhaustive B&B over planar subgraphs x embeddings)"
993
+ if coverage_full else
994
+ "OIL B&B, enumeration capped — honest gap"),
995
+ "witness": witness}
996
+
997
+
998
+ def _two_connected(nodes, U_idx, edges):
999
+ import networkx as nx
1000
+ g = nx.Graph()
1001
+ g.add_nodes_from(nodes)
1002
+ for i in U_idx:
1003
+ g.add_edge(*edges[i])
1004
+ return nx.is_biconnected(g)