#!/usr/bin/env python3 """ closure_graph.py — build the directed vector graph of ZFA at capacity R, check it, render it. The graph of `Closure_Walk.md` §3: nodes are the lattice points `x ∈ ℤ⁴` with `|x|₁ ≤ R` (the capacity horizon is the ℓ¹ ball), and from every node one directed edge per twist, `x → x + s·e_a`, carrying the vector `s·e_a` and the sign `s·(−1)^ε`, `ε = Σ_{spatial b ranked above a} x_b mod 2` (0 on the gauge axis). The sign is a ℤ₂ connection: the product of edge signs around a closed path is the path's Pauli phase (`closure_walk.connection_phase` = `fold_phase`, checked on every closure to length 8). Asserted here, on the built graph: * node count = |ℓ¹ ball| = Σ_k 2^k C(4,k) C(R,k) (1, 9, 41, 129, 321 for R = 0..4); * the connection part (−1)^ε of an edge is the same in both directions (the twist sign s is the direction itself), so each undirected edge has one well-defined connection sign; * every plaquette spanned by two DIFFERENT spatial axes has holonomy −1, every plaquette involving the gauge axis has holonomy +1 (π flux through mixed spatial plaquettes only); * counting closed walks at the origin on the graph reproduces the census: unsigned `W_t` = 8, 168, 5120 and the signed sum Σ phase over closures, for t ≤ 2R (a closed walk of length t never leaves the ball of radius t/2); * the double cover (x, σ) has 2·|ball| nodes and every lifted edge lands in the ball. Writes data/closure_graph_R{R}.json and closure_graph.html (self-contained, canvas, no libraries; rotate by drag, wheel to zoom, hover a node for its ways-to-close counts). Run: python3 closure_graph.py [--R 3] """ from __future__ import annotations import argparse import itertools import json import math import os import sys from closure_walk import closed_walk_count, edge_sign, _AX from census_inventory import balanced_histories, fold_phase from doob_bridge import h as ways_to_close _HERE = os.path.dirname(os.path.abspath(__file__)) TWISTS = ['^', 'v', '>', '<', '/', '\\', '+', '-'] AXIS_NAME = ['Y', 'X', 'Z', 'gauge'] # index order of the action vector def ball(R: int) -> list[tuple[int, int, int, int]]: pts = [] for x in itertools.product(range(-R, R + 1), repeat=4): if sum(abs(v) for v in x) <= R: pts.append(x) pts.sort(key=lambda p: (sum(abs(v) for v in p), p)) return pts def ball_size(R: int) -> int: return sum(2 ** k * math.comb(4, k) * math.comb(R, k) for k in range(0, 5)) def build(R: int) -> dict: nodes = ball(R) index = {x: i for i, x in enumerate(nodes)} edges = [] # directed for i, x in enumerate(nodes): for tw in TWISTS: a, s = _AX[tw] y = list(x); y[a] += s; y = tuple(y) if y not in index: continue sg = edge_sign(list(x), a, s) edges.append({"from": i, "to": index[y], "twist": tw, "axis": a, "s": s, "sign": sg, "conn": sg * s}) # connection is direction-independent lookup = {(e["from"], e["to"]): e for e in edges} for e in edges: back = lookup[(e["to"], e["from"])] assert back["conn"] == e["conn"], "connection sign differs by direction" undirected = [] for e in edges: if e["from"] < e["to"]: undirected.append({"a": e["from"], "b": e["to"], "axis": e["axis"], "conn": e["conn"]}) # plaquettes: x, x+e_a, x+e_a+e_b, x+e_b (positive orientation; other orientations # differ by direction signs that cancel around a loop) plaquettes = [] for x in nodes: for a in range(4): for b in range(a + 1, 4): corners = [] for da, db in ((0, 0), (1, 0), (1, 1), (0, 1)): y = list(x); y[a] += da; y[b] += db corners.append(tuple(y)) if any(c not in index for c in corners): continue loop = [(corners[0], a, 1), (corners[1], b, 1), (corners[2], a, -1), (corners[3], b, -1)] hol = 1 for c, ax, s in loop: hol *= edge_sign(list(c), ax, s) mixed_spatial = a != 3 and b != 3 assert hol == (-1 if mixed_spatial else 1), f"plaquette {x} axes {a},{b}: holonomy {hol}" plaquettes.append({"corners": [index[c] for c in corners], "axes": [a, b], "holonomy": hol}) # closed walks at the origin on the graph vs the census, unsigned and signed o = index[(0, 0, 0, 0)] adj = [[] for _ in nodes] for e in edges: adj[e["from"]].append((e["to"], e["sign"])) checks = {} for t in range(2, 2 * R + 1, 2): u = [0] * len(nodes); u[o] = 1 sgn = [0] * len(nodes); sgn[o] = 1 for _ in range(t): nu = [0] * len(nodes); ns = [0] * len(nodes) for i in range(len(nodes)): if u[i] or sgn[i]: for j, sg in adj[i]: nu[j] += u[i]; ns[j] += sgn[i] * sg u, sgn = nu, ns census_signed = sum(1 if fold_phase(hh) == "+1" else -1 for hh in balanced_histories(t)) assert u[o] == closed_walk_count(t), f"unsigned walk count at t={t}: {u[o]}" assert sgn[o] == census_signed, f"signed walk count at t={t}: {sgn[o]} vs census {census_signed}" checks[t] = {"W": u[o], "signed": sgn[o]} # per-node ways-to-close, for the tooltip node_recs = [] for i, x in enumerate(nodes): d = sum(abs(v) for v in x) hs = {t: ways_to_close(x, t) for t in (d, d + 2, d + 4) if t > 0} node_recs.append({"id": i, "x": list(x), "depth": d, "h": {str(t): v for t, v in hs.items()}, "deg": len(adj[i])}) n = len(nodes) assert n == ball_size(R), f"ball size {n} ≠ {ball_size(R)}" cover_edges = 0 for e in edges: eps = 0 if e["conn"] == 1 else 1 for sigma in (0, 1): cover_edges += 1 # (from, sigma) -> (to, sigma ^ eps); target always in the ball return { "R": R, "nodes": node_recs, "edges": undirected, "directed_edges": len(edges), "plaquettes": plaquettes, "summary": { "nodes": n, "directed_edges": len(edges), "undirected_edges": len(undirected), "plaquettes": len(plaquettes), "plaquettes_flux_minus": sum(1 for p in plaquettes if p["holonomy"] == -1), "double_cover_nodes": 2 * n, "double_cover_edges": cover_edges, "edges_conn_minus": sum(1 for e in undirected if e["conn"] == -1), "walk_checks": checks, "spatial_only_nodes": sum(1 for x in nodes if x[3] == 0), }, } def render(g: dict) -> str: data = json.dumps({"R": g["R"], "nodes": g["nodes"], "edges": g["edges"], "plaquettes": g["plaquettes"], "summary": g["summary"]}, separators=(",", ":")) return TEMPLATE.replace("/*DATA*/null", data) TEMPLATE = r"""Closure Walk Graph

