zeechimp commited on
Commit
b0f3f62
·
verified ·
1 Parent(s): 6fc054f

Upload solver_assistant_v3.py

Browse files
Files changed (1) hide show
  1. solver_assistant_v3.py +1788 -0
solver_assistant_v3.py ADDED
@@ -0,0 +1,1788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ solver_assistant_v3.py
4
+
5
+ Natural language -> structured instance -> exact solver -> verified answer.
6
+ JSON schema interface for LLM front-ends.
7
+ Bench mode: instances with analytically-known optima.
8
+ Cross-verifier: B&B vs independent exhaustive DFS (MAX-CUT).
9
+ Stress test harness.
10
+
11
+ No downloads. No network. Pure standard library.
12
+
13
+ Usage:
14
+ python3 solver_assistant_v3.py # interactive REPL
15
+ python3 solver_assistant_v3.py "your problem" # one-shot
16
+ python3 solver_assistant_v3.py --demo # built-in demos
17
+ python3 solver_assistant_v3.py --stress # stress test
18
+ python3 solver_assistant_v3.py --stress --trials 100
19
+ python3 solver_assistant_v3.py --bench # analytic-optimum bench
20
+ python3 solver_assistant_v3.py --cross # B&B vs DFS (MAX-CUT)
21
+ python3 solver_assistant_v3.py --cross --sizes 14,16,18,20,22
22
+ python3 solver_assistant_v3.py --json '{"type":"maxcut","edges":[["A","B",3]]}'
23
+ python3 solver_assistant_v3.py --help
24
+ """
25
+
26
+ import itertools
27
+ import json
28
+ import math
29
+ import random
30
+ import re
31
+ import sys
32
+ import time
33
+
34
+
35
+ # =============================================================================
36
+ # SOLVERS
37
+ # =============================================================================
38
+
39
+ def cut_value(W, side):
40
+ n = len(W)
41
+ return sum(W[i][j]
42
+ for i in range(n) for j in range(i + 1, n)
43
+ if side[i] != side[j])
44
+
45
+
46
+ def greedy_cut(W, seed=0):
47
+ n = len(W)
48
+ rng = random.Random(seed)
49
+ order = list(range(n))
50
+ rng.shuffle(order)
51
+ side = [0] * n
52
+ assigned = [False] * n
53
+ for v in order:
54
+ to0 = sum(W[v][u] for u in range(n) if assigned[u] and side[u] == 0)
55
+ to1 = sum(W[v][u] for u in range(n) if assigned[u] and side[u] == 1)
56
+ side[v] = 0 if to1 >= to0 else 1
57
+ assigned[v] = True
58
+ return cut_value(W, side), side
59
+
60
+
61
+ def local_search_maxcut(W, side, passes=10):
62
+ n = len(W)
63
+ side = side[:]
64
+ cur = cut_value(W, side)
65
+ for _ in range(passes):
66
+ improved = False
67
+ for v in range(n):
68
+ Wv = W[v]
69
+ sv = side[v]
70
+ gain = 0
71
+ for u in range(n):
72
+ if u == v:
73
+ continue
74
+ if side[u] == sv:
75
+ gain += Wv[u]
76
+ else:
77
+ gain -= Wv[u]
78
+ if gain > 0:
79
+ side[v] ^= 1
80
+ cur += gain
81
+ improved = True
82
+ if not improved:
83
+ break
84
+ return cur, side
85
+
86
+
87
+ def warm_start_maxcut(W, restarts=64, seed=0):
88
+ best_val, best_side = -1, None
89
+ for r in range(restarts):
90
+ v, s = greedy_cut(W, seed=seed * 1000 + r)
91
+ v, s = local_search_maxcut(W, s, passes=5)
92
+ if v > best_val:
93
+ best_val, best_side = v, s
94
+ return best_val, best_side
95
+
96
+
97
+ def brute_force_maxcut(W):
98
+ n = len(W)
99
+ best = -1
100
+ best_side = None
101
+ for mask in range(1 << n):
102
+ side = [(mask >> i) & 1 for i in range(n)]
103
+ v = cut_value(W, side)
104
+ if v > best:
105
+ best = v
106
+ best_side = side
107
+ return best, best_side
108
+
109
+
110
+ def maxcut_bb(W, time_limit, warm, warm_side):
111
+ n = len(W)
112
+ side = [-1] * n
113
+ order = sorted(range(n), key=lambda v: -sum(W[v]))
114
+ st = {"best": float(warm), "best_side": list(warm_side),
115
+ "nodes": 0, "aborted": False}
116
+ t0 = time.perf_counter()
117
+
118
+ def ub():
119
+ fc = 0.0
120
+ for i in range(n):
121
+ si = side[i]
122
+ if si < 0:
123
+ continue
124
+ Wi = W[i]
125
+ for j in range(i + 1, n):
126
+ sj = side[j]
127
+ if sj >= 0 and si != sj:
128
+ fc += Wi[j]
129
+ free = [v for v in range(n) if side[v] < 0]
130
+ extra = 0.0
131
+ for v in free:
132
+ Wv = W[v]
133
+ wa = wb = 0.0
134
+ for u in range(n):
135
+ w = Wv[u]
136
+ if not w:
137
+ continue
138
+ su = side[u]
139
+ if su == 0:
140
+ wa += w
141
+ elif su == 1:
142
+ wb += w
143
+ extra += wa if wa > wb else wb
144
+ for a in range(len(free)):
145
+ Wv = W[free[a]]
146
+ for b in range(a + 1, len(free)):
147
+ extra += Wv[free[b]]
148
+ return fc + extra
149
+
150
+ def rec():
151
+ if st["aborted"]:
152
+ return
153
+ st["nodes"] += 1
154
+ if (st["nodes"] & 0x3FF) == 0 and (time.perf_counter() - t0) > time_limit:
155
+ st["aborted"] = True
156
+ return
157
+ if ub() <= st["best"] + 1e-9:
158
+ return
159
+ v = -1
160
+ for x in order:
161
+ if side[x] < 0:
162
+ v = x
163
+ break
164
+ if v < 0:
165
+ val = cut_value(W, side)
166
+ if val > st["best"]:
167
+ st["best"] = val
168
+ st["best_side"] = side[:]
169
+ return
170
+ for s in (0, 1):
171
+ side[v] = s
172
+ rec()
173
+ side[v] = -1
174
+ if st["aborted"]:
175
+ return
176
+
177
+ rec()
178
+ st["seconds"] = time.perf_counter() - t0
179
+ return st
180
+
181
+
182
+ def maxcut_dfs_exact(W, time_limit=60.0):
183
+ """
184
+ Independent exact MAX-CUT by exhaustive DFS.
185
+
186
+ Fix vertex 0 to side 0 (S/T symmetry halves the search). DFS over
187
+ remaining vertices, maintaining the cut value incrementally.
188
+
189
+ Shares no code with maxcut_bb: different algorithm, no upper bound,
190
+ no warm start, no pruning. Genuinely independent cross-check.
191
+ Returns (best_value, best_mask, aborted_flag).
192
+ """
193
+ n = len(W)
194
+ if n == 0:
195
+ return 0, 0, False
196
+
197
+ neighbors = []
198
+ total_to_earlier = [0] * n
199
+ for v in range(n):
200
+ ns = [(u, W[v][u]) for u in range(v) if W[v][u]]
201
+ neighbors.append(ns)
202
+ total_to_earlier[v] = sum(w for _, w in ns)
203
+
204
+ best = [-1]
205
+ best_mask = [0]
206
+ aborted = [False]
207
+ t0 = time.perf_counter()
208
+
209
+ def rec(v, mask, cut):
210
+ if aborted[0]:
211
+ return
212
+ if (v & 0xFF) == 0 and (time.perf_counter() - t0) > time_limit:
213
+ aborted[0] = True
214
+ return
215
+ if v == n:
216
+ if cut > best[0]:
217
+ best[0] = cut
218
+ best_mask[0] = mask
219
+ return
220
+ to_1 = 0
221
+ for u, w in neighbors[v]:
222
+ if mask & (1 << u):
223
+ to_1 += w
224
+ to_0 = total_to_earlier[v] - to_1
225
+ rec(v + 1, mask, cut + to_1)
226
+ rec(v + 1, mask | (1 << v), cut + to_0)
227
+
228
+ rec(1, 0, 0)
229
+ return best[0], best_mask[0], aborted[0]
230
+
231
+
232
+ def solve_maxcut(W, names, time_limit=30.0):
233
+ n = len(W)
234
+ t0 = time.perf_counter()
235
+ if n <= 20:
236
+ val, side = brute_force_maxcut(W)
237
+ method = "brute force"
238
+ status = "OPTIMAL (proven)"
239
+ else:
240
+ warm, warm_side = warm_start_maxcut(W, restarts=64, seed=0)
241
+ res = maxcut_bb(W, time_limit, warm, warm_side)
242
+ val, side = res["best"], res["best_side"]
243
+ method = f"branch and bound ({res['nodes']:,} nodes)"
244
+ status = ("TIMEOUT (best found is lower bound)" if res["aborted"]
245
+ else "OPTIMAL (proven)")
246
+ t = time.perf_counter() - t0
247
+ check = cut_value(W, side)
248
+ partition = {
249
+ "S": [names[i] for i in range(n) if side[i] == 0],
250
+ "T": [names[i] for i in range(n) if side[i] == 1],
251
+ }
252
+ return {
253
+ "value": val,
254
+ "partition": partition,
255
+ "verify": check == val,
256
+ "method": method,
257
+ "status": status,
258
+ "seconds": t,
259
+ }
260
+
261
+
262
+ # --- SAT ---------------------------------------------------------------
263
+
264
+ def verify_sat(clauses, assignment):
265
+ for c in clauses:
266
+ satisfied = False
267
+ for lit in c:
268
+ v = abs(lit)
269
+ if v in assignment and assignment[v] == (lit > 0):
270
+ satisfied = True
271
+ break
272
+ if not satisfied:
273
+ return False
274
+ return True
275
+
276
+
277
+ def dpll(clauses, n, assignment, deadline, nodes):
278
+ nodes[0] += 1
279
+ if (nodes[0] & 0xFF) == 0 and time.perf_counter() > deadline:
280
+ raise TimeoutError
281
+
282
+ while True:
283
+ unit = None
284
+ for c in clauses:
285
+ satisfied = False
286
+ unassigned = []
287
+ for lit in c:
288
+ v = abs(lit)
289
+ if v in assignment:
290
+ if assignment[v] == (lit > 0):
291
+ satisfied = True
292
+ break
293
+ else:
294
+ unassigned.append(lit)
295
+ if satisfied:
296
+ continue
297
+ if not unassigned:
298
+ return None
299
+ if len(unassigned) == 1:
300
+ unit = unassigned[0]
301
+ break
302
+ if unit is None:
303
+ break
304
+ assignment[abs(unit)] = (unit > 0)
305
+
306
+ pos, neg = set(), set()
307
+ for c in clauses:
308
+ for lit in c:
309
+ v = abs(lit)
310
+ if v in assignment:
311
+ continue
312
+ (pos if lit > 0 else neg).add(v)
313
+ pure_pos = pos - neg
314
+ pure_neg = neg - pos
315
+ if pure_pos or pure_neg:
316
+ new_assign = dict(assignment)
317
+ for v in pure_pos:
318
+ new_assign[v] = True
319
+ for v in pure_neg:
320
+ new_assign[v] = False
321
+ return dpll(clauses, n, new_assign, deadline, nodes)
322
+
323
+ all_sat = True
324
+ for c in clauses:
325
+ satisfied = False
326
+ for lit in c:
327
+ v = abs(lit)
328
+ if v in assignment and assignment[v] == (lit > 0):
329
+ satisfied = True
330
+ break
331
+ if not satisfied:
332
+ all_sat = False
333
+ break
334
+ if all_sat:
335
+ for v in range(1, n + 1):
336
+ if v not in assignment:
337
+ assignment[v] = True
338
+ return assignment
339
+
340
+ counts = {}
341
+ for c in clauses:
342
+ for lit in c:
343
+ v = abs(lit)
344
+ if v not in assignment:
345
+ counts[v] = counts.get(v, 0) + 1
346
+ if not counts:
347
+ return None
348
+ var = max(counts, key=counts.get)
349
+
350
+ for val in (True, False):
351
+ new_assign = dict(assignment)
352
+ new_assign[var] = val
353
+ result = dpll(clauses, n, new_assign, deadline, nodes)
354
+ if result is not None:
355
+ return result
356
+ return None
357
+
358
+
359
+ def solve_sat(clauses, n_vars, time_limit=30.0):
360
+ deadline = time.perf_counter() + time_limit
361
+ nodes = [0]
362
+ t0 = time.perf_counter()
363
+ try:
364
+ result = dpll(clauses, n_vars, {}, deadline, nodes)
365
+ t = time.perf_counter() - t0
366
+ if result is None:
367
+ return {
368
+ "satisfiable": False,
369
+ "model": None,
370
+ "verify": True,
371
+ "method": f"DPLL ({nodes[0]:,} nodes)",
372
+ "status": "UNSAT (proven)",
373
+ "seconds": t,
374
+ }
375
+ ok = verify_sat(clauses, result)
376
+ return {
377
+ "satisfiable": True,
378
+ "model": {v: result[v] for v in range(1, n_vars + 1)},
379
+ "verify": ok,
380
+ "method": f"DPLL ({nodes[0]:,} nodes)",
381
+ "status": "SAT (model verified)",
382
+ "seconds": t,
383
+ }
384
+ except TimeoutError:
385
+ t = time.perf_counter() - t0
386
+ return {
387
+ "satisfiable": None,
388
+ "model": None,
389
+ "verify": False,
390
+ "method": f"DPLL ({nodes[0]:,} nodes)",
391
+ "status": "TIMEOUT",
392
+ "seconds": t,
393
+ }
394
+
395
+
396
+ # --- SUBSET-SUM --------------------------------------------------------
397
+
398
+ def subset_sum_mim(arr, target):
399
+ n = len(arr)
400
+ half = n // 2
401
+ left, right = arr[:half], arr[half:]
402
+
403
+ def all_sums(a):
404
+ out = []
405
+ for mask in range(1 << len(a)):
406
+ s = 0
407
+ for i in range(len(a)):
408
+ if mask & (1 << i):
409
+ s += a[i]
410
+ out.append((s, mask))
411
+ return out
412
+
413
+ L = all_sums(left)
414
+ R = all_sums(right)
415
+ R.sort()
416
+
417
+ for s, m in L:
418
+ need = target - s
419
+ lo, hi = 0, len(R)
420
+ while lo < hi:
421
+ mid = (lo + hi) // 2
422
+ if R[mid][0] < need:
423
+ lo = mid + 1
424
+ else:
425
+ hi = mid
426
+ if lo < len(R) and R[lo][0] == need:
427
+ return m | (R[lo][1] << half)
428
+ return None
429
+
430
+
431
+ def brute_force_subset_sum(arr, target):
432
+ n = len(arr)
433
+ for mask in range(1 << n):
434
+ s = sum(arr[i] for i in range(n) if mask & (1 << i))
435
+ if s == target:
436
+ return mask
437
+ return None
438
+
439
+
440
+ def solve_subset_sum(arr, target):
441
+ t0 = time.perf_counter()
442
+ mask = subset_sum_mim(arr, target)
443
+ t = time.perf_counter() - t0
444
+ if mask is None:
445
+ return {
446
+ "found": False,
447
+ "subset": None,
448
+ "verify": True,
449
+ "method": "meet-in-the-middle",
450
+ "status": "NO EXACT SUBSET",
451
+ "seconds": t,
452
+ }
453
+ chosen = [arr[i] for i in range(len(arr)) if mask & (1 << i)]
454
+ return {
455
+ "found": True,
456
+ "subset": chosen,
457
+ "verify": sum(chosen) == target,
458
+ "method": "meet-in-the-middle",
459
+ "status": "FOUND (verified)",
460
+ "seconds": t,
461
+ }
462
+
463
+
464
+ # --- TSP ---------------------------------------------------------------
465
+
466
+ def held_karp(D):
467
+ n = len(D)
468
+ INF = float("inf")
469
+ size = 1 << n
470
+ dp = [[INF] * n for _ in range(size)]
471
+ parent = [[-1] * n for _ in range(size)]
472
+ dp[1][0] = 0.0
473
+
474
+ for mask in range(size):
475
+ row = dp[mask]
476
+ for i in range(n):
477
+ if not (mask & (1 << i)):
478
+ continue
479
+ ci = row[i]
480
+ if ci == INF:
481
+ continue
482
+ Di = D[i]
483
+ for j in range(n):
484
+ if mask & (1 << j):
485
+ continue
486
+ nm = mask | (1 << j)
487
+ nc = ci + Di[j]
488
+ if nc < dp[nm][j]:
489
+ dp[nm][j] = nc
490
+ parent[nm][j] = i
491
+
492
+ full = size - 1
493
+ best = INF
494
+ end = -1
495
+ for i in range(n):
496
+ c = dp[full][i] + D[i][0]
497
+ if c < best:
498
+ best = c
499
+ end = i
500
+
501
+ path = []
502
+ mask = full
503
+ i = end
504
+ while i != -1:
505
+ path.append(i)
506
+ p = parent[mask][i]
507
+ mask ^= (1 << i)
508
+ i = p
509
+ path.reverse()
510
+ return best, path
511
+
512
+
513
+ def brute_force_tsp(points):
514
+ n = len(points)
515
+ best_cost = float("inf")
516
+ best_tour = None
517
+ for perm in itertools.permutations(range(1, n)):
518
+ tour = (0,) + perm
519
+ cost = sum(math.dist(points[tour[i]], points[tour[(i + 1) % n]])
520
+ for i in range(n))
521
+ if cost < best_cost:
522
+ best_cost = cost
523
+ best_tour = list(tour)
524
+ return best_cost, best_tour
525
+
526
+
527
+ def solve_tsp(points, time_limit=60.0):
528
+ n = len(points)
529
+ if n > 18:
530
+ return {
531
+ "tour": None, "length": None, "verify": False,
532
+ "method": "Held-Karp refused",
533
+ "status": f"TOO LARGE (n={n} > 18)", "seconds": 0.0,
534
+ }
535
+ D = [[math.dist(points[i], points[j]) for j in range(n)] for i in range(n)]
536
+ t0 = time.perf_counter()
537
+ cost, tour = held_karp(D)
538
+ t = time.perf_counter() - t0
539
+ s = sum(D[tour[k]][tour[(k + 1) % n]] for k in range(n))
540
+ return {
541
+ "tour": tour, "length": cost,
542
+ "verify": abs(s - cost) < 1e-6,
543
+ "method": "Held-Karp DP",
544
+ "status": "OPTIMAL (proven)",
545
+ "seconds": t,
546
+ }
547
+
548
+
549
+ # =============================================================================
550
+ # TEXT PARSERS
551
+ # =============================================================================
552
+
553
+ class ParseError(Exception):
554
+ pass
555
+
556
+
557
+ EDGE_RE = re.compile(
558
+ r"([A-Za-z][A-Za-z0-9_]*|\d+)\s*[-=:]\s*([A-Za-z][A-Za-z0-9_]*|\d+)"
559
+ r"\s*(?:[:=]\s*(-?\d+))?"
560
+ )
561
+
562
+
563
+ def parse_maxcut(text):
564
+ edges = []
565
+ for m in EDGE_RE.finditer(text):
566
+ u, v, w = m.group(1), m.group(2), m.group(3)
567
+ edges.append((u, v, int(w) if w is not None else 1))
568
+ if not edges:
569
+ raise ParseError("No edges found. Try: max cut on A-B:3, B-C:5")
570
+ names = []
571
+ for u, v, _ in edges:
572
+ if u not in names:
573
+ names.append(u)
574
+ if v not in names:
575
+ names.append(v)
576
+ index = {n: i for i, n in enumerate(names)}
577
+ n = len(names)
578
+ W = [[0] * n for _ in range(n)]
579
+ for u, v, w in edges:
580
+ i, j = index[u], index[v]
581
+ W[i][j] = w
582
+ W[j][i] = w
583
+ return W, names
584
+
585
+
586
+ CLAUSE_RE = re.compile(r"\(([^()]+)\)")
587
+
588
+
589
+ def parse_sat(text):
590
+ raw_clauses = CLAUSE_RE.findall(text)
591
+ if not raw_clauses:
592
+ m = re.search(
593
+ r"(?:sat|3[\s-]?sat|formula|clauses?)\s*[:=]?\s*(.+)$",
594
+ text, re.I | re.S,
595
+ )
596
+ rest = m.group(1).strip() if m else text
597
+ raw_clauses = re.split(r"\s+and\s+", rest, flags=re.I)
598
+
599
+ raw_clauses = [c for c in (r.strip() for r in raw_clauses) if c]
600
+ if not raw_clauses:
601
+ raise ParseError(
602
+ "No clauses found. Try: sat (x1 or -x2 or x3) and (x2 or x3)"
603
+ )
604
+
605
+ parsed = []
606
+ var_names = set()
607
+ for raw in raw_clauses:
608
+ raw = raw.strip().strip("()").strip()
609
+ if not raw:
610
+ continue
611
+ parts = re.split(r"\s+(?:or|\|)\s+|\s*,\s*", raw, flags=re.I)
612
+ clause = []
613
+ for p in parts:
614
+ p = p.strip()
615
+ if not p:
616
+ continue
617
+ neg = False
618
+ while p and p[0] in "-~!":
619
+ neg = not neg
620
+ p = p[1:].strip()
621
+ if not p:
622
+ raise ParseError("Empty literal in clause")
623
+ if re.match(r"^([A-Za-z_]*)(\d+)$", p) or \
624
+ re.match(r"^[A-Za-z_]\w*$", p):
625
+ var_names.add(p)
626
+ clause.append((p, neg))
627
+ else:
628
+ raise ParseError(f"Cannot parse literal: {p!r}")
629
+ if clause:
630
+ parsed.append(clause)
631
+
632
+ if not parsed:
633
+ raise ParseError("Could not parse any clauses.")
634
+
635
+ name_to_int = {name: i + 1 for i, name in enumerate(sorted(var_names))}
636
+ int_clauses = [
637
+ tuple((name_to_int[n] if not neg else -name_to_int[n])
638
+ for n, neg in c)
639
+ for c in parsed
640
+ ]
641
+ return int_clauses, len(name_to_int), name_to_int
642
+
643
+
644
+ LIST_RE = re.compile(r"\[([^\]]+)\]")
645
+
646
+
647
+ def parse_subset_sum(text):
648
+ m = LIST_RE.search(text)
649
+ if m:
650
+ nums = [int(x) for x in re.split(r"[,\s]+", m.group(1)) if x.strip()]
651
+ else:
652
+ nums = [int(x) for x in re.findall(r"-?\d+", text)]
653
+ if not nums:
654
+ raise ParseError("No numbers found. Try: subset sum [3, 7, 1] target 4")
655
+ target = None
656
+ for pat in [r"(?:target|sum to|summing to|equal(?:s| to)?|=)\s*(-?\d+)",
657
+ r"(-?\d+)\s*$"]:
658
+ tm = re.search(pat, text, re.I)
659
+ if tm:
660
+ target = int(tm.group(1))
661
+ break
662
+ defaulted = False
663
+ if target is None:
664
+ target = sum(nums) // 2
665
+ defaulted = True
666
+ return nums, target, defaulted
667
+
668
+
669
+ PAIR_RE = re.compile(
670
+ r"\(\s*(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*,\s*"
671
+ r"(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*\)"
672
+ )
673
+
674
+
675
+ def parse_tsp(text):
676
+ pairs = PAIR_RE.findall(text)
677
+ if not pairs:
678
+ raise ParseError(
679
+ "No coordinates found. Try: tsp (0,0) (1,2) (3,1) (2,4)"
680
+ )
681
+ points = [(float(a), float(b)) for a, b in pairs]
682
+ if len(points) < 3:
683
+ raise ParseError("TSP needs at least 3 points.")
684
+ return points
685
+
686
+
687
+ # =============================================================================
688
+ # JSON SCHEMA INTERFACE (LLM adapter point)
689
+ # =============================================================================
690
+
691
+ def _require(d, key, typ, ctx):
692
+ if key not in d:
693
+ raise ValueError(f"{ctx}: missing field {key!r}")
694
+ v = d[key]
695
+ if not isinstance(v, typ):
696
+ raise ValueError(
697
+ f"{ctx}: field {key!r} must be {typ.__name__}, "
698
+ f"got {type(v).__name__}"
699
+ )
700
+ return v
701
+
702
+
703
+ def _validate_maxcut_schema(d):
704
+ edges = _require(d, "edges", list, "maxcut")
705
+ if not edges:
706
+ raise ValueError("maxcut: 'edges' list is empty")
707
+ names = []
708
+ parsed = []
709
+ for i, e in enumerate(edges):
710
+ if not isinstance(e, (list, tuple)) or len(e) not in (2, 3):
711
+ raise ValueError(
712
+ f"maxcut: edge {i} must be [u, v] or [u, v, w], got {e!r}"
713
+ )
714
+ u, v = e[0], e[1]
715
+ w = e[2] if len(e) == 3 else 1
716
+ if not isinstance(w, int) or w < 0:
717
+ raise ValueError(
718
+ f"maxcut: edge {i} weight must be non-negative int, got {w!r}"
719
+ )
720
+ for x in (u, v):
721
+ if not isinstance(x, (str, int)):
722
+ raise ValueError(f"maxcut: vertex {x!r} must be str or int")
723
+ if x not in names:
724
+ names.append(x)
725
+ parsed.append((u, v, w))
726
+ n = len(names)
727
+ index = {nm: i for i, nm in enumerate(names)}
728
+ W = [[0] * n for _ in range(n)]
729
+ for u, v, w in parsed:
730
+ i, j = index[u], index[v]
731
+ W[i][j] = w
732
+ W[j][i] = w
733
+ return W, names
734
+
735
+
736
+ def _validate_sat_schema(d):
737
+ clauses = _require(d, "clauses", list, "sat")
738
+ if not clauses:
739
+ raise ValueError("sat: 'clauses' list is empty")
740
+ n_vars = d.get("n_vars")
741
+ if n_vars is not None and not isinstance(n_vars, int):
742
+ raise ValueError("sat: 'n_vars' must be int if present")
743
+ max_var = 0
744
+ int_clauses = []
745
+ for i, c in enumerate(clauses):
746
+ if not isinstance(c, (list, tuple)) or len(c) < 1:
747
+ raise ValueError(f"sat: clause {i} must be a non-empty list")
748
+ clause = []
749
+ for lit in c:
750
+ if not isinstance(lit, int) or lit == 0:
751
+ raise ValueError(
752
+ f"sat: literal {lit!r} in clause {i} must be nonzero int"
753
+ )
754
+ max_var = max(max_var, abs(lit))
755
+ clause.append(lit)
756
+ int_clauses.append(tuple(clause))
757
+ if n_vars is None:
758
+ n_vars = max_var
759
+ elif n_vars < max_var:
760
+ raise ValueError(
761
+ f"sat: 'n_vars'={n_vars} but literal refers to var {max_var}"
762
+ )
763
+ return int_clauses, n_vars
764
+
765
+
766
+ def _validate_subset_sum_schema(d):
767
+ nums = _require(d, "numbers", list, "subset_sum")
768
+ if not nums:
769
+ raise ValueError("subset_sum: 'numbers' list is empty")
770
+ for i, x in enumerate(nums):
771
+ if not isinstance(x, int):
772
+ raise ValueError(f"subset_sum: number {i} must be int, got {x!r}")
773
+ target = d.get("target")
774
+ if target is None:
775
+ target = sum(nums) // 2
776
+ defaulted = True
777
+ else:
778
+ if not isinstance(target, int):
779
+ raise ValueError("subset_sum: 'target' must be int")
780
+ defaulted = False
781
+ return nums, target, defaulted
782
+
783
+
784
+ def _validate_tsp_schema(d):
785
+ pts = _require(d, "points", list, "tsp")
786
+ if len(pts) < 3:
787
+ raise ValueError("tsp: need at least 3 points")
788
+ out = []
789
+ for i, p in enumerate(pts):
790
+ if not isinstance(p, (list, tuple)) or len(p) != 2:
791
+ raise ValueError(f"tsp: point {i} must be [x, y], got {p!r}")
792
+ x, y = p
793
+ if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
794
+ raise ValueError(f"tsp: point {i} coords must be numeric")
795
+ out.append((float(x), float(y)))
796
+ return out
797
+
798
+
799
+ def dispatch_from_json(schema, verbose=True):
800
+ if not isinstance(schema, dict):
801
+ raise ValueError("schema must be a dict")
802
+ kind = schema.get("type")
803
+ if kind is None:
804
+ raise ValueError("schema missing 'type' field")
805
+ kind = str(kind).lower()
806
+
807
+ if kind == "maxcut":
808
+ W, names = _validate_maxcut_schema(schema)
809
+ if verbose:
810
+ n_edges = sum(1 for i in range(len(W))
811
+ for j in range(i + 1, len(W)) if W[i][j])
812
+ print(f" [json] maxcut: {len(names)} vertices, {n_edges} edges")
813
+ r = solve_maxcut(W, names, time_limit=30.0)
814
+ if verbose:
815
+ _report_maxcut(r)
816
+ return r
817
+
818
+ if kind == "sat":
819
+ clauses, n_vars = _validate_sat_schema(schema)
820
+ if verbose:
821
+ print(f" [json] sat: {n_vars} variables, {len(clauses)} clauses")
822
+ r = solve_sat(clauses, n_vars, time_limit=30.0)
823
+ if verbose:
824
+ _report_sat(r)
825
+ return r
826
+
827
+ if kind == "subset_sum":
828
+ nums, target, defaulted = _validate_subset_sum_schema(schema)
829
+ if verbose:
830
+ note = " (target defaulted to half-total)" if defaulted else ""
831
+ print(f" [json] subset_sum: {len(nums)} numbers, "
832
+ f"target={target}{note}")
833
+ r = solve_subset_sum(nums, target)
834
+ if verbose:
835
+ _report_subset_sum(r)
836
+ return r
837
+
838
+ if kind == "tsp":
839
+ pts = _validate_tsp_schema(schema)
840
+ if verbose:
841
+ print(f" [json] tsp: {len(pts)} points")
842
+ r = solve_tsp(pts, time_limit=60.0)
843
+ if verbose:
844
+ _report_tsp(r)
845
+ return r
846
+
847
+ raise ValueError(f"unknown schema type: {kind!r}")
848
+
849
+
850
+ # =============================================================================
851
+ # TEXT FRONT-END
852
+ # =============================================================================
853
+
854
+ def detect_kind(text):
855
+ t = text.lower()
856
+ if re.search(r"\b(max[\s-]?cut|maximum cut|maxcut)\b", t):
857
+ return "maxcut"
858
+ if re.search(r"\b(3[\s-]?sat|sat\b|satisfiab)", t):
859
+ return "sat"
860
+ if re.search(r"\b(subset[\s-]?sum|partition|sum to|target sum)\b", t):
861
+ return "subset_sum"
862
+ if re.search(r"\b(tsp|travell?ing salesman|shortest tour|best tour)\b", t):
863
+ return "tsp"
864
+ return None
865
+
866
+
867
+ def _report_maxcut(r):
868
+ print(f" method: {r['method']} ({r['seconds']:.2f}s)")
869
+ print(f" status: {r['status']}")
870
+ print(f" best cut value: {r['value']}")
871
+ print(f" partition S: {r['partition']['S']}")
872
+ print(f" partition T: {r['partition']['T']}")
873
+ print(f" verify: {'OK' if r['verify'] else 'FAIL'}")
874
+
875
+
876
+ def _report_sat(r):
877
+ print(f" method: {r['method']} ({r['seconds']:.2f}s)")
878
+ print(f" status: {r['status']}")
879
+ if r["satisfiable"]:
880
+ true_vars = [v for v in r["model"] if r["model"][v]]
881
+ print(f" model true vars: {true_vars}")
882
+ print(f" verify: {'OK' if r['verify'] else 'FAIL'}")
883
+
884
+
885
+ def _report_subset_sum(r):
886
+ print(f" method: {r['method']} ({r['seconds']:.2f}s)")
887
+ print(f" status: {r['status']}")
888
+ if r["found"]:
889
+ print(f" subset: {r['subset']}")
890
+ print(f" sum: {sum(r['subset'])}")
891
+ print(f" verify: {'OK' if r['verify'] else 'FAIL'}")
892
+
893
+
894
+ def _report_tsp(r):
895
+ print(f" method: {r['method']} ({r['seconds']:.2f}s)")
896
+ print(f" status: {r['status']}")
897
+ if r["tour"] is not None:
898
+ print(f" tour: {r['tour']}")
899
+ print(f" length: {r['length']:.4f}")
900
+ print(f" verify: {'OK' if r['verify'] else 'FAIL'}")
901
+
902
+
903
+ def dispatch(text, verbose=True):
904
+ kind = detect_kind(text)
905
+ if kind is None:
906
+ if verbose:
907
+ print(" Could not identify problem type.")
908
+ print(" Supported keywords:")
909
+ print(" 'max cut' / 'maxcut' -> MAX-CUT")
910
+ print(" 'sat' / '3-sat' -> Boolean satisfiability")
911
+ print(" 'subset sum' / 'target' -> SUBSET-SUM")
912
+ print(" 'tsp' / 'tour' -> Traveling salesman")
913
+ return None
914
+
915
+ if verbose:
916
+ print(f" detected: {kind.upper()}")
917
+
918
+ if kind == "maxcut":
919
+ try:
920
+ W, names = parse_maxcut(text)
921
+ except ParseError as e:
922
+ if verbose:
923
+ print(f" parse error: {e}")
924
+ return None
925
+ if verbose:
926
+ n_edges = sum(1 for i in range(len(W))
927
+ for j in range(i + 1, len(W)) if W[i][j])
928
+ print(f" parsed: {len(names)} vertices, {n_edges} edges")
929
+ r = solve_maxcut(W, names, time_limit=30.0)
930
+ if verbose:
931
+ _report_maxcut(r)
932
+ return r
933
+
934
+ if kind == "sat":
935
+ try:
936
+ clauses, n_vars, name_to_int = parse_sat(text)
937
+ except ParseError as e:
938
+ if verbose:
939
+ print(f" parse error: {e}")
940
+ return None
941
+ if verbose:
942
+ print(f" parsed: {n_vars} variables, {len(clauses)} clauses")
943
+ r = solve_sat(clauses, n_vars, time_limit=30.0)
944
+ if r["satisfiable"] and r["model"] is not None:
945
+ inv = {v: k for k, v in name_to_int.items()}
946
+ r["model"] = {inv[v]: r["model"][v] for v in r["model"]}
947
+ if verbose:
948
+ _report_sat(r)
949
+ return r
950
+
951
+ if kind == "subset_sum":
952
+ try:
953
+ nums, target, defaulted = parse_subset_sum(text)
954
+ except ParseError as e:
955
+ if verbose:
956
+ print(f" parse error: {e}")
957
+ return None
958
+ if verbose:
959
+ note = " (defaulted to half-total)" if defaulted else ""
960
+ print(f" parsed: {len(nums)} numbers, target = {target}{note}")
961
+ r = solve_subset_sum(nums, target)
962
+ if verbose:
963
+ _report_subset_sum(r)
964
+ return r
965
+
966
+ if kind == "tsp":
967
+ try:
968
+ pts = parse_tsp(text)
969
+ except ParseError as e:
970
+ if verbose:
971
+ print(f" parse error: {e}")
972
+ return None
973
+ if verbose:
974
+ print(f" parsed: {len(pts)} points")
975
+ r = solve_tsp(pts, time_limit=60.0)
976
+ if verbose:
977
+ _report_tsp(r)
978
+ return r
979
+
980
+ return None
981
+
982
+
983
+ # =============================================================================
984
+ # BENCH INSTANCES
985
+ # =============================================================================
986
+
987
+ def pigeonhole_clauses(m, n):
988
+ """PHP(m, n): m pigeons, n holes. UNSAT iff m > n."""
989
+ def var(i, j):
990
+ return (i - 1) * n + j
991
+ clauses = []
992
+ for i in range(1, m + 1):
993
+ clauses.append([var(i, j) for j in range(1, n + 1)])
994
+ for j in range(1, n + 1):
995
+ for i1 in range(1, m + 1):
996
+ for i2 in range(i1 + 1, m + 1):
997
+ clauses.append([-var(i1, j), -var(i2, j)])
998
+ return clauses, m * n
999
+
1000
+
1001
+ def bench_maxcut():
1002
+ out = []
1003
+
1004
+ for n in [4, 5, 6, 7, 8]:
1005
+ names = [f"v{i}" for i in range(n)]
1006
+ edges = [[names[i], names[j], 1]
1007
+ for i in range(n) for j in range(i + 1, n)]
1008
+ out.append((f"K_{n} complete, unit weights",
1009
+ {"type": "maxcut", "edges": edges},
1010
+ (n * n) // 4,
1011
+ "analytic: floor(n^2/4)"))
1012
+
1013
+ for n in [4, 6, 8, 10]:
1014
+ names = [f"v{i}" for i in range(n)]
1015
+ edges = [[names[i], names[(i + 1) % n], 1] for i in range(n)]
1016
+ out.append((f"C_{n} cycle, unit weights",
1017
+ {"type": "maxcut", "edges": edges},
1018
+ n, "analytic: n for even cycle"))
1019
+
1020
+ for n in [5, 7, 9]:
1021
+ names = [f"v{i}" for i in range(n)]
1022
+ edges = [[names[i], names[(i + 1) % n], 1] for i in range(n)]
1023
+ out.append((f"C_{n} cycle, unit weights",
1024
+ {"type": "maxcut", "edges": edges},
1025
+ n - 1, "analytic: n-1 for odd cycle"))
1026
+
1027
+ for n in [5, 6, 7, 8]:
1028
+ names = [f"v{i}" for i in range(n)]
1029
+ edges = [[names[i], names[i + 1], 1] for i in range(n - 1)]
1030
+ out.append((f"P_{n} path, unit weights",
1031
+ {"type": "maxcut", "edges": edges},
1032
+ n - 1, "analytic: n-1"))
1033
+
1034
+ for m, n in [(3, 3), (4, 4), (3, 5)]:
1035
+ left = [f"L{i}" for i in range(m)]
1036
+ right = [f"R{j}" for j in range(n)]
1037
+ edges = [[li, rj, 1] for li in left for rj in right]
1038
+ out.append((f"K_{{{m},{n}}} complete bipartite",
1039
+ {"type": "maxcut", "edges": edges},
1040
+ m * n, "analytic: all edges cuttable"))
1041
+
1042
+ return out
1043
+
1044
+
1045
+ def bench_sat():
1046
+ out = []
1047
+
1048
+ out.append(("trivial unit clause (x1)",
1049
+ {"type": "sat", "n_vars": 1, "clauses": [[1]]},
1050
+ True, "x1=True"))
1051
+
1052
+ out.append(("(x1 | x2) & -x1",
1053
+ {"type": "sat", "n_vars": 2, "clauses": [[1, 2], [-1]]},
1054
+ True, "x1=False, x2=True"))
1055
+
1056
+ out.append(("(x1) & (-x1)",
1057
+ {"type": "sat", "n_vars": 1, "clauses": [[1], [-1]]},
1058
+ False, "contradiction"))
1059
+
1060
+ clauses, n_vars = pigeonhole_clauses(3, 2)
1061
+ out.append(("PHP(3,2) pigeonhole",
1062
+ {"type": "sat", "n_vars": n_vars, "clauses": clauses},
1063
+ False, "pigeonhole: 3 pigeons, 2 holes"))
1064
+
1065
+ clauses, n_vars = pigeonhole_clauses(4, 3)
1066
+ out.append(("PHP(4,3) pigeonhole",
1067
+ {"type": "sat", "n_vars": n_vars, "clauses": clauses},
1068
+ False, "pigeonhole: 4 pigeons, 3 holes"))
1069
+
1070
+ clauses = [[1, 2, 3], [-1, 2, 4], [-2, -3, 4]]
1071
+ out.append(("3-SAT with known model (4 vars)",
1072
+ {"type": "sat", "n_vars": 4, "clauses": clauses},
1073
+ True, "model 1=T 2=T 3=F 4=T"))
1074
+
1075
+ return out
1076
+
1077
+
1078
+ def bench_subset_sum():
1079
+ """Instances with subset-sum answers provable by hand."""
1080
+ out = []
1081
+
1082
+ # Powers of 2: unique binary encoding for every target in [0, 127]
1083
+ nums = [1, 2, 4, 8, 16, 32, 64]
1084
+ out.append(("powers of 2, target=100",
1085
+ {"type": "subset_sum", "numbers": nums, "target": 100},
1086
+ True, "100 = 64 + 32 + 4"))
1087
+ out.append(("powers of 2, target=127",
1088
+ {"type": "subset_sum", "numbers": nums, "target": 127},
1089
+ True, "127 = 1+2+4+8+16+32+64"))
1090
+ out.append(("powers of 2, target=0",
1091
+ {"type": "subset_sum", "numbers": nums, "target": 0},
1092
+ True, "empty subset"))
1093
+ out.append(("powers of 2, target=200",
1094
+ {"type": "subset_sum", "numbers": nums, "target": 200},
1095
+ False, "200 > total(127)"))
1096
+
1097
+ # {1,...,10} is subset-sum complete: every integer in [0,55] achievable.
1098
+ # Only targets outside that range are provably UNSAT.
1099
+ nums = list(range(1, 11)) # total = 55
1100
+ out.append(("1..10, target=28",
1101
+ {"type": "subset_sum", "numbers": nums, "target": 28},
1102
+ True, "10 + 9 + 8 + 1 = 28"))
1103
+ out.append(("1..10, target=55",
1104
+ {"type": "subset_sum", "numbers": nums, "target": 55},
1105
+ True, "the entire set"))
1106
+ out.append(("1..10, target=60",
1107
+ {"type": "subset_sum", "numbers": nums, "target": 60},
1108
+ False, "60 > total(55)"))
1109
+ out.append(("1..10, target=-1",
1110
+ {"type": "subset_sum", "numbers": nums, "target": -1},
1111
+ False, "all numbers positive"))
1112
+
1113
+ return out
1114
+
1115
+ def bench_tsp():
1116
+ out = []
1117
+
1118
+ for a, b in [(10, 10), (10, 20), (5, 12)]:
1119
+ pts = [[0, 0], [a, 0], [a, b], [0, b]]
1120
+ out.append((f"rectangle {a}x{b} corners",
1121
+ {"type": "tsp", "points": pts},
1122
+ 2.0 * (a + b),
1123
+ f"perimeter 2*({a}+{b})"))
1124
+
1125
+ for n in [4, 5, 6]:
1126
+ pts = [[i * 10, 0] for i in range(n)]
1127
+ out.append((f"{n} collinear, spacing 10",
1128
+ {"type": "tsp", "points": pts},
1129
+ 2.0 * 10 * (n - 1),
1130
+ "2*(max-min) for collinear"))
1131
+
1132
+ return out
1133
+
1134
+
1135
+ # =============================================================================
1136
+ # BENCH MODE
1137
+ # =============================================================================
1138
+
1139
+ def run_bench(verbose=True):
1140
+ print()
1141
+ print("=" * 78)
1142
+ print("BENCH MODE -- instances with analytically-known optima")
1143
+ print("=" * 78)
1144
+
1145
+ total = 0
1146
+ passed = 0
1147
+
1148
+ print()
1149
+ print(" MAX-CUT:")
1150
+ for name, schema, expected, source in bench_maxcut():
1151
+ r = dispatch_from_json(schema, verbose=False)
1152
+ got = r["value"]
1153
+ ok = (got == expected)
1154
+ total += 1
1155
+ if ok:
1156
+ passed += 1
1157
+ if verbose:
1158
+ tag = "PASS" if ok else "FAIL"
1159
+ print(f" [{tag}] {name:<36} solver={got:>5} "
1160
+ f"expected={expected:>5} ({source})")
1161
+
1162
+ print()
1163
+ print(" SAT:")
1164
+ for name, schema, expected, source in bench_sat():
1165
+ r = dispatch_from_json(schema, verbose=False)
1166
+ got = r["satisfiable"]
1167
+ ok = (got == expected)
1168
+ total += 1
1169
+ if ok:
1170
+ passed += 1
1171
+ if verbose:
1172
+ tag = "PASS" if ok else "FAIL"
1173
+ print(f" [{tag}] {name:<36} solver={str(got):<5} "
1174
+ f"expected={str(expected):<5} ({source})")
1175
+
1176
+ print()
1177
+ print(" SUBSET-SUM:")
1178
+ for name, schema, expected, source in bench_subset_sum():
1179
+ r = dispatch_from_json(schema, verbose=False)
1180
+ got = r["found"]
1181
+ ok = (got == expected)
1182
+ total += 1
1183
+ if ok:
1184
+ passed += 1
1185
+ if verbose:
1186
+ tag = "PASS" if ok else "FAIL"
1187
+ print(f" [{tag}] {name:<36} solver={str(got):<5} "
1188
+ f"expected={str(expected):<5} ({source})")
1189
+
1190
+ print()
1191
+ print(" TSP:")
1192
+ for name, schema, expected, source in bench_tsp():
1193
+ r = dispatch_from_json(schema, verbose=False)
1194
+ got = r["length"]
1195
+ ok = (got is not None and abs(got - expected) < 1e-6)
1196
+ total += 1
1197
+ if ok:
1198
+ passed += 1
1199
+ if verbose:
1200
+ tag = "PASS" if ok else "FAIL"
1201
+ gs = f"{got:.4f}" if got is not None else "None"
1202
+ print(f" [{tag}] {name:<36} solver={gs:>9} "
1203
+ f"expected={expected:>9.4f} ({source})")
1204
+
1205
+ print()
1206
+ print(f" BENCH TOTAL: passed {passed}/{total}")
1207
+ print("=" * 78)
1208
+ return passed == total
1209
+
1210
+
1211
+ # =============================================================================
1212
+ # CROSS-VERIFY MODE
1213
+ # =============================================================================
1214
+
1215
+ def run_cross(sizes=None, seed=0, dfs_time_limit=60.0, bb_time_limit=60.0,
1216
+ verbose=True):
1217
+ """Cross-verify MAX-CUT B&B against independent exhaustive DFS."""
1218
+ if sizes is None:
1219
+ sizes = [14, 16, 18, 20, 22]
1220
+
1221
+ print()
1222
+ print("=" * 78)
1223
+ print("CROSS-VERIFY -- MAX-CUT: B&B vs independent exhaustive DFS")
1224
+ print("=" * 78)
1225
+ print(f" B&B time limit per instance : {bb_time_limit:.0f}s")
1226
+ print(f" DFS time limit per instance : {dfs_time_limit:.0f}s")
1227
+ print()
1228
+
1229
+ rng = random.Random(seed)
1230
+ results = []
1231
+
1232
+ print(f" {'n':>3} {'edges':>6} {'B&B':>8} {'B&B(s)':>8} "
1233
+ f"{'DFS':>8} {'DFS(s)':>8} {'verdict':>10}")
1234
+ print(" " + "-" * 62)
1235
+
1236
+ for n in sizes:
1237
+ schema = _rand_maxcut(n, rng.randint(0, 10**9))
1238
+ W, _ = _validate_maxcut_schema(schema)
1239
+ edges = sum(1 for i in range(n) for j in range(i + 1, n) if W[i][j])
1240
+
1241
+ # B&B
1242
+ t0 = time.perf_counter()
1243
+ warm, warm_side = warm_start_maxcut(W, restarts=64, seed=0)
1244
+ bb_res = maxcut_bb(W, bb_time_limit, warm, warm_side)
1245
+ bb_time = time.perf_counter() - t0
1246
+ bb_val = bb_res["best"]
1247
+ bb_aborted = bb_res["aborted"]
1248
+
1249
+ # DFS
1250
+ t0 = time.perf_counter()
1251
+ dfs_val, _, dfs_aborted = maxcut_dfs_exact(W, time_limit=dfs_time_limit)
1252
+ dfs_time = time.perf_counter() - t0
1253
+
1254
+ if dfs_aborted and bb_aborted:
1255
+ verdict = "both to"
1256
+ agree = None
1257
+ elif dfs_aborted:
1258
+ verdict = "DFS to"
1259
+ agree = None
1260
+ elif bb_aborted:
1261
+ verdict = "B&B to"
1262
+ agree = None
1263
+ elif bb_val == dfs_val:
1264
+ verdict = "AGREE"
1265
+ agree = True
1266
+ else:
1267
+ verdict = "DISAGREE"
1268
+ agree = False
1269
+
1270
+ dfs_str = f"{dfs_val}" if not dfs_aborted else ">t"
1271
+ bb_str = f"{bb_val}"
1272
+ print(f" {n:>3} {edges:>6} {bb_str:>8} {bb_time:>8.2f} "
1273
+ f"{dfs_str:>8} {dfs_time:>8.2f} {verdict:>10}")
1274
+
1275
+ results.append((n, bb_val, dfs_val, bb_aborted, dfs_aborted, agree))
1276
+
1277
+ n_agree = sum(1 for r in results if r[5] is True)
1278
+ n_disagree = sum(1 for r in results if r[5] is False)
1279
+ n_timeout = sum(1 for r in results if r[5] is None)
1280
+
1281
+ print()
1282
+ print(f" AGREEMENT: {n_agree} DISAGREEMENT: {n_disagree} "
1283
+ f"TIMEOUT-ON-ONE-SIDE: {n_timeout}")
1284
+ print("=" * 78)
1285
+ return n_disagree == 0
1286
+
1287
+
1288
+ # =============================================================================
1289
+ # STRESS TEST HARNESS
1290
+ # =============================================================================
1291
+
1292
+ def _rand_maxcut(n, seed, wmax=10, density=1.0):
1293
+ rng = random.Random(seed)
1294
+ names = [f"v{i}" for i in range(n)]
1295
+ edges = []
1296
+ for i in range(n):
1297
+ for j in range(i + 1, n):
1298
+ if rng.random() < density:
1299
+ edges.append([names[i], names[j], rng.randint(1, wmax)])
1300
+ return {"type": "maxcut", "edges": edges}
1301
+
1302
+
1303
+ def _rand_sat(n, ratio, seed):
1304
+ rng = random.Random(seed)
1305
+ m = max(1, int(round(n * ratio)))
1306
+ clauses = []
1307
+ for _ in range(m):
1308
+ vs = rng.sample(range(1, n + 1), min(3, n))
1309
+ clauses.append([v if rng.random() < 0.5 else -v for v in vs])
1310
+ return {"type": "sat", "n_vars": n, "clauses": clauses}
1311
+
1312
+
1313
+ def _rand_subset_sum(n, maxval, seed):
1314
+ rng = random.Random(seed)
1315
+ nums = [rng.randint(1, maxval) for _ in range(n)]
1316
+ return {"type": "subset_sum", "numbers": nums, "target": sum(nums) // 2}
1317
+
1318
+
1319
+ def _rand_tsp(n, seed, span=100.0):
1320
+ rng = random.Random(seed)
1321
+ pts = [[rng.uniform(0, span), rng.uniform(0, span)] for _ in range(n)]
1322
+ return {"type": "tsp", "points": pts}
1323
+
1324
+
1325
+ def schema_to_text(schema):
1326
+ kind = schema["type"]
1327
+ if kind == "maxcut":
1328
+ parts = []
1329
+ for e in schema["edges"]:
1330
+ if len(e) == 3:
1331
+ parts.append(f"{e[0]}-{e[1]}:{e[2]}")
1332
+ else:
1333
+ parts.append(f"{e[0]}-{e[1]}")
1334
+ return "max cut on " + ", ".join(parts)
1335
+ if kind == "sat":
1336
+ clauses = ["(" + " or ".join(str(x) for x in c) + ")"
1337
+ for c in schema["clauses"]]
1338
+ return "sat " + " and ".join(clauses)
1339
+ if kind == "subset_sum":
1340
+ nums = ", ".join(str(x) for x in schema["numbers"])
1341
+ if "target" in schema and schema["target"] is not None:
1342
+ return f"subset sum [{nums}] target {schema['target']}"
1343
+ return f"subset sum [{nums}]"
1344
+ if kind == "tsp":
1345
+ pts = " ".join(f"({p[0]!r},{p[1]!r})" for p in schema["points"])
1346
+ return "tsp " + pts
1347
+ raise ValueError(f"unknown type: {kind!r}")
1348
+
1349
+
1350
+ def _schema_equivalent(a, b):
1351
+ if a["type"] != b["type"]:
1352
+ return False, "type mismatch"
1353
+ t = a["type"]
1354
+
1355
+ if t == "maxcut":
1356
+ def key(e):
1357
+ u, v = e[0], e[1]
1358
+ w = e[2] if len(e) == 3 else 1
1359
+ if str(u) > str(v):
1360
+ u, v = v, u
1361
+ return (str(u), str(v), w)
1362
+ return sorted(key(e) for e in a["edges"]) == \
1363
+ sorted(key(e) for e in b["edges"]), "edge set differs"
1364
+
1365
+ if t == "sat":
1366
+ def norm(c):
1367
+ return tuple(sorted(set(c)))
1368
+ ca = sorted(norm(c) for c in a["clauses"])
1369
+ cb = sorted(norm(c) for c in b["clauses"])
1370
+ na = a.get("n_vars") or max((abs(x) for c in a["clauses"] for x in c),
1371
+ default=0)
1372
+ nb = b.get("n_vars") or max((abs(x) for c in b["clauses"] for x in c),
1373
+ default=0)
1374
+ return ca == cb and na == nb, f"clauses or n_vars ({na} vs {nb})"
1375
+
1376
+ if t == "subset_sum":
1377
+ return a["numbers"] == b["numbers"] and a["target"] == b["target"], \
1378
+ "numbers or target differ"
1379
+
1380
+ if t == "tsp":
1381
+ pa = sorted((round(x, 12), round(y, 12)) for x, y in a["points"])
1382
+ pb = sorted((round(x, 12), round(y, 12)) for x, y in b["points"])
1383
+ return pa == pb, "point set differs"
1384
+
1385
+ return False, "unknown type"
1386
+
1387
+
1388
+ def stress_solvers(trials, seed, verbose):
1389
+ rng = random.Random(seed)
1390
+ report = {"passed": 0, "failed": 0, "notes": []}
1391
+
1392
+ for t in range(trials):
1393
+ n = rng.randint(4, 10)
1394
+ schema = _rand_maxcut(n, rng.randint(0, 10**9))
1395
+ W, names = _validate_maxcut_schema(schema)
1396
+ r = solve_maxcut(W, names, time_limit=5.0)
1397
+ bf, _ = brute_force_maxcut(W)
1398
+ if r["value"] != bf:
1399
+ report["failed"] += 1
1400
+ report["notes"].append(f"maxcut n={n}: {r['value']} vs {bf}")
1401
+ else:
1402
+ report["passed"] += 1
1403
+ if verbose:
1404
+ print(f" maxcut n={n:>2} solver={r['value']:>5} bf={bf:>5} "
1405
+ f"{'OK' if r['value']==bf else 'FAIL'}")
1406
+
1407
+ for t in range(trials):
1408
+ n = rng.randint(3, 9)
1409
+ ratio = rng.choice([3.0, 4.0, 4.26, 5.0])
1410
+ schema = _rand_sat(n, ratio, rng.randint(0, 10**9))
1411
+ clauses, n_vars = _validate_sat_schema(schema)
1412
+ r = solve_sat(clauses, n_vars, time_limit=5.0)
1413
+ bf_sat = False
1414
+ for mask in range(1 << n_vars):
1415
+ assign = {v + 1: bool(mask & (1 << v)) for v in range(n_vars)}
1416
+ if verify_sat(clauses, assign):
1417
+ bf_sat = True
1418
+ break
1419
+ if r["satisfiable"] != bf_sat:
1420
+ report["failed"] += 1
1421
+ report["notes"].append(
1422
+ f"sat n={n}: {r['satisfiable']} vs {bf_sat}")
1423
+ elif r["satisfiable"] and not r["verify"]:
1424
+ report["failed"] += 1
1425
+ report["notes"].append("sat: verify failed")
1426
+ else:
1427
+ report["passed"] += 1
1428
+ if verbose:
1429
+ print(f" sat n={n:>2} r={ratio:<5} "
1430
+ f"solver={r['satisfiable']} bf={bf_sat} "
1431
+ f"{'OK' if r['satisfiable']==bf_sat else 'FAIL'}")
1432
+
1433
+ for t in range(trials):
1434
+ n = rng.randint(4, 12)
1435
+ schema = _rand_subset_sum(n, 30, rng.randint(0, 10**9))
1436
+ nums, target, _ = _validate_subset_sum_schema(schema)
1437
+ r = solve_subset_sum(nums, target)
1438
+ bf_found = brute_force_subset_sum(nums, target) is not None
1439
+ if r["found"] != bf_found:
1440
+ report["failed"] += 1
1441
+ report["notes"].append(f"ssum n={n}: {r['found']} vs {bf_found}")
1442
+ elif r["found"] and not r["verify"]:
1443
+ report["failed"] += 1
1444
+ report["notes"].append("ssum: verify failed")
1445
+ else:
1446
+ report["passed"] += 1
1447
+ if verbose:
1448
+ print(f" ssum n={n:>2} solver={r['found']} "
1449
+ f"bf={bf_found} "
1450
+ f"{'OK' if r['found']==bf_found else 'FAIL'}")
1451
+
1452
+ for t in range(trials):
1453
+ n = rng.randint(4, 7)
1454
+ schema = _rand_tsp(n, rng.randint(0, 10**9))
1455
+ pts = _validate_tsp_schema(schema)
1456
+ r = solve_tsp(pts, time_limit=5.0)
1457
+ bf_cost, _ = brute_force_tsp(pts)
1458
+ if abs(r["length"] - bf_cost) > 1e-6:
1459
+ report["failed"] += 1
1460
+ report["notes"].append(
1461
+ f"tsp n={n}: {r['length']:.6f} vs {bf_cost:.6f}")
1462
+ else:
1463
+ report["passed"] += 1
1464
+ if verbose:
1465
+ print(f" tsp n={n:>2} solver={r['length']:.6f} "
1466
+ f"bf={bf_cost:.6f} "
1467
+ f"{'OK' if abs(r['length']-bf_cost)<1e-6 else 'FAIL'}")
1468
+
1469
+ return report
1470
+
1471
+
1472
+ def stress_parsers(trials, seed, verbose):
1473
+ rng = random.Random(seed)
1474
+ report = {"passed": 0, "failed": 0, "notes": []}
1475
+
1476
+ for t in range(trials):
1477
+ kind = rng.choice(["maxcut", "sat", "subset_sum", "tsp"])
1478
+ if kind == "maxcut":
1479
+ schema = _rand_maxcut(rng.randint(3, 8), rng.randint(0, 10**9))
1480
+ elif kind == "sat":
1481
+ schema = _rand_sat(rng.randint(3, 8),
1482
+ rng.choice([3.0, 4.0, 4.26]),
1483
+ rng.randint(0, 10**9))
1484
+ elif kind == "subset_sum":
1485
+ schema = _rand_subset_sum(rng.randint(3, 10), 50,
1486
+ rng.randint(0, 10**9))
1487
+ else:
1488
+ schema = _rand_tsp(rng.randint(3, 7), rng.randint(0, 10**9))
1489
+
1490
+ text = schema_to_text(schema)
1491
+ parsed = None
1492
+ try:
1493
+ if kind == "maxcut":
1494
+ W, names = parse_maxcut(text)
1495
+ parsed = {"type": "maxcut", "edges": []}
1496
+ for i in range(len(W)):
1497
+ for j in range(i + 1, len(W)):
1498
+ if W[i][j]:
1499
+ parsed["edges"].append([names[i], names[j],
1500
+ W[i][j]])
1501
+ elif kind == "sat":
1502
+ clauses, n_vars, _ = parse_sat(text)
1503
+ parsed = {"type": "sat", "n_vars": n_vars,
1504
+ "clauses": [list(c) for c in clauses]}
1505
+ elif kind == "subset_sum":
1506
+ nums, target, _ = parse_subset_sum(text)
1507
+ parsed = {"type": "subset_sum", "numbers": nums,
1508
+ "target": target}
1509
+ else:
1510
+ pts = parse_tsp(text)
1511
+ parsed = {"type": "tsp",
1512
+ "points": [[x, y] for x, y in pts]}
1513
+ ok, msg = _schema_equivalent(schema, parsed)
1514
+ except ParseError as e:
1515
+ ok, msg = False, f"parse error: {e}"
1516
+
1517
+ if ok:
1518
+ report["passed"] += 1
1519
+ else:
1520
+ report["failed"] += 1
1521
+ report["notes"].append(
1522
+ f"{kind} round-trip: {msg}\n"
1523
+ f" schema: {schema}\n"
1524
+ f" text: {text}\n"
1525
+ f" parsed: {parsed}"
1526
+ )
1527
+ if verbose:
1528
+ print(f" {kind:<10} {'OK' if ok else 'FAIL'} "
1529
+ f"{'' if ok else msg}")
1530
+
1531
+ return report
1532
+
1533
+
1534
+ def stress_json(trials, seed, verbose):
1535
+ rng = random.Random(seed)
1536
+ report = {"passed": 0, "failed": 0, "notes": []}
1537
+
1538
+ for t in range(trials):
1539
+ kind = rng.choice(["maxcut", "sat", "subset_sum", "tsp"])
1540
+ if kind == "maxcut":
1541
+ schema = _rand_maxcut(rng.randint(3, 8), rng.randint(0, 10**9))
1542
+ elif kind == "sat":
1543
+ schema = _rand_sat(rng.randint(3, 8), 4.0,
1544
+ rng.randint(0, 10**9))
1545
+ elif kind == "subset_sum":
1546
+ schema = _rand_subset_sum(rng.randint(3, 10), 50,
1547
+ rng.randint(0, 10**9))
1548
+ else:
1549
+ schema = _rand_tsp(rng.randint(3, 7), rng.randint(0, 10**9))
1550
+
1551
+ text = schema_to_text(schema)
1552
+ r_json = dispatch_from_json(schema, verbose=False)
1553
+ r_text = dispatch(text, verbose=False)
1554
+
1555
+ if kind == "maxcut":
1556
+ agree = r_json["value"] == r_text["value"]
1557
+ msg = f"values {r_json['value']} vs {r_text['value']}"
1558
+ elif kind == "sat":
1559
+ agree = r_json["satisfiable"] == r_text["satisfiable"]
1560
+ msg = (f"satisfiable {r_json['satisfiable']} vs "
1561
+ f"{r_text['satisfiable']}")
1562
+ elif kind == "subset_sum":
1563
+ agree = r_json["found"] == r_text["found"]
1564
+ msg = f"found {r_json['found']} vs {r_text['found']}"
1565
+ else:
1566
+ jl, tl = r_json["length"], r_text["length"]
1567
+ if jl is None or tl is None:
1568
+ agree = jl == tl
1569
+ else:
1570
+ agree = abs(jl - tl) < 1e-6
1571
+ msg = f"length {jl} vs {tl}"
1572
+
1573
+ if agree and r_json.get("verify"):
1574
+ report["passed"] += 1
1575
+ else:
1576
+ report["failed"] += 1
1577
+ report["notes"].append(
1578
+ f"{kind} json-vs-text: {msg} "
1579
+ f"(verify={r_json.get('verify')})"
1580
+ )
1581
+ if verbose:
1582
+ print(f" {kind:<10} "
1583
+ f"{'OK' if (agree and r_json.get('verify')) else 'FAIL'} "
1584
+ f"{msg}")
1585
+
1586
+ return report
1587
+
1588
+
1589
+ def run_stress(trials=20, seed=0, verbose=True):
1590
+ print()
1591
+ print("=" * 78)
1592
+ print(f"STRESS TEST trials per class per suite = {trials}")
1593
+ print("=" * 78)
1594
+
1595
+ print()
1596
+ print(" Suite 1: solvers vs brute force")
1597
+ r1 = stress_solvers(trials, seed, verbose)
1598
+ print(f" -> passed {r1['passed']} failed {r1['failed']}")
1599
+ for n in r1["notes"][:10]:
1600
+ print(f" ! {n}")
1601
+
1602
+ print()
1603
+ print(" Suite 2: schema -> text -> schema round-trip")
1604
+ r2 = stress_parsers(trials * 2, seed + 1, verbose)
1605
+ print(f" -> passed {r2['passed']} failed {r2['failed']}")
1606
+ for n in r2["notes"][:5]:
1607
+ print(f" ! {n}")
1608
+
1609
+ print()
1610
+ print(" Suite 3: dispatch_from_json vs dispatch")
1611
+ r3 = stress_json(trials, seed + 2, verbose)
1612
+ print(f" -> passed {r3['passed']} failed {r3['failed']}")
1613
+ for n in r3["notes"][:5]:
1614
+ print(f" ! {n}")
1615
+
1616
+ total_pass = r1["passed"] + r2["passed"] + r3["passed"]
1617
+ total_fail = r1["failed"] + r2["failed"] + r3["failed"]
1618
+ print()
1619
+ print(f" TOTAL: passed {total_pass} failed {total_fail}")
1620
+ print("=" * 78)
1621
+ return total_fail == 0
1622
+
1623
+
1624
+ # =============================================================================
1625
+ # DEMOS
1626
+ # =============================================================================
1627
+
1628
+ DEMOS = [
1629
+ ("MAX-CUT, 6 vertices, weighted",
1630
+ "max cut on A-B:3, B-C:5, C-A:2, C-D:4, D-E:7, E-F:1, F-A:6, B-E:2"),
1631
+ ("3-SAT, mixed variable names",
1632
+ "sat (x1 or -x2 or x3) and (a or x2 or -x3) and (-a or b or x1)"),
1633
+ ("SUBSET-SUM, find exact half",
1634
+ "subset sum [3, 7, 1, 8, 2, 11, 5, 4] target 20"),
1635
+ ("TSP, 6 cities in the plane",
1636
+ "tsp (0,0) (10,0) (10,10) (0,10) (5,5) (2,8)"),
1637
+ ]
1638
+
1639
+
1640
+ def run_demo():
1641
+ print()
1642
+ print("=" * 78)
1643
+ print("DEMO -- one instance per problem class")
1644
+ print("=" * 78)
1645
+ for label, text in DEMOS:
1646
+ print()
1647
+ print(f"### {label}")
1648
+ print(f"input: {text}")
1649
+ print()
1650
+ dispatch(text, verbose=True)
1651
+ print()
1652
+ print("-" * 78)
1653
+
1654
+
1655
+ # =============================================================================
1656
+ # CLI
1657
+ # =============================================================================
1658
+
1659
+ BANNER = r"""
1660
+ +------------------------------------------------------------------------+
1661
+ | SOLVER-BACKED OPTIMIZATION ASSISTANT v3 |
1662
+ | |
1663
+ | NL front-end -> exact solvers -> verified answers. |
1664
+ | Bench mode with analytically-known optima. |
1665
+ | Cross-verifier: B&B vs independent DFS (MAX-CUT). |
1666
+ | |
1667
+ | Examples: |
1668
+ | max cut on A-B:3, B-C:5, C-A:2 |
1669
+ | sat (x1 or -x2 or x3) and (x2 or x3) and (-x1 or -x3) |
1670
+ | subset sum [3, 7, 1, 8, 2, 11] target 15 |
1671
+ | tsp (0,0) (10,0) (10,10) (0,10) (5,5) |
1672
+ | |
1673
+ | Commands: demo, stress [N], bench, cross [sizes], help, quit |
1674
+ +------------------------------------------------------------------------+
1675
+ """
1676
+
1677
+
1678
+ def _parse_sizes_arg(s):
1679
+ return [int(x.strip()) for x in s.split(",") if x.strip()]
1680
+
1681
+
1682
+ def interactive():
1683
+ print(BANNER)
1684
+ while True:
1685
+ try:
1686
+ line = input("> ").strip()
1687
+ except (EOFError, KeyboardInterrupt):
1688
+ print()
1689
+ break
1690
+ if not line:
1691
+ continue
1692
+ low = line.lower()
1693
+ if low in ("quit", "exit", "q"):
1694
+ break
1695
+ if low == "help":
1696
+ print(BANNER)
1697
+ continue
1698
+ if low == "demo":
1699
+ run_demo()
1700
+ continue
1701
+ if low == "bench":
1702
+ run_bench(verbose=True)
1703
+ continue
1704
+ if low == "cross" or low.startswith("cross "):
1705
+ m = re.match(r"cross\s+([\d,\s]+)$", low)
1706
+ sizes = _parse_sizes_arg(m.group(1)) if m else None
1707
+ run_cross(sizes=sizes, verbose=True)
1708
+ continue
1709
+ if low.startswith("stress"):
1710
+ m = re.match(r"stress\s*(\d+)?", low)
1711
+ n = int(m.group(1)) if m and m.group(1) else 20
1712
+ run_stress(trials=n, seed=0, verbose=False)
1713
+ continue
1714
+ print()
1715
+ try:
1716
+ dispatch(line, verbose=True)
1717
+ except Exception as e:
1718
+ print(f" error: {type(e).__name__}: {e}")
1719
+ print()
1720
+
1721
+
1722
+ def main():
1723
+ args = sys.argv[1:]
1724
+ if not args:
1725
+ interactive()
1726
+ return
1727
+
1728
+ if args[0] in ("-h", "--help"):
1729
+ print(__doc__)
1730
+ return
1731
+
1732
+ if args[0] == "--demo":
1733
+ run_demo()
1734
+ return
1735
+
1736
+ if args[0] == "--bench":
1737
+ ok = run_bench(verbose=True)
1738
+ sys.exit(0 if ok else 1)
1739
+
1740
+ if args[0] == "--cross":
1741
+ sizes = None
1742
+ i = 1
1743
+ while i < len(args):
1744
+ if args[i] == "--sizes" and i + 1 < len(args):
1745
+ sizes = _parse_sizes_arg(args[i + 1])
1746
+ i += 2
1747
+ else:
1748
+ i += 1
1749
+ ok = run_cross(sizes=sizes, verbose=True)
1750
+ sys.exit(0 if ok else 1)
1751
+
1752
+ if args[0] == "--stress":
1753
+ trials = 20
1754
+ verbose = False
1755
+ i = 1
1756
+ while i < len(args):
1757
+ if args[i] == "--trials" and i + 1 < len(args):
1758
+ trials = int(args[i + 1])
1759
+ i += 2
1760
+ elif args[i] == "-v":
1761
+ verbose = True
1762
+ i += 1
1763
+ else:
1764
+ i += 1
1765
+ ok = run_stress(trials=trials, seed=0, verbose=verbose)
1766
+ sys.exit(0 if ok else 1)
1767
+
1768
+ if args[0] == "--json":
1769
+ if len(args) < 2:
1770
+ print("usage: --json '<schema>'")
1771
+ sys.exit(1)
1772
+ try:
1773
+ schema = json.loads(args[1])
1774
+ except json.JSONDecodeError as e:
1775
+ print(f"invalid JSON: {e}")
1776
+ sys.exit(1)
1777
+ try:
1778
+ dispatch_from_json(schema, verbose=True)
1779
+ except ValueError as e:
1780
+ print(f"schema error: {e}")
1781
+ sys.exit(1)
1782
+ return
1783
+
1784
+ dispatch(" ".join(args), verbose=True)
1785
+
1786
+
1787
+ if __name__ == "__main__":
1788
+ main()