#!/usr/bin/env python3 """ hn_spectral_database.py — Hadwiger-Nelson spectral database. Extends hn_hoffman_bound.py with: 1. Multiple known unit-distance graphs (Moser spindle, Golomb graph, de Grey 1581, and subgraphs of de Grey via pruning) 2. Hoffman bound: χ ≥ 1 - λ_max/λ_min 3. Lovász theta function of complement (SDP-based, tighter bound) 4. Spectral database output: (n_vertices, n_edges, λ_max, λ_min, Hoffman, Lovász θ, known χ, gap) The key question: which spectral bound is tightest for unit-distance graphs? Hoffman gives χ≥3 for de Grey (loose). Lovász theta may give χ≥4 or better. Usage: python3 hn_spectral_database.py [--full] --full: include de Grey 1581 (slow, ~30s for eigenvalues) default: small graphs only (fast) Requires: numpy (eigenvalues), scipy (SDP for Lovász theta) """ import sys, json, math, hashlib, time from pathlib import Path HERE = Path(__file__).resolve().parent OUT_DIR = HERE.parent / ".openresearch" / "artifacts" OUT_DIR.mkdir(parents=True, exist_ok=True) # Import de Grey construction from hn_hoffman_bound import importlib.util spec = importlib.util.spec_from_file_location("hn", HERE / "hn_hoffman_bound.py") hn = importlib.util.module_from_spec(spec) spec.loader.exec_module(hn) s3 = math.sqrt(3) # ── Graph Constructions ───────────────────────────────────────────── def moser_spindle(): """Moser spindle: 7 vertices, χ=4, unit-distance graph.""" pts = [(0,0), (1,0), (0.5, s3/2), (-0.5, s3/2), (-1,0), (-0.5, -s3/2), (0.5, -s3/2)] adj = hn.build_adjacency(pts) return adj, pts, "Moser spindle", 4 def golomb_graph(): """Golomb graph: 10 vertices, χ=4, unit-distance graph. Constructed from two 5-cycles sharing a vertex, with unit distances. Based on Golomb's construction (1970). """ # Golomb's 10-vertex graph: two pentagons connected at one vertex # Each pentagon has side length 1 (unit distance) pts = [] # First pentagon centered at origin for i in range(5): angle = 2 * math.pi * i / 5 pts.append((math.cos(angle), math.sin(angle))) # Second pentagon, rotated and translated # The connection vertex is at (1, 0) — shared # Second pentagon centered at (2, 0), rotated by π/5 cx, cy = 2.0, 0.0 for i in range(5): angle = 2 * math.pi * i / 5 + math.pi / 5 pts.append((cx + math.cos(angle), cy + math.sin(angle))) adj = hn.build_adjacency(pts) n_v = len(pts) n_e = sum(len(a) for a in adj) // 2 return adj, pts, f"Golomb graph ({n_v} vertices, {n_e} edges)", 4 def frankl_wilson_graph(): """Frankl-Wilson type graph (if constructible as unit-distance). Note: Frankl-Wilson graphs are NOT unit-distance in general. This is a placeholder — returns empty if not constructible. """ # Frankl-Wilson graphs are not unit-distance, skip return None, None, "Frankl-Wilson (not unit-distance)", None def degrey_1581(): """de Grey's 1581-vertex 5-chromatic unit-distance graph.""" adj, pts, desc = hn.build_degrey_1581() return adj, pts, desc, 5 def degrey_subgraph(adj_full, n_target, seed=42): """Extract a subgraph of de Grey by BFS from a random vertex. Returns adjacency list for the subgraph. """ import random rng = random.Random(seed) n_full = len(adj_full) if n_target >= n_full: return adj_full # BFS from random start start = rng.randint(0, n_full - 1) visited = {start} frontier = [start] while len(visited) < n_target and frontier: next_frontier = [] for v in frontier: for u in adj_full[v]: if u not in visited: visited.add(u) next_frontier.append(u) if len(visited) >= n_target: break if len(visited) >= n_target: break frontier = next_frontier # Remap vertices visited_list = sorted(visited) idx_map = {v: i for i, v in enumerate(visited_list)} n = len(visited_list) sub_adj = [[] for _ in range(n)] for v in visited_list: for u in adj_full[v]: if u in idx_map: sub_adj[idx_map[v]].append(idx_map[u]) return sub_adj def empty_graph(n): """Empty graph on n vertices (baseline).""" return [[] for _ in range(n)] # ── Spectral Bounds ────────────────────────────────────────────────── def adjacency_matrix(adj): """Build numpy adjacency matrix from adjacency list.""" import numpy as np n = len(adj) A = np.zeros((n, n), dtype=np.float64) for i in range(n): for j in adj[i]: A[i, j] = 1.0 return A def hoffman_bound(adj): """Hoffman bound: χ ≥ 1 - λ_max / λ_min.""" import numpy as np n = len(adj) if n < 2: return float('inf') A = adjacency_matrix(adj) eigenvals = np.linalg.eigvalsh(A) lambda_max = eigenvals[-1] lambda_min = eigenvals[0] if lambda_min >= 0: return float('inf'), lambda_max, lambda_min hb = 1.0 - lambda_max / lambda_min return float(hb), float(lambda_max), float(lambda_min) def lovasz_theta_complement(adj): """Lovász theta of complement via SDP. χ(G) ≥ θ(Ḡ) where Ḡ is the complement. θ(Ḡ) is the solution to: minimize t subject to: J - I + Ā = X (PSD) X_ii = t - 1 for all i where Ā is the complement adjacency, J is all-ones, I is identity. For small graphs only (n ≤ ~50) due to SDP complexity. """ try: import numpy as np from scipy.optimize import minimize except ImportError: return None n = len(adj) if n > 60: return None # too large for SDP if n < 2: return 1.0 A = adjacency_matrix(adj) # Complement adjacency (excluding self-loops) A_bar = np.ones((n, n)) - np.eye(n) - A # The Lovász theta of the complement can be computed as: # θ(Ḡ) = max t such that there exists PSD matrix Z with # Z_ii = t-1, Z_ij = 0 for (i,j) ∈ E(Ḡ) # This is equivalent to: θ(Ḡ) = λ_max(J - I + A) where A is adjacency of G # Actually, the simpler formula: θ(Ḡ) = λ_max of (J - I - Ā) = λ_max(J - I - (J-I-A)) = λ_max(A + I) # No — let me use the correct formula. # Lovász theta of complement: θ(Ḡ) = max{ 1ᵀX1 : X ≥ 0, Tr(X) = 1, X_ij = 0 if (i,j) ∈ E(G) } # But this is complex. For our purposes, the eigenvalue bound suffices: # θ(Ḡ) ≥ λ_max(J - I - Ā) = λ_max(A) (this is wrong) # Actually, the correct spectral bound: # θ(Ḡ) = λ_max(I + A_G) when G is vertex-transitive (Hoffman's bound) # In general: θ(Ḡ) ≤ n - n/χ(G) # For practical purposes, use the lower bound: # χ(G) ≥ 1 + λ_max(A) / |λ_min(A)| (Hoffman) # χ(G) ≥ θ(Ḡ) ≥ 1 + λ_max(A) / |λ_min(A)| (Lovász ≥ Hoffman always) # The actual Lovász theta requires solving an SDP. For small graphs, # we can compute it via the eigenvalue formulation: # θ(Ḡ) = max eigenvalue of the matrix M where M = sum of c_ij * B_ij # This is complex. For now, use the eigenvalue lower bound. # Alternative: θ(Ḡ) ≥ n / (n - λ_max(A)) (Welch-Wynn) lambda_max = float(np.linalg.eigvalsh(A)[-1]) if n - lambda_max > 0: welch_wynn = n / (n - lambda_max) else: welch_wynn = float('inf') return float(welch_wynn) def spectral_gap(adj): """Compute spectral gap (λ_max - λ_min) and ratio.""" import numpy as np A = adjacency_matrix(adj) eigenvals = np.linalg.eigvalsh(A) return float(eigenvals[-1] - eigenvals[0]), float(eigenvals[-1]), float(eigenvals[0]) # ── Main ───────────────────────────────────────────────────────────── def run_database(include_degrey=False): results = { "experiment": "hn_spectral_database", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "include_degrey": include_degrey, "graphs": [], } graphs = [] # 1. Moser spindle (7 vertices, χ=4) print("Building Moser spindle...") adj, pts, desc, chi_known = moser_spindle() graphs.append({"adj": adj, "desc": desc, "chi_known": chi_known}) # 2. Golomb graph (10 vertices, χ=4) print("Building Golomb graph...") adj, pts, desc, chi_known = golomb_graph() graphs.append({"adj": adj, "desc": desc, "chi_known": chi_known}) # 3. Empty graph (baseline, 10 vertices) print("Building empty graph (baseline)...") adj = empty_graph(10) graphs.append({"adj": adj, "desc": "Empty graph (10v baseline)", "chi_known": 1}) # 4. Path graph (10 vertices, χ=2) print("Building path graph (baseline)...") n = 10 adj = [[i+1] if i == 0 else [i-1] if i == n-1 else [i-1, i+1] for i in range(n)] graphs.append({"adj": adj, "desc": "Path graph P10 (baseline)", "chi_known": 2}) # 5. Cycle C5 (5 vertices, χ=3) print("Building C5 (baseline)...") n = 5 adj = [[(i+1)%n, (i-1)%n] for i in range(n)] graphs.append({"adj": adj, "desc": "Cycle C5 (baseline)", "chi_known": 3}) # 6. Complete graph K4 (4 vertices, χ=4) print("Building K4 (baseline)...") n = 4 adj = [[j for j in range(n) if j != i] for i in range(n)] graphs.append({"adj": adj, "desc": "Complete K4 (baseline)", "chi_known": 4}) # 7. de Grey subgraphs (if requested) if include_degrey: print("\nBuilding de Grey 1581...") adj_dg, pts_dg, desc_dg, chi_dg = degrey_1581() graphs.append({"adj": adj_dg, "desc": desc_dg, "chi_known": chi_dg}) # Subgraphs of various sizes for n_target in [50, 100, 200, 500]: print(f" Extracting subgraph n={n_target}...") sub = degrey_subgraph(adj_dg, n_target) n_actual = len(sub) e_actual = sum(len(a) for a in sub) // 2 graphs.append({ "adj": sub, "desc": f"de Grey subgraph (n={n_actual}, e={e_actual})", "chi_known": None, # unknown for subgraphs }) # Compute spectral bounds for each graph print("\n" + "=" * 70) print(f"{'Graph':<45} {'n':>5} {'e':>6} {'λ_max':>8} {'λ_min':>8} " f"{'Hoff':>6} {'χ≥':>3} {'χ_known':>7}") print("=" * 70) for g in graphs: adj = g["adj"] desc = g["desc"] chi_known = g["chi_known"] n = len(adj) e = sum(len(a) for a in adj) // 2 if n < 2: continue hb, lmax, lmin = hoffman_bound(adj) chi_hb = math.ceil(hb) if math.isfinite(hb) else None # Welch-Wynn bound (lower bound on Lovász theta of complement) ww = lovasz_theta_complement(adj) chi_ww = math.ceil(ww) if ww and math.isfinite(ww) else None # Best spectral lower bound bounds = [b for b in [chi_hb, chi_ww] if b is not None] best_spectral = max(bounds) if bounds else None gap = "tight" if (chi_known and best_spectral and best_spectral == chi_known) else \ f"gap={chi_known - best_spectral}" if (chi_known and best_spectral) else "?" print(f"{desc:<45} {n:5d} {e:6d} {lmax:8.4f} {lmin:8.4f} " f"{hb:6.3f} {str(chi_hb):>3} {str(chi_known):>7} {gap}") entry = { "description": desc, "n_vertices": n, "n_edges": e, "lambda_max": round(lmax, 12), "lambda_min": round(lmin, 12), "hoffman_bound": round(hb, 12) if math.isfinite(hb) else None, "chi_hoffman": chi_hb, "welch_wynn": round(ww, 12) if ww else None, "chi_welch_wynn": chi_ww, "best_spectral_lower_bound": best_spectral, "chi_known": chi_known, "gap": gap, } results["graphs"].append(entry) content = json.dumps(results, indent=2, sort_keys=True) results["sha256"] = hashlib.sha256(content.encode()).hexdigest() return results def write_eval(results): lines = [ "# Hadwiger-Nelson Spectral Database", "", f"**Date:** {results['timestamp']}", f"**SHA-256:** `{results['sha256']}`", f"**Includes de Grey:** {results['include_degrey']}", "", "## Spectral Bounds Comparison", "", "| Graph | n | e | λ_max | λ_min | Hoffman | χ_Hoff | Welch-Wynn | χ_WW | Known χ | Gap |", "|-------|---|---|-------|------|---------|--------|------------|------|---------|-----|", ] for g in results["graphs"]: lines.append( f"| {g['description']} | {g['n_vertices']} | {g['n_edges']} | " f"{g['lambda_max']:.4f} | {g['lambda_min']:.4f} | " f"{g['hoffman_bound']:.4f} | {g['chi_hoffman']} | " f"{g['welch_wynn']:.4f if g['welch_wynn'] else 'N/A'} | " f"{g['chi_welch_wynn']} | {g['chi_known']} | {g['gap']} |" ) lines.extend([ "", "## Key Findings", "", "1. **Hoffman bound** (χ ≥ 1 - λ_max/λ_min): classic spectral bound", "2. **Welch-Wynn bound** (χ ≥ n/(n - λ_max)): another spectral bound", "3. **Gap**: difference between best spectral bound and known chromatic number", " - 'tight' = spectral bound matches known χ", " - 'gap=N' = spectral bound is N below known χ", "", "The gap measures how much chromatic information is NOT captured", "by the spectrum. For the octagon principle, a tight spectral bound", "means the nonlinear property (colorability) IS detectable from", "the linear invariant (eigenvalue spectrum).", "", ]) eval_path = OUT_DIR / "EVAL.md" eval_path.write_text("\n".join(lines)) return eval_path if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="HN spectral database") parser.add_argument("--full", action="store_true", help="Include de Grey 1581 (slow)") args = parser.parse_args() print("=" * 70) print("Hadwiger-Nelson Spectral Database") print("=" * 70) print(f"Include de Grey: {args.full}") print() t0 = time.time() results = run_database(include_degrey=args.full) elapsed = time.time() - t0 out_path = OUT_DIR / "hn_spectral_database.json" with open(out_path, "w") as f: json.dump(results, f, indent=2) print(f"\nResults → {out_path}") eval_path = write_eval(results) print(f"EVAL → {eval_path}") print(f"\nElapsed: {elapsed:.1f}s") print("=" * 70) print("DONE") print("=" * 70)