diff --git a/colored_pentagon_reduction/example.py b/colored_pentagon_reduction/example.py index 007cef2..4bf20e5 100644 --- a/colored_pentagon_reduction/example.py +++ b/colored_pentagon_reduction/example.py @@ -24,6 +24,9 @@ class Operation(TypedDict): meta: Any before: ColoredGraphId after: ColoredGraphId + g: Graph + g_prime: Graph + coloring_prime: VertexColoring class CanonicalColoredGraph(TypedDict): """Canonical representation of a colored graph""" @@ -81,6 +84,77 @@ def save_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, Verte save(g_canon, str(out_dir / 'graph')) return g_canon, canonical_coloring, cid +def outer_face(g: Graph) -> list[Any]: + """Return the vertices of the outer (unbounded) face of g using its planar embedding and positions.""" + pos = g.get_pos() + embedding = g._embedding + + visited: set[tuple[Any, Any]] = set() + faces: list[list[Any]] = [] + + for u in g.vertices(): + for v in embedding[u]: + if (u, v) not in visited: + face: list[Any] = [] + cu, cv = u, v + while (cu, cv) not in visited: + visited.add((cu, cv)) + face.append(cu) + neighbors = embedding[cv] + cw = neighbors[(neighbors.index(cu) + 1) % len(neighbors)] + cu, cv = cv, cw + faces.append(face) + + def signed_area(face: list[Any]) -> float: + coords = [pos[v] for v in face] + n = len(coords) + return sum( + coords[i][0] * coords[(i + 1) % n][1] - coords[(i + 1) % n][0] * coords[i][1] + for i in range(n) + ) / 2 + + return min(faces, key=signed_area) + +def tutte_embedding(g: Graph, outer: list[Any]) -> dict[Any, tuple[float, float]]: + """Compute a Tutte embedding fixing outer on a convex polygon, solving for inner vertices.""" + import math + import numpy as np + + outer_set = set(outer) + inner = [v for v in g.vertices() if v not in outer_set] + + pos: dict[Any, tuple[float, float]] = {} + for i, v in enumerate(outer): + angle = 2 * math.pi * i / len(outer) + pos[v] = (math.cos(angle), math.sin(angle)) + + if not inner: + return pos + + inner_idx = {v: i for i, v in enumerate(inner)} + n = len(inner) + A = np.zeros((n, n)) + bx = np.zeros(n) + by = np.zeros(n) + + for i, v in enumerate(inner): + neighbors = g.neighbors(v) + deg = len(neighbors) + A[i, i] = 1.0 + for w in neighbors: + if w in inner_idx: + A[i, inner_idx[w]] = -1.0 / deg + else: + bx[i] += pos[w][0] / deg + by[i] += pos[w][1] / deg + + x = np.linalg.solve(A, bx) + y = np.linalg.solve(A, by) + for i, v in enumerate(inner): + pos[v] = (float(x[i]), float(y[i])) + + return pos + def _neighbors_form_cycle(g: Graph, v: Any) -> bool: """Return True if the neighbors of v induce a cycle in g.""" return bool(cast(Graph, g.subgraph(g.neighbors(v))).is_cycle()) @@ -98,6 +172,9 @@ def pluck(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexCol """Delete v0 and all its incident edges from g""" g_prime = g.copy() g_prime.delete_vertex(v0) + if (pos := g.get_pos()) is not None: + g_prime.set_pos({v: p for v, p in pos.items() if v != v0}) + g_prime.is_planar(set_embedding=True) coloring_prime = coloring.copy() del coloring_prime[v0] return g_prime, coloring_prime @@ -132,6 +209,9 @@ def squish(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexCo g_prime = g.copy() g_prime.merge_vertices([v0, v1, v2]) + if (pos := g.get_pos()) is not None: + g_prime.set_pos({v: p for v, p in pos.items() if v not in (v1, v2)}) + g_prime.is_planar(set_embedding=True) coloring_prime = {v: c for v, c in coloring.items() if v not in (v1, v2)} coloring_prime[v0] = coloring[v1] return g_prime, coloring_prime, v1, v2 @@ -159,7 +239,7 @@ def reduce( g_prime, coloring_prime = pluck(g, coloring, v) print(f"\nG' (after pluck v0={v}): {g_prime.order()} vertices, {g_prime.size()} edges") _, _, after_cid = save_colored_graph(g_prime, coloring_prime) - steps.append(PluckOperation(name='pluck', meta=PluckMeta(v0=v), before=before_cid, after=after_cid)) + steps.append(PluckOperation(name='pluck', meta=PluckMeta(v0=v), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid)) return reduce(g_prime, coloring_prime, after_cid, steps) if g.degree(v) == 4 and _neighbors_form_cycle(g, v): degree_4_candidates.append(v) @@ -172,7 +252,7 @@ def reduce( print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})") print(f"\nG' (after squish v0={v0}): {g_prime.order()} vertices, {g_prime.size()} edges") _, _, after_cid = save_colored_graph(g_prime, coloring_prime) - steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), before=before_cid, after=after_cid)) + steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid)) return reduce(g_prime, coloring_prime, after_cid, steps) if degree_5_candidates: @@ -181,7 +261,7 @@ def reduce( print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})") print(f"\nG' (after squish v0={v0}): {g_prime.order()} vertices, {g_prime.size()} edges") _, _, after_cid = save_colored_graph(g_prime, coloring_prime) - steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), before=before_cid, after=after_cid)) + steps.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), g=g, g_prime=g_prime, coloring_prime=coloring_prime, before=before_cid, after=after_cid)) return reduce(g_prime, coloring_prime, after_cid, steps) print("DONE") @@ -194,24 +274,39 @@ print(f"Degree sequence: {sorted(G.degree_sequence(), reverse=True)}") starting_coloring_classes = G.coloring() starting_coloring = {v: i for i, cls in enumerate(starting_coloring_classes) for v in cls} _, _, initial_cid = save_colored_graph(G, starting_coloring) +G.is_planar(set_embedding=True, set_pos=True) steps = reduce(G, starting_coloring, initial_cid) +def strip_graphs(obj: Any) -> Any: + if isinstance(obj, dict): + return {k: strip_graphs(v) for k, v in obj.items() if not isinstance(v, Graph)} + if isinstance(obj, list): + return [strip_graphs(v) for v in obj] + return obj + print("\nSteps:") -print(json.dumps(steps, indent=2)) +print(json.dumps(strip_graphs(steps), indent=2)) op_seq_id = operation_sequence_id(steps) op_dir = DIR / "data" / "operations" / op_seq_id op_dir.mkdir(parents=True, exist_ok=True) -(op_dir / "colored_pentagon_contractions.json").write_text(json.dumps(steps, indent=2)) +(op_dir / "colored_pentagon_contractions.json").write_text(json.dumps(strip_graphs(steps), indent=2)) -def img_data_uri(cid: ColoredGraphId) -> str: - png_bytes = (DIR / "data" / "graphs" / cid['graph_id'] / cid['coloring_id'] / "graph.png").read_bytes() +def plot_to_data_uri(g: Graph, coloring: VertexColoring) -> str: + import tempfile + vertex_colors: defaultdict[str, list[Any]] = defaultdict(list) + for v, c in coloring.items(): + vertex_colors[PALETTE[c]].append(v) + if g.get_pos() is None: + g.is_planar(set_embedding=True, set_pos=True) + g.set_pos(tutte_embedding(g, outer_face(g))) + with tempfile.NamedTemporaryFile(suffix='.png', delete=True) as f: + g.plot(vertex_colors=dict(vertex_colors)).save(f.name) + png_bytes = Path(f.name).read_bytes() return f"data:image/png;base64,{base64.b64encode(png_bytes).decode()}" -md_lines = [f"## start\n\n![start]({img_data_uri(steps[0]['before'])})"] +md_lines = [f"## start\n\n![start]({plot_to_data_uri(G, starting_coloring)})"] for step in steps: - b = step['before'] - a = step['after'] meta_json = json.dumps(step['meta']) - md_lines.append(f"## {step['name']} {meta_json}\n\n![b]({img_data_uri(a)})") + md_lines.append(f"## {step['name']} {meta_json}\n\n![b]({plot_to_data_uri(step['g_prime'], step['coloring_prime'])})") (op_dir / "colored_pentagon_contractions.md").write_text("\n".join(md_lines) + "\n")