Move canonize_colored_graph and save_colored_graph to lib/colored_graphs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<seq_id>. 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})"]
|
|
||||||
for op in op_sequence:
|
|
||||||
meta_json = json.dumps(op['meta'])
|
|
||||||
md_lines.append(f"## {op['name']} {meta_json}\n\n})")
|
|
||||||
(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)
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user