The closure walk at capacity R =

Every lattice point of ℤ⁴ within ℓ¹-distance R of the origin, joined by the eight twists. Edge colour is the ℤ₂ connection; a closed path's Pauli phase is the product of its edge signs.

Show
Key
origin — the closure set
|x|₁ = 1  2  3
node with gauge component
connection +1
connection −1 (Jordan–Wigner sign)

Spatial axes X, Y, Z are drawn as three orthogonal directions; the gauge axis is the fourth, drawn as a skew offset. Drag to rotate, wheel to zoom, hover a node for its ways-to-close counts h(x,t). Spec: Closure_Walk.md §3; built and checked by closure_graph.py.

""" def main(argv=None) -> int: ap = argparse.ArgumentParser() ap.add_argument("--R", type=int, default=3) args = ap.parse_args(argv) g = build(args.R) s = g["summary"] print(f"R = {args.R}: {s['nodes']} nodes, {s['directed_edges']} directed edges " f"({s['undirected_edges']} undirected, {s['edges_conn_minus']} with connection −1), " f"{s['plaquettes']} plaquettes of which {s['plaquettes_flux_minus']} carry π flux; " f"double cover {s['double_cover_nodes']} nodes / {s['double_cover_edges']} edges") for t, c in s["walk_checks"].items(): print(f" closed walks at the origin, t={t}: {c['W']} (= W_t), signed Σ phase = {c['signed']} (= census)") print(" all plaquette holonomies: −1 iff both axes spatial — OK") jpath = os.path.join(_HERE, "data", f"closure_graph_R{args.R}.json") with open(jpath, "w") as f: json.dump({k: v for k, v in g.items() if k != "directed_edges"}, f, separators=(",", ":")) hpath = os.path.join(_HERE, "closure_graph.html") with open(hpath, "w") as f: f.write(render(g)) print(f"wrote {os.path.relpath(jpath, _HERE)}, {os.path.relpath(hpath, _HERE)}") return 0 if __name__ == "__main__": sys.exit(main())