From a094250cc8a3715b785efa7474a74e21d8bea3e6 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Apr 2026 21:15:04 -0400 Subject: [PATCH] Move canonize_colored_graph and save_colored_graph to lib/colored_graphs Co-Authored-By: Claude Sonnet 4.6 --- colored_pentagon_reduction/example.py | 245 -------------------------- lib/colored_graphs.py | 60 +++++++ 2 files changed, 60 insertions(+), 245 deletions(-) delete mode 100644 colored_pentagon_reduction/example.py create mode 100644 lib/colored_graphs.py diff --git a/colored_pentagon_reduction/example.py b/colored_pentagon_reduction/example.py deleted file mode 100644 index 0c7a2ee..0000000 --- a/colored_pentagon_reduction/example.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Example: colored pentagon reduction on a random 20-vertex triangulation.""" -import base64 -import hashlib -import json -from collections import defaultdict -from pathlib import Path -from typing import Any, cast, TypedDict, Literal -from sage.all import graphs, Graph, save, load # type: ignore[attr-defined] # pylint: disable=no-name-in-module -from lib.tutte_embedding import tutte_embedding -from lib.planar_embedding import outer_face, get_embedding_from_pos - -DIR = Path(__file__).parent.parent -PALETTE = ['red', 'blue', 'green', 'yellow'] - -VertexColoring = dict[Any, Any] - -class ColoredGraphId(TypedDict): - """Canonical id representing a colored graph""" - graph_id: str - coloring_id: str - -class Operation(TypedDict): - """Information about a change made to a (colored) graph""" - name: Any - meta: Any - source_graph: Graph - source_canon_id: ColoredGraphId - result_graph: Graph - result_coloring: VertexColoring - result_canon_id: ColoredGraphId - -class CanonicalColoredGraph(TypedDict): - """Canonical representation of a colored graph""" - colored_graph_id: ColoredGraphId - graph: Graph - coloring: VertexColoring - -def colored_graph_id_to_string(cid: ColoredGraphId) -> str: - return f"{cid['graph_id']} {cid['coloring_id']}" - -def op_to_transform_id(op: Operation) -> str: - return f"{colored_graph_id_to_string(op['source_canon_id'])} -> {colored_graph_id_to_string(op['result_canon_id'])}" - -def operation_sequence_id(ops: list[Operation]) -> str: - joined = "\n".join(op_to_transform_id(op) for op in ops) - return hashlib.sha256(joined.encode()).hexdigest() - -def canonize_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, VertexColoring, ColoredGraphId]: - """Return a new canonical graph, new canonical coloring, and a ColoredGraphId without mutating inputs""" - canonical, cert = cast( - tuple[Graph, dict[Any, int]], - g.canonical_label(certificate=True), - ) - graph_id = base64.urlsafe_b64encode( - canonical.graph6_string().encode() - ).decode() - - color_seq = [0] * g.order() - for orig_v, canon_idx in cert.items(): - color_seq[canon_idx] = coloring[orig_v] - - canonical_coloring = {canon_idx: color for canon_idx, color in enumerate(color_seq)} - coloring_id = base64.urlsafe_b64encode(bytes(color_seq)).decode() - return canonical, canonical_coloring, ColoredGraphId(graph_id=graph_id, coloring_id=coloring_id) - -def save_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, VertexColoring, ColoredGraphId]: - """ - Canonize g and coloring, save to disk, and return the canonical forms with id. - If already saved, load and return the cached graph. - """ - g_canon, canonical_coloring, cid = canonize_colored_graph(g, coloring) - out_dir = DIR / "data" / "graphs" / cid['graph_id'] / cid['coloring_id'] - if (out_dir / "graph.sobj").exists(): - g_canon = cast(Graph, load(str(out_dir / 'graph'))) - return g_canon, canonical_coloring, cid - g_canon.is_planar(set_embedding=True, set_pos=True) - vertex_colors: defaultdict[str, list[Any]] = defaultdict(list) - for v, c in canonical_coloring.items(): - vertex_colors[PALETTE[c]].append(v) - out_dir.mkdir(parents=True, exist_ok=True) - g_canon.plot( - vertex_colors=dict(vertex_colors), - title=f"graph: {cid['graph_id']} coloring: {cid['coloring_id']}", - ).save(out_dir / 'graph.png') - save(g_canon, str(out_dir / 'graph')) - return g_canon, canonical_coloring, cid - -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()) - -class PluckMeta(TypedDict): - """Meta information about the pluck operation""" - v0: Any - -class PluckOperation(Operation): - """Info about an operation in which a vertex v0 and its incident edges is removed from G""" - name: Literal['pluck'] - meta: PluckMeta - -def pluck(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexColoring]: - """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.set_embedding(get_embedding_from_pos(g_prime)) - coloring_prime = coloring.copy() - del coloring_prime[v0] - return g_prime, coloring_prime - - -class SquishMeta(TypedDict): - """Meta information about the squish operation""" - v0: Any - v_merged: list[Any] - -class SquishOperation(Operation): - """ - Info about an operation in which two same colored neighbors of a vertex v0 are merged - into v0 - """ - name: Literal['squish'] - meta: SquishMeta - -def squish(g: Graph, coloring: VertexColoring, v0: Any) -> tuple[Graph, VertexColoring, Any, Any]: - """ - Contract two same-colored neighbors of v0 into v0 and return a new valid - coloring along with the new graph. - NOTE: assumes g is a maximal planar graph - """ - neighbor_by_color: defaultdict[Any, list[Any]] = defaultdict(list) - for v in g.neighbors(v0): - neighbor_by_color[coloring[v]].append(v) - - v1, v2 = next( - (vs[0], vs[1]) for vs in neighbor_by_color.values() if len(vs) >= 2 - ) - - 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.set_embedding(get_embedding_from_pos(g_prime)) - 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 - - -ReduceOperation = SquishOperation | PluckOperation - -def reduce( - g: Graph, - coloring: VertexColoring, - source_canon_id: ColoredGraphId, - op_sequence: list[ReduceOperation] | None = None, -) -> list[ReduceOperation]: - """Repeatedly apply pluck/squish reductions until no candidates remain.""" - if op_sequence is None: - op_sequence = [] - - print(f"Coloring: {coloring}") - - degree_4_candidates: list[Any] = [] - degree_5_candidates: list[Any] = [] - - for v in g.vertices(): - if g.degree(v) == 3 and _neighbors_form_cycle(g, v): - result_graph, result_coloring = pluck(g, coloring, v) - print(f"\nG' (after pluck v0={v}): {result_graph.order()} vertices, {result_graph.size()} edges") - _, _, result_canon_id = save_colored_graph(result_graph, result_coloring) - op_sequence.append(PluckOperation(name='pluck', meta=PluckMeta(v0=v), source_graph=g, source_canon_id=source_canon_id, result_graph=result_graph, result_coloring=result_coloring, result_canon_id=result_canon_id)) - return reduce(result_graph, result_coloring, result_canon_id, op_sequence) - if g.degree(v) == 4 and _neighbors_form_cycle(g, v): - degree_4_candidates.append(v) - elif g.degree(v) == 5 and _neighbors_form_cycle(g, v): - degree_5_candidates.append(v) - - if degree_4_candidates: - v0 = degree_4_candidates[0] - result_graph, result_coloring, v1, v2 = squish(g, coloring, v0) - print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})") - print(f"\nG' (after squish v0={v0}): {result_graph.order()} vertices, {result_graph.size()} edges") - _, _, result_canon_id = save_colored_graph(result_graph, result_coloring) - op_sequence.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), source_graph=g, source_canon_id=source_canon_id, result_graph=result_graph, result_coloring=result_coloring, result_canon_id=result_canon_id)) - return reduce(result_graph, result_coloring, result_canon_id, op_sequence) - - if degree_5_candidates: - v0 = degree_5_candidates[0] - result_graph, result_coloring, v1, v2 = squish(g, coloring, v0) - print(f"Shared-color neighbors: v1={v1}, v2={v2} (color {coloring[v1]})") - print(f"\nG' (after squish v0={v0}): {result_graph.order()} vertices, {result_graph.size()} edges") - _, _, result_canon_id = save_colored_graph(result_graph, result_coloring) - op_sequence.append(SquishOperation(name='squish', meta=SquishMeta(v0=v0, v_merged=[v1, v2]), source_graph=g, source_canon_id=source_canon_id, result_graph=result_graph, result_coloring=result_coloring, result_canon_id=result_canon_id)) - return reduce(result_graph, result_coloring, result_canon_id, op_sequence) - - print("DONE") - return op_sequence - - -G = next(graphs.planar_graphs(20, minimum_degree=5)) -print(f"G: {G.order()} vertices, {G.size()} edges") -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_canon_id = save_colored_graph(G, starting_coloring) -G.is_planar(set_embedding=True, set_pos=True) - -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 - -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()}" - -def save_operation_sequence(op_sequence: list[ReduceOperation], g: Graph, coloring: VertexColoring) -> str: - """Save op_sequence as JSON and Markdown under data/operations/. Returns the sequence id.""" - op_seq_id = operation_sequence_id(op_sequence) - 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(strip_graphs(op_sequence), indent=2)) - md_lines = [f"## start\n\n![start]({plot_to_data_uri(g, coloring)})"] - for op in op_sequence: - meta_json = json.dumps(op['meta']) - md_lines.append(f"## {op['name']} {meta_json}\n\n![b]({plot_to_data_uri(op['result_graph'], op['result_coloring'])})") - (op_dir / "colored_pentagon_contractions.md").write_text("\n".join(md_lines) + "\n") - return op_seq_id - -op_sequence = reduce(G, starting_coloring, initial_canon_id) -print("\nOp sequence:") -print(json.dumps(strip_graphs(op_sequence), indent=2)) -save_operation_sequence(op_sequence, G, starting_coloring) diff --git a/lib/colored_graphs.py b/lib/colored_graphs.py new file mode 100644 index 0000000..a9a841c --- /dev/null +++ b/lib/colored_graphs.py @@ -0,0 +1,60 @@ +"""Utilities for canonizing and saving colored graphs.""" +import base64 +from collections import defaultdict +from pathlib import Path +from typing import Any, cast, TypedDict + +from sage.all import Graph, save, load # type: ignore[attr-defined] # pylint: disable=no-name-in-module + +DIR = Path(__file__).parent.parent +PALETTE = ['red', 'blue', 'green', 'yellow'] + +VertexColoring = dict[Any, Any] + + +class ColoredGraphId(TypedDict): + """Canonical id representing a colored graph""" + graph_id: str + coloring_id: str + + +def canonize_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, VertexColoring, ColoredGraphId]: + """Return a new canonical graph, new canonical coloring, and a ColoredGraphId without mutating inputs""" + canonical, cert = cast( + tuple[Graph, dict[Any, int]], + g.canonical_label(certificate=True), + ) + graph_id = base64.urlsafe_b64encode( + canonical.graph6_string().encode() + ).decode() + + color_seq = [0] * g.order() + for orig_v, canon_idx in cert.items(): + color_seq[canon_idx] = coloring[orig_v] + + canonical_coloring = {canon_idx: color for canon_idx, color in enumerate(color_seq)} + coloring_id = base64.urlsafe_b64encode(bytes(color_seq)).decode() + return canonical, canonical_coloring, ColoredGraphId(graph_id=graph_id, coloring_id=coloring_id) + + +def save_colored_graph(g: Graph, coloring: VertexColoring) -> tuple[Graph, VertexColoring, ColoredGraphId]: + """ + Canonize g and coloring, save to disk, and return the canonical forms with id. + If already saved, load and return the cached graph. + """ + g_canon, canonical_coloring, cid = canonize_colored_graph(g, coloring) + out_dir = DIR / "data" / "graphs" / cid['graph_id'] / cid['coloring_id'] + if (out_dir / "graph.sobj").exists(): + g_canon = cast(Graph, load(str(out_dir / 'graph'))) + return g_canon, canonical_coloring, cid + g_canon.is_planar(set_embedding=True, set_pos=True) + vertex_colors: defaultdict[str, list[Any]] = defaultdict(list) + for v, c in canonical_coloring.items(): + vertex_colors[PALETTE[c]].append(v) + out_dir.mkdir(parents=True, exist_ok=True) + g_canon.plot( + vertex_colors=dict(vertex_colors), + title=f"graph: {cid['graph_id']} coloring: {cid['coloring_id']}", + ).save(out_dir / 'graph.png') + save(g_canon, str(out_dir / 'graph')) + return g_canon, canonical_coloring, cid