Add figures, Kempe-cycle section, and restriction experiments

Adds two TikZ figures (boundary-state worst cases and annular cycle
counterexample), a new subsection on Kempe-cycle conservation across
medial tires, and the experiment scripts/findings for the medial tire
restriction search and annular cycle condition check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 01:16:05 -04:00
parent 20fe6c24ca
commit 4062e87c61
10 changed files with 1418 additions and 239 deletions
@@ -0,0 +1,358 @@
"""Check the medial annular-cycle almost-two-colour condition.
For each generated plane triangulation G and each requested level source:
1. Build the full medial graph M(G).
2. Find depth-component tire annular medial subgraphs.
3. Enumerate simple cycles in those annular subgraphs.
4. Search for a proper vertex 3-colouring of M(G) such that every
such cycle uses two colours except at at most one vertex.
Run with Sage, for example:
sage -python papers/medial_tire_decompositions_of_plane_triangulations/experiments/check_medial_annular_cycle_condition.py --n-min 4 --n-max 8
"""
from __future__ import annotations
import argparse
from collections import defaultdict, deque
from itertools import combinations
from typing import Any, Iterable, Iterator, Sequence, cast
from sage.all import Graph, graphs # type: ignore[attr-defined] # pylint: disable=no-name-in-module
from sage.graphs.graph_coloring import all_graph_colorings # type: ignore[attr-defined] # pylint: disable=no-name-in-module
Edge = tuple[Any, Any]
Coloring = dict[Edge, int]
Source = tuple[Any, ...]
def vertex_key(v: Any) -> str:
return repr(v)
def edge_key(u: Any, v: Any) -> Edge:
return (u, v) if vertex_key(u) <= vertex_key(v) else (v, u)
def is_induced_cycle(g: Graph, vertices: Sequence[Any]) -> bool:
if len(vertices) < 3:
return False
h = cast(Graph, g.subgraph(list(vertices)))
return h.is_connected() and h.num_edges() == len(vertices) and all(
h.degree(v) == 2 for v in h.vertices()
)
def induced_cycle_sources(g: Graph, max_size: int | None = None) -> Iterator[Source]:
vertices = sorted(g.vertices(), key=vertex_key)
upper = len(vertices) if max_size is None else min(max_size, len(vertices))
for k in range(3, upper + 1):
for subset in combinations(vertices, k):
if is_induced_cycle(g, subset):
yield tuple(subset)
def level_sources(g: Graph, mode: str, max_cycle_source_size: int | None) -> Iterator[Source]:
if mode in ("vertex", "all"):
for v in sorted(g.vertices(), key=vertex_key):
yield (v,)
if mode in ("cycle", "all"):
yield from induced_cycle_sources(g, max_cycle_source_size)
def distances_from_source(g: Graph, source: Source) -> dict[Any, int]:
if len(source) == 1:
return dict(g.shortest_path_lengths(source[0]))
distances = {v: 0 for v in source}
queue: deque[Any] = deque(source)
while queue:
v = queue.popleft()
for w in g.neighbor_iterator(v):
if w in distances:
continue
distances[w] = distances[v] + 1
queue.append(w)
return distances
def embedded_copy(g: Graph) -> Graph:
emb = cast(Graph, g.copy())
if not emb.is_planar(set_embedding=True):
raise ValueError("graph is not planar")
return emb
def medial_graph(g: Graph) -> Graph:
"""Build the full medial graph from the embedding rotation at vertices."""
emb = embedded_copy(g)
rotation = emb.get_embedding()
m = Graph()
medial_vertices = [edge_key(u, v) for u, v, _ in emb.edge_iterator()]
m.add_vertices(medial_vertices)
for v, neighbors in rotation.items():
if len(neighbors) < 2:
continue
n = len(neighbors)
for i in range(n):
e1 = edge_key(v, neighbors[i])
e2 = edge_key(v, neighbors[(i + 1) % n])
if e1 != e2:
m.add_edge(e1, e2)
return m
def face_vertices(face: Sequence[tuple[Any, Any]]) -> set[Any]:
out: set[Any] = set()
for u, v in face:
out.add(u)
out.add(v)
return out
def face_edges(face: Sequence[tuple[Any, Any]]) -> set[Edge]:
return {edge_key(u, v) for u, v in face}
def dual_components_by_depth(
g: Graph, source: Source
) -> list[tuple[int, list[int], set[Edge]]]:
"""Return (depth, face-indices, annular-edge-set) for each depth component."""
emb = embedded_copy(g)
distances = distances_from_source(emb, source)
faces = emb.faces()
f_vertices = [face_vertices(face) for face in faces]
f_edges = [face_edges(face) for face in faces]
depths = [min(distances[v] for v in verts) for verts in f_vertices]
edge_faces: dict[Edge, list[int]] = defaultdict(list)
for idx, edges in enumerate(f_edges):
for edge in edges:
edge_faces[edge].append(idx)
dual_adj: dict[int, set[int]] = defaultdict(set)
for incident in edge_faces.values():
for a in range(len(incident)):
for b in range(a + 1, len(incident)):
dual_adj[incident[a]].add(incident[b])
dual_adj[incident[b]].add(incident[a])
components = []
seen = [False] * len(faces)
for start in range(len(faces)):
if seen[start]:
continue
depth = depths[start]
comp = [start]
seen[start] = True
stack = [start]
while stack:
f = stack.pop()
for h in dual_adj[f]:
if not seen[h] and depths[h] == depth:
seen[h] = True
comp.append(h)
stack.append(h)
annular_edges: set[Edge] = set()
for f in comp:
for u, v in f_edges[f]:
if {distances[u], distances[v]} == {depth, depth + 1}:
annular_edges.add(edge_key(u, v))
if len(annular_edges) >= 3:
components.append((depth, comp, annular_edges))
return components
def simple_cycle_vertex_sets(g: Graph) -> set[frozenset[Any]]:
vertices = sorted(g.vertices(), key=repr)
index = {v: i for i, v in enumerate(vertices)}
cycles: set[frozenset[Any]] = set()
def dfs(start: Any, current: Any, path: list[Any], seen: set[Any]) -> None:
for nxt in g.neighbor_iterator(current):
if nxt == start:
if len(path) >= 3:
cycles.add(frozenset(path))
continue
if nxt in seen or index[nxt] <= index[start]:
continue
seen.add(nxt)
path.append(nxt)
dfs(start, nxt, path, seen)
path.pop()
seen.remove(nxt)
for start in vertices:
dfs(start, start, [start], {start})
return cycles
def annular_medial_cycles(g: Graph, source: Source) -> list[frozenset[Edge]]:
m = medial_graph(g)
cycles: list[frozenset[Edge]] = []
seen: set[frozenset[Edge]] = set()
for _depth, _faces, annular_edges in dual_components_by_depth(g, source):
sub = cast(Graph, m.subgraph(list(annular_edges)))
for cycle in simple_cycle_vertex_sets(sub):
typed = frozenset(cast(Iterable[Edge], cycle))
if typed not in seen:
seen.add(typed)
cycles.append(typed)
return cycles
def almost_two_coloured(cycle: frozenset[Edge], coloring: Coloring) -> bool:
counts = defaultdict(int)
for vertex in cycle:
counts[coloring[vertex]] += 1
return min(counts.get(c, 0) for c in range(3)) <= 1
def first_cycle_violation(
cycles: Sequence[frozenset[Edge]], coloring: Coloring
) -> frozenset[Edge] | None:
for cycle in cycles:
if not almost_two_coloured(cycle, coloring):
return cycle
return None
def color_counts(cycle: frozenset[Edge], coloring: Coloring) -> dict[int, int]:
counts = {0: 0, 1: 0, 2: 0}
for vertex in cycle:
counts[coloring[vertex]] += 1
return counts
def coloring_witness(
m: Graph,
cycles: Sequence[frozenset[Edge]],
max_colorings: int | None,
) -> tuple[Coloring | None, int, bool, frozenset[Edge] | None, Coloring | None]:
checked = 0
last_violation = None
for raw in all_graph_colorings(m, 3, vertex_color_dict=True):
coloring = cast(Coloring, raw)
checked += 1
violation = first_cycle_violation(cycles, coloring)
if violation is None:
return coloring, checked, True, None, None
last_violation = violation
if max_colorings is not None and checked >= max_colorings:
return None, checked, False, last_violation, coloring
return None, checked, True, last_violation, coloring
def source_label(source: Source) -> str:
if len(source) == 1:
return f"vertex:{source[0]}"
return "cycle:{" + ",".join(map(str, source)) + "}"
def graphs_to_check(n: int, max_graphs: int | None):
for idx, g in enumerate(graphs.triangulations(n)):
if max_graphs is not None and idx >= max_graphs:
break
yield idx, cast(Graph, g)
def run(args: argparse.Namespace) -> None:
total_cases = 0
skipped_no_cycles = 0
witnesses = 0
failures = []
inconclusive = []
for n in range(args.n_min, args.n_max + 1):
print(f"n={n}")
for graph_idx, g in graphs_to_check(n, args.max_graphs_per_n):
m = medial_graph(g)
sources = list(level_sources(g, args.sources, args.max_cycle_source_size))
if args.max_sources_per_graph is not None:
sources = sources[: args.max_sources_per_graph]
for source in sources:
cycles = annular_medial_cycles(g, source)
if not cycles:
skipped_no_cycles += 1
continue
total_cases += 1
witness, checked, exhausted, violation, last_coloring = coloring_witness(
m, cycles, args.max_colorings
)
if witness is not None:
witnesses += 1
if args.verbose:
print(
f" graph={graph_idx} source={source_label(source)} "
f"cycles={len(cycles)} witness_after={checked}"
)
continue
record = {
"n": n,
"graph_idx": graph_idx,
"graph_edges": sorted(edge_key(u, v) for u, v, _ in g.edge_iterator()),
"source": source_label(source),
"cycles": len(cycles),
"checked": checked,
"exhausted": exhausted,
"violation_size": len(violation) if violation else None,
}
if args.failure_details and violation is not None and last_coloring is not None:
record["violation_cycle"] = sorted(violation)
record["violation_counts"] = color_counts(violation, last_coloring)
record["violation_coloring"] = {
edge: last_coloring[edge] for edge in sorted(violation)
}
if exhausted:
failures.append(record)
print(" FAILURE", record)
if args.stop_on_failure:
print_summary(total_cases, skipped_no_cycles, witnesses, failures, inconclusive)
return
else:
inconclusive.append(record)
print(" INCONCLUSIVE", record)
print_summary(total_cases, skipped_no_cycles, witnesses, failures, inconclusive)
def print_summary(
total_cases: int,
skipped_no_cycles: int,
witnesses: int,
failures: Sequence[dict],
inconclusive: Sequence[dict],
) -> None:
print()
print("summary")
print(f" checked source decompositions with annular cycles: {total_cases}")
print(f" skipped source decompositions with no annular cycles: {skipped_no_cycles}")
print(f" witnesses found: {witnesses}")
print(f" failures: {len(failures)}")
print(f" inconclusive: {len(inconclusive)}")
if failures:
print(f" first failure: {failures[0]}")
if inconclusive:
print(f" first inconclusive: {inconclusive[0]}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--n-min", type=int, default=4)
parser.add_argument("--n-max", type=int, default=8)
parser.add_argument("--sources", choices=("vertex", "cycle", "all"), default="vertex")
parser.add_argument("--max-cycle-source-size", type=int, default=6)
parser.add_argument("--max-graphs-per-n", type=int)
parser.add_argument("--max-sources-per-graph", type=int)
parser.add_argument("--max-colorings", type=int)
parser.add_argument("--stop-on-failure", action="store_true")
parser.add_argument("--failure-details", action="store_true")
parser.add_argument("--verbose", action="store_true")
run(parser.parse_args())
if __name__ == "__main__":
main()
@@ -0,0 +1,83 @@
# Medial Annular Cycle Condition Findings
Question: for an actual plane triangulation and a tire decomposition,
does there exist a proper vertex 3-coloring of the full medial graph
such that every medial annular simple cycle uses two colors except at
at most one vertex?
The experiment is graph-level, not local-pattern-level:
1. Build the full medial graph `M(G)` from the embedding rotation.
2. Enumerate proper vertex 3-colorings of `M(G)`.
3. For each level-source tire decomposition, build the annular medial
subgraph of each depth-component tire.
4. Enumerate simple cycles in those annular medial subgraphs.
5. Search for a proper medial coloring satisfying the almost-two-color
condition on every such cycle.
## First Counterexample Found
Command:
```bash
sage -python papers/medial_tire_decompositions_of_plane_triangulations/experiments/check_medial_annular_cycle_condition.py --n-min 7 --n-max 7 --stop-on-failure --failure-details
```
Result:
```text
n=7
FAILURE {
'n': 7,
'graph_idx': 0,
'graph_edges': [
(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7),
(2, 3), (2, 6), (2, 7), (3, 4), (3, 5), (3, 6),
(4, 5), (5, 6), (6, 7)
],
'source': 'vertex:1',
'cycles': 1,
'checked': 6,
'exhausted': True,
'violation_size': 6,
'violation_cycle': [
(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7)
],
'violation_counts': {0: 2, 1: 2, 2: 2},
'violation_coloring': {
(1, 2): 2, (1, 3): 1, (1, 4): 0,
(1, 5): 2, (1, 6): 0, (1, 7): 1
}
}
```
Interpretation: for the vertex-source decomposition at source `1`, the
only annular medial simple cycle is the six medial vertices
corresponding to the six edges incident to `1`. Sage enumerated all six
proper vertex 3-colorings of the full medial graph. Every one violates
the almost-two-color condition on that cycle; the displayed coloring has
color counts `{0: 2, 1: 2, 2: 2}` on the annular cycle.
Thus the conjecture, in the form "for every possible tire decomposition
there exists a proper medial 3-coloring satisfying the condition on all
medial annular simple cycles", is false.
## Default Sweep
Command:
```bash
sage -python papers/medial_tire_decompositions_of_plane_triangulations/experiments/check_medial_annular_cycle_condition.py --n-min 4 --n-max 8
```
Summary:
```text
checked source decompositions with annular cycles: 168
skipped source decompositions with no annular cycles: 0
witnesses found: 111
failures: 57
inconclusive: 0
```
Failures begin at `n=7` for vertex-source decompositions.
@@ -0,0 +1,178 @@
# Medial Tire Restriction Findings
Question: among possible full medial tire graphs, which examples most
restrict the inner boundary coloring relative to the outer boundary
coloring?
## Model
The experiment uses the ambient full-medial tread model.
- Annular medial vertices form a cycle `A_0, ..., A_{n-1}`.
- Each annular face contributes one boundary-medial vertex `B_i`.
- `B_i` is adjacent to `A_i` and `A_{i+1}`.
- `B_i` is tagged `O` or `I` according to whether the corresponding
primal boundary edge lies on the outer or inner boundary.
Boundary states are quotient by color permutations only. Boundary
order is fixed: rotations and reversals are not identified.
Inner chords of the inner outerplanar graph do not affect this ambient
full medial tire graph, because they are not incident to annular tread
faces.
## Metrics
For the quotient transfer relation
```text
R_T subset outer_state_orbits x inner_state_orbits
```
the script reports:
- `min_outer_extensions`: minimum number of compatible inner state
orbits over realized outer state orbits.
- `avg_outer_extensions`: average number of compatible inner state
orbits over realized outer state orbits.
- `relation_density_all`: `|R_T|` divided by all possible outer/inner
state orbit pairs.
- `blocked_outer_orbits`: number of outer boundary state orbits that
are not compatible with any inner boundary state orbit.
- `blocked_inner_orbits`: number of inner boundary state orbits that
are not compatible with any outer boundary state orbit.
## Exhaustive Sweep Through 10 Faces
Command:
```bash
python3 papers/medial_tire_decompositions_of_plane_triangulations/experiments/medial_tire_restriction_search.py --exhaustive --min-faces 6 --max-faces 10 --limit 3
```
Result:
```text
graphs analyzed: 1974
boundary states quotient by color permutations only; no rotations or reversals
most restrictive by min_outer_extensions
types=IIIOOO
faces=6 outer=3 inner=3 outer_orbits=5 inner_orbits=5
realized_outer=4 realized_inner=4 relation=10
blocked_outer=1 (0.200) blocked_inner=1 (0.200)
min_outer_extensions=1 avg_outer_extensions=2.500
density_all=0.400000 density_realized_outer=0.500000
worst_outer_states=((0, 1, 2),)
types=IIOIOO
faces=6 outer=3 inner=3 outer_orbits=5 inner_orbits=5
realized_outer=5 realized_inner=5 relation=9
blocked_outer=0 (0.000) blocked_inner=0 (0.000)
min_outer_extensions=1 avg_outer_extensions=1.800
density_all=0.360000 density_realized_outer=0.360000
worst_outer_states=((0, 0, 1), (0, 1, 2))
types=IIOOIO
faces=6 outer=3 inner=3 outer_orbits=5 inner_orbits=5
realized_outer=5 realized_inner=5 relation=9
blocked_outer=0 (0.000) blocked_inner=0 (0.000)
min_outer_extensions=1 avg_outer_extensions=1.800
density_all=0.360000 density_realized_outer=0.360000
worst_outer_states=((0, 1, 1), (0, 1, 2))
most restrictive by avg_outer_extensions
types=IIOOOO
faces=6 outer=4 inner=2 outer_orbits=14 inner_orbits=2
realized_outer=8 realized_inner=2 relation=8
blocked_outer=6 (0.429) blocked_inner=0 (0.000)
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.285714 density_realized_outer=0.500000
types=IOIOOO
faces=6 outer=4 inner=2 outer_orbits=14 inner_orbits=2
realized_outer=9 realized_inner=2 relation=9
blocked_outer=5 (0.357) blocked_inner=0 (0.000)
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.321429 density_realized_outer=0.500000
types=IOOIOO
faces=6 outer=4 inner=2 outer_orbits=14 inner_orbits=2
realized_outer=9 realized_inner=2 relation=9
blocked_outer=5 (0.357) blocked_inner=0 (0.000)
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.321429 density_realized_outer=0.500000
most restrictive by relation_density_all
types=IIIIIIIIIO
faces=10 outer=1 inner=9 outer_orbits=1 inner_orbits=3281
realized_outer=1 realized_inner=171 relation=171
blocked_outer=0 (0.000) blocked_inner=3110 (0.948)
min_outer_extensions=171 avg_outer_extensions=171.000
density_all=0.052118 density_realized_outer=0.052118
```
## Random Sweep Through 14 Faces
Command:
```bash
python3 papers/medial_tire_decompositions_of_plane_triangulations/experiments/medial_tire_restriction_search.py --samples 5000 --max-faces 14 --limit 3
```
Result:
```text
graphs analyzed: 3227
most restrictive by min_outer_extensions
types=IIIOOO
min_outer_extensions=1 avg_outer_extensions=2.500
density_all=0.400000 density_realized_outer=0.500000
types=IIOIOO
min_outer_extensions=1 avg_outer_extensions=1.800
density_all=0.360000 density_realized_outer=0.360000
types=IIOOIO
min_outer_extensions=1 avg_outer_extensions=1.800
density_all=0.360000 density_realized_outer=0.360000
most restrictive by avg_outer_extensions
types=IIOOOO
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.285714 density_realized_outer=0.500000
types=IOIOOO
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.321429 density_realized_outer=0.500000
types=IOOIOO
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.321429 density_realized_outer=0.500000
most restrictive by relation_density_all
types=IIIOIIIIIIIIII
faces=14 outer=1 inner=13 relation=2731
min_outer_extensions=2731 avg_outer_extensions=2731.000
density_all=0.010278 density_realized_outer=0.010278
types=OOOOOOOIOOOOOO
faces=14 outer=13 inner=1 relation=2731
min_outer_extensions=1 avg_outer_extensions=1.000
density_all=0.010278 density_realized_outer=1.000000
types=IIIOOIIIIIIIII
faces=14 outer=2 inner=12 relation=2048
min_outer_extensions=683 avg_outer_extensions=1024.000
density_all=0.011561 density_realized_outer=0.011561
```
## Interpretation
The most restrictive small examples for extension counts appear at six
annular faces. Alternating or clustered `O/I` boundary-face patterns
with a small boundary on one side can force each realized outer state
to a single inner orbit.
The raw density metric favors highly unbalanced boundaries, such as one
outer boundary state position and many inner positions. This may be a
less useful notion of "worst case" unless boundary sizes are fixed or
the density is normalized within a fixed `(outer, inner)` size class.
The blocked-state metrics make the asymmetry explicit. For instance,
`IIIOOO` blocks one of the five possible outer state orbits, while
`IIOIOO` and `IIOOIO` block none but still contain outer states with a
unique compatible inner state orbit. Highly unbalanced cases can block
most state orbits on the larger boundary simply because the annular
cycle has far fewer colorings than the unconstrained ordered boundary.
@@ -0,0 +1,294 @@
"""Search boundary-state restrictions for possible full medial tire graphs.
Model.
A full medial tire graph has an annular medial cycle A_0,...,A_{n-1}.
Each annular face contributes one boundary-medial vertex B_i adjacent
to A_i and A_{i+1}; B_i is tagged as outer or inner. This is the
ambient tread-face model from the medial tire decomposition paper.
Boundary order is the cyclic order inherited from the annular face
sequence. Boundary states are quotient by S_3 color relabeling only:
no rotations or reversals are identified.
"""
from __future__ import annotations
import argparse
import itertools
import random
from collections import defaultdict
from dataclasses import dataclass
from functools import lru_cache
Coloring = tuple[int, ...]
def canonical_color_orbit(coloring: Coloring) -> Coloring:
"""Canonical representative modulo color renaming, preserving order."""
mapping: dict[int, int] = {}
out = []
for color in coloring:
if color not in mapping:
mapping[color] = len(mapping)
out.append(mapping[color])
return tuple(out)
@lru_cache(maxsize=None)
def all_state_orbits(length: int) -> frozenset[Coloring]:
return frozenset({
canonical_color_orbit(tuple(colors))
for colors in itertools.product(range(3), repeat=length)
})
@lru_cache(maxsize=None)
def cycle_3_colorings(n: int) -> list[Coloring]:
colors = [-1] * n
out: list[Coloring] = []
def rec(idx: int) -> None:
if idx == n:
if colors[-1] != colors[0]:
out.append(tuple(colors))
return
for color in range(3):
if idx > 0 and colors[idx - 1] == color:
continue
if idx == n - 1 and colors[0] == color:
continue
colors[idx] = color
rec(idx + 1)
colors[idx] = -1
rec(0)
return out
def boundary_coloring_from_annular(
annular_coloring: Coloring,
face_types: str,
boundary_type: str,
) -> Coloring:
values = []
n = len(face_types)
for idx, face_type in enumerate(face_types):
if face_type != boundary_type:
continue
left = annular_coloring[idx]
right = annular_coloring[(idx + 1) % n]
values.append(({0, 1, 2} - {left, right}).pop())
return tuple(values)
@dataclass(frozen=True)
class RestrictionMetrics:
face_types: str
n_faces: int
n_outer: int
n_inner: int
outer_orbits: int
inner_orbits: int
realized_outer_orbits: int
realized_inner_orbits: int
blocked_outer_orbits: int
blocked_inner_orbits: int
blocked_outer_fraction: float
blocked_inner_fraction: float
relation_size: int
min_outer_extensions: int
avg_outer_extensions: float
relation_density_all: float
relation_density_realized_outer: float
worst_outer_states: tuple[Coloring, ...]
@lru_cache(maxsize=None)
def analyze_face_types(face_types: str) -> RestrictionMetrics:
n = len(face_types)
n_outer = face_types.count("O")
n_inner = face_types.count("I")
possible_outer = all_state_orbits(n_outer)
possible_inner = all_state_orbits(n_inner)
relation: set[tuple[Coloring, Coloring]] = set()
by_outer: dict[Coloring, set[Coloring]] = defaultdict(set)
for annular in cycle_3_colorings(n):
outer = canonical_color_orbit(
boundary_coloring_from_annular(annular, face_types, "O")
)
inner = canonical_color_orbit(
boundary_coloring_from_annular(annular, face_types, "I")
)
relation.add((outer, inner))
by_outer[outer].add(inner)
realized_outer = set(by_outer)
realized_inner = {inner for _, inner in relation}
extension_counts = {outer: len(inners) for outer, inners in by_outer.items()}
min_extensions = min(extension_counts.values()) if extension_counts else 0
worst_outer = tuple(
sorted(outer for outer, count in extension_counts.items() if count == min_extensions)
)
avg_extensions = (
sum(extension_counts.values()) / len(extension_counts)
if extension_counts
else 0.0
)
all_denominator = len(possible_outer) * len(possible_inner)
realized_denominator = len(realized_outer) * len(possible_inner)
return RestrictionMetrics(
face_types=face_types,
n_faces=n,
n_outer=n_outer,
n_inner=n_inner,
outer_orbits=len(possible_outer),
inner_orbits=len(possible_inner),
realized_outer_orbits=len(realized_outer),
realized_inner_orbits=len(realized_inner),
blocked_outer_orbits=len(possible_outer) - len(realized_outer),
blocked_inner_orbits=len(possible_inner) - len(realized_inner),
blocked_outer_fraction=(
(len(possible_outer) - len(realized_outer)) / len(possible_outer)
if possible_outer
else 0.0
),
blocked_inner_fraction=(
(len(possible_inner) - len(realized_inner)) / len(possible_inner)
if possible_inner
else 0.0
),
relation_size=len(relation),
min_outer_extensions=min_extensions,
avg_outer_extensions=avg_extensions,
relation_density_all=len(relation) / all_denominator if all_denominator else 0.0,
relation_density_realized_outer=(
len(relation) / realized_denominator if realized_denominator else 0.0
),
worst_outer_states=worst_outer[:5],
)
def random_face_types(rng: random.Random, n: int, min_outer: int, min_inner: int) -> str:
if min_outer + min_inner > n:
raise ValueError("minimum outer/inner counts exceed n")
values = ["O"] * min_outer + ["I"] * min_inner
values.extend(rng.choice("OI") for _ in range(n - len(values)))
rng.shuffle(values)
return "".join(values)
def all_face_types(n: int, min_outer: int, min_inner: int):
for values in itertools.product("OI", repeat=n):
face_types = "".join(values)
if face_types.count("O") >= min_outer and face_types.count("I") >= min_inner:
yield face_types
def best_by(metrics: list[RestrictionMetrics], key_name: str, limit: int):
return sorted(metrics, key=lambda m: (getattr(m, key_name), m.n_faces, m.face_types))[
:limit
]
def print_metric(metric: RestrictionMetrics) -> None:
print(f" types={metric.face_types}")
print(
f" faces={metric.n_faces} outer={metric.n_outer} inner={metric.n_inner} "
f"outer_orbits={metric.outer_orbits} inner_orbits={metric.inner_orbits}"
)
print(
f" realized_outer={metric.realized_outer_orbits} "
f"realized_inner={metric.realized_inner_orbits} relation={metric.relation_size}"
)
print(
f" blocked_outer={metric.blocked_outer_orbits} "
f"({metric.blocked_outer_fraction:.3f}) "
f"blocked_inner={metric.blocked_inner_orbits} "
f"({metric.blocked_inner_fraction:.3f})"
)
print(
f" min_outer_extensions={metric.min_outer_extensions} "
f"avg_outer_extensions={metric.avg_outer_extensions:.3f}"
)
print(
f" density_all={metric.relation_density_all:.6f} "
f"density_realized_outer={metric.relation_density_realized_outer:.6f}"
)
print(f" worst_outer_states={metric.worst_outer_states}")
def run(args: argparse.Namespace) -> None:
rng = random.Random(args.seed)
seen: set[str] = set()
metrics: list[RestrictionMetrics] = []
if args.exhaustive:
for n in range(args.min_faces, args.max_faces + 1):
for face_types in all_face_types(n, args.min_outer, args.min_inner):
metrics.append(analyze_face_types(face_types))
else:
for _ in range(args.samples):
n = rng.randint(args.min_faces, args.max_faces)
face_types = random_face_types(rng, n, args.min_outer, args.min_inner)
if face_types in seen:
continue
seen.add(face_types)
metrics.append(analyze_face_types(face_types))
print(f"graphs analyzed: {len(metrics)}")
print(
"boundary states quotient by color permutations only; "
"no rotations or reversals"
)
print()
print("most restrictive by min_outer_extensions")
for metric in best_by(metrics, "min_outer_extensions", args.limit):
print_metric(metric)
print()
print("most restrictive by avg_outer_extensions")
for metric in best_by(metrics, "avg_outer_extensions", args.limit):
print_metric(metric)
print()
print("most restrictive by relation_density_all")
for metric in best_by(metrics, "relation_density_all", args.limit):
print_metric(metric)
print()
print("most restrictive by blocked_outer_orbits")
for metric in sorted(
metrics,
key=lambda m: (-m.blocked_outer_orbits, m.n_faces, m.face_types),
)[: args.limit]:
print_metric(metric)
print()
print("most restrictive by blocked_outer_fraction")
for metric in sorted(
metrics,
key=lambda m: (-m.blocked_outer_fraction, m.n_faces, m.face_types),
)[: args.limit]:
print_metric(metric)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--min-faces", type=int, default=6)
parser.add_argument("--max-faces", type=int, default=10)
parser.add_argument("--samples", type=int, default=1000)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--min-outer", type=int, default=1)
parser.add_argument("--min-inner", type=int, default=1)
parser.add_argument("--limit", type=int, default=5)
parser.add_argument("--exhaustive", action="store_true")
run(parser.parse_args())
if __name__ == "__main__":
main()