diff --git a/colored_pentagon_contractions.py b/colored_pentagon_contractions.py new file mode 100644 index 0000000..c0ff7a0 --- /dev/null +++ b/colored_pentagon_contractions.py @@ -0,0 +1,193 @@ +"""Example: colored pentagon reduction on a random 20-vertex triangulation.""" +import hashlib +import json +import base64 +from collections import defaultdict +from pathlib import Path +from typing import Any, cast, TypedDict, Literal +from sage.all import graphs, Graph # 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 +from lib.colored_graphs import ColoredGraphId, VertexColoring, save_colored_graph + +DIR = Path(__file__).parent +PALETTE = ['red', 'blue', 'green', 'yellow'] + +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 + +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 _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/colored_pentagon_reduction/paper.aux b/colored_pentagon_contractions/paper.aux similarity index 100% rename from colored_pentagon_reduction/paper.aux rename to colored_pentagon_contractions/paper.aux diff --git a/colored_pentagon_reduction/paper.fdb_latexmk b/colored_pentagon_contractions/paper.fdb_latexmk similarity index 100% rename from colored_pentagon_reduction/paper.fdb_latexmk rename to colored_pentagon_contractions/paper.fdb_latexmk diff --git a/colored_pentagon_reduction/paper.fls b/colored_pentagon_contractions/paper.fls similarity index 100% rename from colored_pentagon_reduction/paper.fls rename to colored_pentagon_contractions/paper.fls diff --git a/colored_pentagon_reduction/paper.log b/colored_pentagon_contractions/paper.log similarity index 100% rename from colored_pentagon_reduction/paper.log rename to colored_pentagon_contractions/paper.log diff --git a/colored_pentagon_reduction/paper.pdf b/colored_pentagon_contractions/paper.pdf similarity index 100% rename from colored_pentagon_reduction/paper.pdf rename to colored_pentagon_contractions/paper.pdf diff --git a/colored_pentagon_reduction/paper.synctex.gz b/colored_pentagon_contractions/paper.synctex.gz similarity index 100% rename from colored_pentagon_reduction/paper.synctex.gz rename to colored_pentagon_contractions/paper.synctex.gz diff --git a/colored_pentagon_reduction/paper.tex b/colored_pentagon_contractions/paper.tex similarity index 100% rename from colored_pentagon_reduction/paper.tex rename to colored_pentagon_contractions/paper.tex