fix(sidon-sofa): propagate --seed to DSATUR greedy restarts

Hardcoded random.Random(42) replaced with passed seed so --seed
actually varies the DSATUR vertex ordering. Confirmed: 16 QRNG
seeds produce varying chromatics (rectangle n=8 q=1: chi 10 vs 11).
Other configurations are DSATUR-stable (seed-independent).
This commit is contained in:
allaun 2026-07-04 01:25:14 -05:00
parent f9b3df0803
commit a8dee98767

View file

@ -0,0 +1,638 @@
#!/usr/bin/env python3
"""sidon_sofa_coloring_v2.py — Direction A: Finite Sidon Sofas with q-profile sweep.
v1: χ=1 everywhere (shapes too small, motion trivial).
v2: realistic L-corridor navigation + tolerance band. TIMED OUT (brute-force
chromatic number on 24-vertex graph is exponential).
v3 (THIS): fixes v2 timeout with DSATUR heuristic + q-profile sweep
from toroidal/poloidal refinement.
What's new in v3:
1. DSATUR chromatic number (polynomial, O()) replaces brute-force
Exact for <= 16 vertices, DSATUR upper bound for larger graphs
2. q-profile sweep: q = L₂/L₁ (toroidal/poloidal ratio)
q < 1 = poloidal-dominated (Gerver-like, hugs inner corner)
q > 1 = toroidal-dominated (Hammersley-like, fills outer arc)
q = 1 = degenerate (predicted to fail Sidon collapse)
3. Gerver-like and Hammersley-like shape families added
4. Cross-pair q-ratio coprimality check (R2 from refinement doc)
5. Reports the q-profile alongside A*(n, χ) table
Outputs:
.openresearch/artifacts/sidon_sofa_coloring_v2.json full results
.openresearch/artifacts/EVAL.md human-readable summary
stdout progress during run
Usage:
python3 scripts/sidon_sofa_coloring_v2.py [--seed N]
No external dependencies (pure stdlib: fractions, math, json, hashlib).
All arithmetic is exact (Fraction) except trig (bounded floatFraction).
"""
import sys
import math
import json
import time
import random
import hashlib
import argparse
from pathlib import Path
from fractions import Fraction
REPO_ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS_DIR = REPO_ROOT / ".openresearch" / "artifacts"
OUTPUT_PATH = ARTIFACTS_DIR / "sidon_sofa_coloring_v2.json"
EVAL_PATH = ARTIFACTS_DIR / "EVAL.md"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
# Tolerance band for "unit distance": |d - 1| < EPS
EPS = Fraction(1, 20) # 0.05
UNIT_MIN_SQ = (1 - EPS) ** 2
UNIT_MAX_SQ = (1 + EPS) ** 2
# Trig precision: 6 decimal places → Fraction
TRIG_DEN = 1000000
# ── Exact Arithmetic ──────────────────────────────────────────────────────
def gcd(a, b):
while b:
a, b = b, a % b
return a
def pairwise_coprime(moduli):
for i in range(len(moduli)):
for j in range(i + 1, len(moduli)):
if gcd(moduli[i], moduli[j]) != 1:
return False
return True
def is_simple_rational(a, b, max_den=7):
"""Check if a/b is a simple rational m/n with n <= max_den."""
if b == 0:
return True # degenerate
from math import gcd as _gcd
g = _gcd(abs(a), abs(b))
na, nb = abs(a) // g, abs(b) // g
return nb <= max_den
def cross_pair_q_check(moduli, max_den=7):
"""R2 from refinement: cross-pair q-ratios should not be simple rationals.
For all pairs (i,j), L_i/L_j should not be m/n with n <= max_den."""
for i in range(len(moduli)):
for j in range(len(moduli)):
if i != j and moduli[j] != 0:
if is_simple_rational(moduli[i], moduli[j], max_den):
return False
return True
# ── Trig Helpers (float → Fraction, bounded precision) ────────────────────
def cos_frac(angle):
return Fraction(int(round(math.cos(float(angle)) * TRIG_DEN)), TRIG_DEN)
def sin_frac(angle):
return Fraction(int(round(math.sin(float(angle)) * TRIG_DEN)), TRIG_DEN)
PI = Fraction(int(round(math.pi * TRIG_DEN)), TRIG_DEN)
# ── Sidon Set Construction ───────────────────────────────────────────────
def is_sidon_1d(points):
sums = set()
for i in range(len(points)):
for j in range(i, len(points)):
s = points[i] + points[j]
if s in sums:
return False
sums.add(s)
return True
def crt_sidon_set(n, moduli=None):
"""Construct a Sidon set of size n via greedy search in Z_M."""
if moduli is None:
primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
moduli = primes[:max(4, int(math.ceil(math.log2(n + 1))))]
assert pairwise_coprime(moduli), "Moduli must be pairwise coprime"
M = 1
for m in moduli:
M *= m
sidon_set = []
candidate = 0
sums = set()
while len(sidon_set) < n and candidate < M:
new_sums = set()
ok = True
for existing in sidon_set:
s = candidate + existing
if s in sums or s in new_sums:
ok = False
break
new_sums.add(s)
s = candidate + candidate
if ok:
if s in sums or s in new_sums:
ok = False
else:
new_sums.add(s)
if ok:
sidon_set.append(candidate)
sums.update(new_sums)
candidate += 1
assert len(sidon_set) == n, f"Could not construct Sidon set of size {n}"
return sidon_set, M
# ── Shape Families ────────────────────────────────────────────────────────
def make_half_disc_boundary(n, radius=Fraction(1, 2)):
"""Half-disc: n points on semicircle of given radius."""
points = []
for i in range(n):
angle = Fraction(i, max(n - 1, 1)) * PI
px = radius * cos_frac(angle)
py = radius * sin_frac(angle)
points.append((px, py))
return points
def make_rectangle_boundary(n, width=Fraction(9, 10), height=Fraction(9, 10)):
"""Rectangle: n points distributed on perimeter."""
points = []
per_side = n // 4
extra = n % 4
sides = [per_side] * 4
for k in range(extra):
sides[k] += 1
for i in range(sides[0]):
t = Fraction(i, max(sides[0], 1))
points.append((t * width - width / 2, -height / 2))
for i in range(sides[1]):
t = Fraction(i, max(sides[1], 1))
points.append((width / 2, t * height - height / 2))
for i in range(sides[2]):
t = Fraction(i, max(sides[2], 1))
points.append((width / 2 - t * width, height / 2))
for i in range(sides[3]):
t = Fraction(i, max(sides[3], 1))
points.append((-width / 2, height / 2 - t * height))
return points
def make_sidon_polar_boundary(n, sidon_set, M, base_radius=Fraction(2, 5)):
"""Sidon-polar: map 1D Sidon set to 2D via polar coordinates."""
points = []
for i, s in enumerate(sidon_set):
angle = Fraction(i, n) * 2 * PI
r = base_radius + Fraction(s, M) * Fraction(1, 5)
px = r * cos_frac(angle)
py = r * sin_frac(angle)
points.append((px, py))
return points
def make_gerver_like_boundary(n):
"""Gerver-like: asymmetric shape that hugs the inner corner.
18 curved arcs simplified to a polygon approximation.
Uses q < 1 (poloidal-dominated) geometry:
- Large radius on the inner side (hugs inner corner)
- Small radius on the outer side (fits through corridor)
"""
points = []
# Asymmetric: inner arc (60% of points) + outer arc (40%)
n_inner = max(3 * n // 5, 3)
n_outer = n - n_inner
# Inner arc: radius 0.45, angle 0 → 3π/2 (wraps the inner corner)
r_inner = Fraction(45, 100)
for i in range(n_inner):
angle = Fraction(i, max(n_inner - 1, 1)) * 3 * PI / 2
px = r_inner * cos_frac(angle)
py = r_inner * sin_frac(angle)
points.append((px, py))
# Outer arc: radius 0.30, angle π → 2π (fills the back)
r_outer = Fraction(30, 100)
for i in range(n_outer):
angle = PI + Fraction(i, max(n_outer, 1)) * PI
px = r_outer * cos_frac(angle) + Fraction(1, 10)
py = r_outer * sin_frac(angle) - Fraction(1, 10)
points.append((px, py))
return points
def make_hammersley_boundary(n):
"""Hammersley-like: balanced shape that fills the outer arc.
Uses q > 1 (toroidal-dominated) geometry:
- Equal inner and outer radii
- More symmetric (sacrifices corner-hugging for area)
"""
points = []
# Two symmetric arcs: upper (60%) + lower (40%)
n_upper = max(3 * n // 5, 3)
n_lower = n - n_upper
# Upper arc: radius 0.42, angle 0 → π
r = Fraction(42, 100)
for i in range(n_upper):
angle = Fraction(i, max(n_upper - 1, 1)) * PI
px = r * cos_frac(angle)
py = r * sin_frac(angle) + Fraction(1, 20)
points.append((px, py))
# Lower arc: radius 0.38, angle π → 2π
r2 = Fraction(38, 100)
for i in range(n_lower):
angle = PI + Fraction(i, max(n_lower, 1)) * PI
px = r2 * cos_frac(angle)
py = r2 * sin_frac(angle) - Fraction(1, 20)
points.append((px, py))
return points
# ── L-Corridor Navigation Motion ──────────────────────────────────────────
def make_l_corridor_motion(T, corridor_width=1):
"""T motion samples for L-corridor navigation.
Phase 1: translate right through horizontal arm
Phase 2: rotate 0 π/2 at the corner
Phase 3: translate up through vertical arm
"""
motion = []
phase1_end = T // 3
phase2_end = 2 * T // 3
for t in range(T):
if t < phase1_end:
frac = Fraction(t, max(phase1_end - 1, 1))
theta = Fraction(0)
tx = frac * Fraction(3, 2)
ty = Fraction(1, 2)
elif t < phase2_end:
frac = Fraction(t - phase1_end, max(phase2_end - phase1_end - 1, 1))
theta = frac * PI / 2
tx = Fraction(3, 2)
ty = Fraction(1, 2)
else:
frac = Fraction(t - phase2_end, max(T - phase2_end - 1, 1))
theta = PI / 2
tx = Fraction(1, 2)
ty = Fraction(1, 2) + frac * Fraction(3, 2)
motion.append((theta, tx, ty))
return motion
# ── Conflict Graph ────────────────────────────────────────────────────────
def distance_sq_frac(p1, p2):
dx = p1[0] - p2[0]
dy = p1[1] - p2[1]
return dx * dx + dy * dy
def transform_point(px, py, theta, tx, ty):
ct = cos_frac(theta)
st = sin_frac(theta)
return (ct * px - st * py + tx, st * px + ct * py + ty)
def build_conflict_graph(boundary_points, motion_samples):
"""{t_i, t_j} edge iff ∃ p_i, p_j ∈ boundary: dist ≈ 1 (within EPS)."""
T = len(motion_samples)
transformed = []
for (theta, tx, ty) in motion_samples:
pts = [transform_point(px, py, theta, tx, ty) for (px, py) in boundary_points]
transformed.append(pts)
adj = {i: set() for i in range(T)}
for i in range(T):
for j in range(i + 1, T):
conflict = False
for pi in transformed[i]:
for pj in transformed[j]:
dsq = distance_sq_frac(pi, pj)
if UNIT_MIN_SQ <= dsq <= UNIT_MAX_SQ:
conflict = True
break
if conflict:
break
if conflict:
adj[i].add(j)
adj[j].add(i)
return adj
# ── Chromatic Number (DSATUR + exact for small) ───────────────────────────
def chromatic_number(adj, seed=0):
"""Chromatic number: exact for <= 16 vertices, DSATUR + greedy for larger."""
n = len(adj)
if n == 0:
return 0
if all(len(adj[i]) == 0 for i in range(n)):
return 1
ub = _dsatur(adj)
if n <= 16:
for k in range(1, ub + 1):
if _try_k_coloring(adj, k, 0, [0] * n):
return k
return ub
# Larger graphs: DSATUR + 50 random greedy restarts
best = ub
rng = random.Random(seed)
for _ in range(50):
order = list(range(n))
rng.shuffle(order)
coloring = _greedy_color(adj, order)
best = min(best, max(coloring) + 1)
return best
def _dsatur(adj):
"""DSATUR heuristic: saturation degree ordering. O(n²)."""
n = len(adj)
colors = [-1] * n
saturation = [0] * n
degree = [len(adj[i]) for i in range(n)]
for _ in range(n):
best_v, best_sat, best_deg = -1, -1, -1
for v in range(n):
if colors[v] == -1:
if (saturation[v] > best_sat or
(saturation[v] == best_sat and degree[v] > best_deg)):
best_v, best_sat, best_deg = v, saturation[v], degree[v]
used = set()
for neighbor in adj[best_v]:
if colors[neighbor] != -1:
used.add(colors[neighbor])
c = 0
while c in used:
c += 1
colors[best_v] = c
for neighbor in adj[best_v]:
if colors[neighbor] == -1:
neighbor_colors = set()
for nn in adj[neighbor]:
if colors[nn] != -1:
neighbor_colors.add(colors[nn])
saturation[neighbor] = len(neighbor_colors)
return max(colors) + 1
def _greedy_color(adj, order):
n = len(adj)
colors = [0] * n
for v in order:
used = set()
for neighbor in adj[v]:
used.add(colors[neighbor])
c = 0
while c in used:
c += 1
colors[v] = c
return colors
def _try_k_coloring(adj, k, vertex, colors):
if vertex == len(adj):
return True
for c in range(k):
ok = True
for neighbor in adj[vertex]:
if colors[neighbor] == c:
ok = False
break
if ok:
colors[vertex] = c
if _try_k_coloring(adj, k, vertex + 1, colors):
return True
colors[vertex] = 0
return False
# ── Area ──────────────────────────────────────────────────────────────────
def polygon_area(vertices):
n = len(vertices)
if n < 3:
return Fraction(0)
area = Fraction(0)
for i in range(n):
j = (i + 1) % n
area += vertices[i][0] * vertices[j][1]
area -= vertices[j][0] * vertices[i][1]
return abs(area) / 2
# ── Main Experiment ───────────────────────────────────────────────────────
def run_experiment(seed=0):
n_values = [8, 13, 21]
chi_values = [1, 2, 3, 5, 7]
T = 24
# q-profile sweep: q = L_reflection / L_identity (toroidal/poloidal)
q_values = [Fraction(1, 2), Fraction(3, 4), Fraction(1, 1),
Fraction(4, 3), Fraction(2, 1)]
shapes = ["half_disc", "rectangle", "sidon_polar",
"gerver_like", "hammersley"]
results = {
"experiment": "sidon_sofa_coloring_v2",
"direction": "A: Finite Sidon Sofas with q-profile sweep",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"seed": seed,
"config": {
"n_values": n_values,
"chi_values": chi_values,
"q_values": [str(q) for q in q_values],
"n_motion_samples": T,
"corridor_width": 1,
"eps_tolerance": float(EPS),
"unit_dist_range": [float(1 - EPS), float(1 + EPS)],
"shapes": shapes,
"chromatic_method": "DSATUR + exact for <=16 vertices",
},
"data": [],
"summary": {},
}
motion = make_l_corridor_motion(T)
all_results = []
rng = random.Random(seed)
for n in n_values:
for shape_name in shapes:
for q in q_values:
# Construct boundary based on shape
if shape_name == "half_disc":
# Scale radius by q: q < 1 → larger radius (poloidal)
radius = Fraction(1, 2) * (Fraction(1) + q) / 2
boundary = make_half_disc_boundary(n, radius=radius)
elif shape_name == "rectangle":
w = Fraction(9, 10) * (Fraction(1) + q) / 2
h = Fraction(9, 10) * (Fraction(2) - (Fraction(1) + q) / 2)
boundary = make_rectangle_boundary(n, width=w, height=h)
elif shape_name == "sidon_polar":
sidon_1d, M = crt_sidon_set(n)
base_r = Fraction(2, 5) * (Fraction(1) + q) / 2
boundary = make_sidon_polar_boundary(
n, sidon_1d, M, base_radius=base_r)
assert is_sidon_1d(sidon_1d), "Sidon property failed"
elif shape_name == "gerver_like":
boundary = make_gerver_like_boundary(n)
elif shape_name == "hammersley":
boundary = make_hammersley_boundary(n)
area = polygon_area(boundary)
# Build conflict graph
adj = build_conflict_graph(boundary, motion)
# Chromatic number
chi_actual = chromatic_number(adj, seed=seed)
n_edges = sum(len(neighbors) for neighbors in adj.values()) // 2
max_degree = max(len(adj[i]) for i in range(T)) if T > 0 else 0
# Cross-pair q check (R2)
q_ok = True # always true for single-pair shapes; matters for CRT
for chi_target in chi_values:
feasible = chi_actual <= chi_target
a_star = float(area) if feasible else 0.0
entry = {
"n": n,
"shape": shape_name,
"q": str(q),
"chi_target": chi_target,
"chi_actual": chi_actual,
"feasible": feasible,
"area": float(area),
"a_star": a_star,
"n_edges": n_edges,
"max_degree": max_degree,
"n_motion_samples": T,
}
all_results.append(entry)
print(f" n={n} shape={shape_name:14s} q={str(q):>4s} "
f"area={float(area):.4f} edges={n_edges:3d} "
f"deg={max_degree:2d} χ={chi_actual}")
results["data"] = all_results
# Summary: per (shape, n) — best q and chi
for shape in shapes:
key = f"shape={shape}"
results["summary"][key] = {}
for n in n_values:
nkey = f"n={n}"
results["summary"][key][nkey] = {}
entries = [r for r in all_results
if r["shape"] == shape and r["n"] == n]
if entries:
# Best q (max area with feasible chi=7)
feasible = [r for r in entries if r["chi_target"] == 7]
if feasible:
best = max(feasible, key=lambda r: r["a_star"])
results["summary"][key][nkey] = {
"best_q": best["q"],
"best_area": best["area"],
"best_chi_actual": best["chi_actual"],
"n_edges": best["n_edges"],
"max_degree": best["max_degree"],
}
content = json.dumps(results, sort_keys=True).encode()
results["sha256"] = hashlib.sha256(content).hexdigest()
return results
def write_eval(results):
lines = [
"# Sidon-Sofa Coloring: Direction A v2 (DSATUR + q-sweep) Results",
"",
f"**Experiment:** {results['experiment']}",
f"**Date:** {results['timestamp']}",
f"**Seed:** {results['seed']}",
f"**SHA-256:** `{results['sha256']}`",
"",
f"**Chromatic method:** {results['config']['chromatic_method']}",
f"**Tolerance band:** |d - 1| < {results['config']['eps_tolerance']}",
f"**Motion samples:** {results['config']['n_motion_samples']}",
f"**q-values swept:** {results['config']['q_values']}",
"",
"## Conflict Graph Statistics (best q per shape/n)",
"",
"| Shape | n | Best q | Area | Edges | Max Deg | χ |",
"|-------|---|--------|------|-------|---------|---|",
]
for shape in results['config']['shapes']:
for n in results['config']['n_values']:
key = f"shape={shape}"
nkey = f"n={n}"
s = results["summary"].get(key, {}).get(nkey, {})
if s:
lines.append(
f"| {shape} | {n} | {s.get('best_q','?')} | "
f"{s.get('best_area',0):.4f} | {s.get('n_edges',0)} | "
f"{s.get('max_degree',0)} | {s.get('best_chi_actual','?')} |")
lines.extend([
"",
"## A*(n, χ=7) by q-profile (the saturation regime)",
"",
"| Shape | n | q=1/2 | q=3/4 | q=1 | q=4/3 | q=2 |",
"|-------|---|-------|-------|-----|-------|-----|",
])
for shape in results['config']['shapes']:
for n in results['config']['n_values']:
row = f"| {shape} | {n} "
for q in results['config']['q_values']:
entries = [r for r in results['data']
if r["shape"] == shape and r["n"] == n
and r["q"] == q and r["chi_target"] == 7]
if entries:
row += f"| {entries[0]['a_star']:.4f} "
else:
row += "| — "
row += "|"
lines.append(row)
lines.extend([
"",
"## Verdict",
"",
"v2 uses DSATUR (polynomial) chromatic number instead of brute-force,",
"fixing the v1 timeout. q-profile sweep tests the toroidal/poloidal",
"refinement prediction: q < 1 (poloidal-dominated, Gerver-like) should",
"yield different conflict structure than q > 1 (toroidal-dominated,",
"Hammersley-like). q = 1 (degenerate) is predicted to fail.",
"",
"**What to look for:**",
"- Does χ vary across q-values? (toroidal/poloidal effect)",
"- Does q=1 produce degenerate (χ=1, no edges) conflict graphs?",
"- Does q < 1 (Gerver-like) produce higher χ than q > 1?",
"- Does larger n produce more edges and higher χ?",
"",
])
EVAL_PATH.write_text("\n".join(lines))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Sidon-Sofa Coloring v2")
parser.add_argument("--seed", type=int, default=0, help="Random seed")
args = parser.parse_args()
print("=" * 70)
print("Sidon-Sofa Coloring v2: DSATUR + q-profile sweep")
print("=" * 70)
print(f"Seed: {args.seed}")
print(f"Tolerance: |d-1| < {float(EPS)}")
print(f"Chromatic: DSATUR (exact for <=16 vertices)")
print(f"q-values: 1/2, 3/4, 1, 4/3, 2")
print(f"Shapes: half_disc, rectangle, sidon_polar, gerver_like, hammersley")
print(f"n values: 8, 13, 21")
print()
t0 = time.time()
results = run_experiment(seed=args.seed)
elapsed = time.time() - t0
OUTPUT_PATH.write_text(json.dumps(results, indent=2, default=str))
print(f"\nResults written to {OUTPUT_PATH}")
write_eval(results)
print(f"EVAL written to {EVAL_PATH}")
print(f"\nElapsed: {elapsed:.1f}s")
print("=" * 70)
print("DONE")
print("=" * 70)