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/__init__.py +32 -0
- stdraw/__main__.py +350 -0
- stdraw/_interactive_js.py +2030 -0
- stdraw/anchors.py +103 -0
- stdraw/bowtielayout.py +350 -0
- stdraw/check.py +651 -0
- stdraw/columnlayout.py +531 -0
- stdraw/comblayout.py +533 -0
- stdraw/crossing_exact.py +1004 -0
- stdraw/delivery.py +81 -0
- stdraw/dsl.py +99 -0
- stdraw/layout3d.py +167 -0
- stdraw/layout_auto.py +1827 -0
- stdraw/layoutdiff.py +424 -0
- stdraw/loops.py +145 -0
- stdraw/metrics.py +1225 -0
- stdraw/model.py +134 -0
- stdraw/placement.py +220 -0
- stdraw/planar.py +625 -0
- stdraw/recipes.py +68 -0
- stdraw/regularity.py +176 -0
- stdraw/render.py +361 -0
- stdraw/render3d.py +165 -0
- stdraw/rendercheck.py +90 -0
- stdraw/ringlayout.py +475 -0
- stdraw/routing.py +2630 -0
- stdraw/spinewinglayout.py +329 -0
- stdraw/symlayout.py +245 -0
- stdraw/textmetrics.py +97 -0
- stdraw/visibility.py +382 -0
- stdraw-0.7.13.dist-info/METADATA +383 -0
- stdraw-0.7.13.dist-info/RECORD +36 -0
- stdraw-0.7.13.dist-info/WHEEL +5 -0
- stdraw-0.7.13.dist-info/entry_points.txt +2 -0
- stdraw-0.7.13.dist-info/licenses/LICENSE +21 -0
- stdraw-0.7.13.dist-info/top_level.txt +1 -0
stdraw/crossing_exact.py
ADDED
|
@@ -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)
|