From 57f5c2839aa67924236b83bd3497fd514e38a738 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 2 Jun 2026 10:28:30 -0400 Subject: [PATCH] Canonicalize tire symmetry and exhaust n=6 level-cycle supports Quotient (m, k, path, chords, cycle) by the order-2(m+k) dihedral action on the rung sequence; exhaustive sweep over outer 3..7, inner 7..9 yields 19 distinct supports with a unique floor at 252/732 realised by m=3, k=7, single-chord 6-face. Co-Authored-By: Claude Opus 4.7 --- .../experiments/level_cycle_support.py | 429 ++++++++++++++++++ .../level_cycle_support_findings.md | 241 ++++++++++ 2 files changed, 670 insertions(+) create mode 100644 papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py create mode 100644 papers/coloring_nested_tire_graphs/experiments/level_cycle_support_findings.md diff --git a/papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py b/papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py new file mode 100644 index 0000000..3f567d8 --- /dev/null +++ b/papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py @@ -0,0 +1,429 @@ +"""Support on genuine level-cycle candidates inside a tire. + +Here a tested cycle is not an arbitrary tire boundary. It must be a +bounded face cycle of the inner outerplanar graph O, distinct from +O's outer-face boundary. This models a level/seam cycle as it appears +inside the parent's next-level graph, rather than treating it as an +outer boundary of a tire at the same level. + +For each such cycle C, the support is the set of proper 4-colorings of +C that extend to a proper 4-coloring of the parent tire graph. +""" +from __future__ import annotations + +import argparse +from functools import lru_cache +from itertools import combinations, permutations, product +from math import comb + +COLORS = (0, 1, 2, 3) + + +def canonical_edge(u: int, v: int) -> tuple[int, int]: + return (u, v) if u < v else (v, u) + + +def cycle_edges(vertices: list[int]) -> set[tuple[int, int]]: + return { + canonical_edge(vertices[i], vertices[(i + 1) % len(vertices)]) + for i in range(len(vertices)) + } + + +def chord_crosses(c1: tuple[int, int], c2: tuple[int, int]) -> bool: + a, b = sorted(c1) + c, d = sorted(c2) + return (a < c < b < d) or (c < a < d < b) + + +def noncrossing_chord_sets(k: int, max_chords: int | None = None): + candidates = [ + (a, b) + for a in range(k) + for b in range(a + 2, k) + if not (a == 0 and b == k - 1) + ] + out = [()] + + def rec(start: int, chosen: tuple[tuple[int, int], ...]) -> None: + if max_chords is not None and len(chosen) >= max_chords: + return + for idx in range(start, len(candidates)): + ch = candidates[idx] + if any(chord_crosses(ch, old) for old in chosen): + continue + nxt = chosen + (ch,) + out.append(nxt) + rec(idx + 1, nxt) + + rec(0, ()) + return tuple(sorted(set(out), key=lambda cs: (len(cs), cs))) + + +def lattice_paths(m: int, k: int): + n = m + k + for outer_positions in combinations(range(n), m): + outer_positions = set(outer_positions) + yield "".join("O" if i in outer_positions else "I" for i in range(n)) + + +def tire_edges(m: int, k: int, path: str, chords: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]: + outer = list(range(m)) + inner = list(range(m, m + k)) + edges = set() + edges |= cycle_edges(outer) + edges |= cycle_edges(inner) + for a, b in chords: + edges.add(canonical_edge(inner[a], inner[b])) + + edges.add(canonical_edge(outer[0], inner[0])) + i = j = 0 + for move in path: + if move == "O": + edges.add(canonical_edge(outer[(i + 1) % m], inner[j % k])) + i += 1 + else: + edges.add(canonical_edge(outer[i % m], inner[(j + 1) % k])) + j += 1 + return tuple(sorted(edges)) + + +def proper_cycle_colorings(n: int) -> tuple[tuple[int, ...], ...]: + out = [] + for colors in product(COLORS, repeat=n): + if all(colors[i] != colors[(i + 1) % n] for i in range(n)): + out.append(colors) + return tuple(out) + + +@lru_cache(maxsize=None) +def boundary_support( + n_vertices: int, + edges: tuple[tuple[int, int], ...], + boundary_vertices: tuple[int, ...], +) -> frozenset[tuple[int, ...]]: + assigned = {0: 0} + adj = {v: set() for v in range(n_vertices)} + for u, v in edges: + adj[u].add(v) + adj[v].add(u) + + remaining = [v for v in range(n_vertices) if v not in assigned] + remaining.sort(key=lambda v: (-len(adj[v]), v)) + support = set() + + def dfs(index: int) -> None: + if index == len(remaining): + support.add(tuple(assigned[v] for v in boundary_vertices)) + return + best_at = index + best_options = None + for pos in range(index, len(remaining)): + v = remaining[pos] + used = {assigned[w] for w in adj[v] if w in assigned} + options = tuple(c for c in COLORS if c not in used) + if best_options is None or len(options) < len(best_options): + best_at = pos + best_options = options + if len(options) <= 1: + break + if not best_options: + return + + remaining[index], remaining[best_at] = remaining[best_at], remaining[index] + v = remaining[index] + for color in best_options: + assigned[v] = color + dfs(index + 1) + assigned.pop(v) + remaining[index], remaining[best_at] = remaining[best_at], remaining[index] + + dfs(0) + return frozenset(support) + + +def rotate_tuple(values: tuple[int, ...], shift: int) -> tuple[int, ...]: + n = len(values) + return tuple(values[(i + shift) % n] for i in range(n)) + + +def normalize_support(support: frozenset[tuple[int, ...]]) -> frozenset[tuple[int, ...]]: + out = set() + for state in support: + for perm in permutations(COLORS): + relabeled = tuple(perm[c] for c in state) + for seq in (relabeled, relabeled[::-1]): + for shift in range(len(seq)): + out.add(rotate_tuple(seq, shift)) + return frozenset(out) + + +def describe_chords(chords: tuple[tuple[int, int], ...]) -> str: + return ",".join(f"{a}-{b}" for a, b in chords) if chords else "none" + + +def canonicalize_tire_cycle( + m: int, + k: int, + path: str, + chords: tuple[tuple[int, int], ...], + cycle: tuple[int, ...], +) -> tuple[str, tuple[tuple[int, int], ...], tuple[int, ...]]: + """Canonical form of (tire, level cycle) under tire symmetries. + + The tire has dihedral symmetry of order 2(m+k) on the rung sequence: + cyclic shift s = "start at rung s of the original path", and reflection + = "traverse in reverse" (the path string reverses, inner cycle direction + flips so chord (a,b) becomes ((k-a) mod k, (k-b) mod k)). + + The level cycle is transported by the same rigid relabelling and then + normalized under its own D_n (the support is invariant under D_n on the + cycle's vertex sequence, so this stage is part of the canonical form). + """ + n_rung = m + k + n_cycle = len(cycle) + best = None + for reverse in (False, True): + if reverse: + p_base = path[::-1] + cs_base = tuple( + canonical_edge((k - a) % k, (k - b) % k) for a, b in chords + ) + c_base = tuple((k - v) % k for v in reversed(cycle)) + else: + p_base = path + cs_base = chords + c_base = cycle + + prefix_i = [0] + for ch in p_base: + prefix_i.append(prefix_i[-1] + (1 if ch == "I" else 0)) + + for s in range(n_rung): + b_s = prefix_i[s] + shifted = p_base[s:] + p_base[:s] + new_cs = tuple( + sorted( + canonical_edge((a - b_s) % k, (b - b_s) % k) + for a, b in cs_base + ) + ) + new_c = tuple((v - b_s) % k for v in c_base) + norm_c = min( + tuple(orient[(r + i) % n_cycle] for i in range(n_cycle)) + for orient in (new_c, new_c[::-1]) + for r in range(n_cycle) + ) + cand = (shifted, new_cs, norm_c) + if best is None or cand < best: + best = cand + return best + + +def interval_vertices(vertices: tuple[int, ...], a: int, b: int) -> tuple[int, ...]: + ia = vertices.index(a) + out = [a] + i = ia + while vertices[i] != b: + i = (i + 1) % len(vertices) + out.append(vertices[i]) + return tuple(out) + + +def chord_inside(poly: tuple[int, ...], chord: tuple[int, int]) -> bool: + a, b = chord + return a in poly and b in poly + + +def polygon_faces(poly: tuple[int, ...], chords: tuple[tuple[int, int], ...]) -> list[tuple[int, ...]]: + """Cells inside a polygon cut by noncrossing chords.""" + usable = [ch for ch in chords if chord_inside(poly, ch)] + split = None + for a, b in usable: + if a not in poly or b not in poly: + continue + ia, ib = poly.index(a), poly.index(b) + distance = abs(ia - ib) + if distance in (1, len(poly) - 1): + continue + split = (a, b) + break + + if split is None: + return [poly] + + a, b = split + side1 = interval_vertices(poly, a, b) + side2 = interval_vertices(poly, b, a) + rest = tuple(ch for ch in usable if ch != split) + return polygon_faces(side1, rest) + polygon_faces(side2, rest) + + +def level_cycles_in_o(k: int, chords: tuple[tuple[int, int], ...]) -> tuple[tuple[int, ...], ...]: + """Bounded face cycles of O that are not the outer boundary of O.""" + if not chords: + return () + outer = tuple(range(k)) + faces = polygon_faces(outer, chords) + out = [] + for face in faces: + if len(face) == k and set(face) == set(outer): + continue + out.append(face) + return tuple(out) + + +def enumerate_level_cycle_supports( + n: int, + outer_min: int, + outer_max: int, + inner_min: int, + inner_max: int, + max_chords: int | None, + max_paths: int | None, + canonicalize: bool = True, + progress: bool = False, +) -> tuple[dict[frozenset[tuple[int, ...]], list[dict]], dict]: + supports: dict[frozenset[tuple[int, ...]], list[dict]] = {} + seen_canon: set = set() + stats = {"raw": 0, "canonical": 0} + for m in range(outer_min, outer_max + 1): + for k in range(max(inner_min, n), inner_max + 1): + for chords in noncrossing_chord_sets(k, max_chords): + level_cycles = [cycle for cycle in level_cycles_in_o(k, chords) if len(cycle) == n] + if not level_cycles: + continue + for path_idx, path in enumerate(lattice_paths(m, k)): + if max_paths is not None and path_idx >= max_paths: + break + edges = None + for cycle in level_cycles: + stats["raw"] += 1 + if canonicalize: + canon = canonicalize_tire_cycle(m, k, path, chords, cycle) + if canon in seen_canon: + continue + seen_canon.add(canon) + stats["canonical"] += 1 + if edges is None: + edges = tire_edges(m, k, path, chords) + boundary = tuple(m + v for v in cycle) + support = normalize_support(boundary_support(m + k, edges, boundary)) + meta = { + "m": m, + "k": k, + "path": path, + "chords": chords, + "cycle": cycle, + } + supports.setdefault(support, []).append(meta) + if progress: + print( + f" m={m} k={k} chords={describe_chords(chords)} " + f"raw={stats['raw']} canonical={stats['canonical']} " + f"supports={len(supports)}", + flush=True, + ) + return supports, stats + + +def summarize( + n: int, + supports: dict[frozenset[tuple[int, ...]], list[dict]], + examples: int, + stats: dict | None = None, +) -> None: + all_colorings = len(proper_cycle_colorings(n)) + if not supports: + print() + print(f"n={n}: no admissible level-cycle candidates in search window") + return + + min_size = min(len(support) for support in supports) + max_size = max(len(support) for support in supports) + min_supports = [support for support in supports if len(support) == min_size] + pair_overlaps = [ + len(a & b) + for i, a in enumerate(min_supports) + for b in min_supports[i:] + ] + print() + print(f"n={n}") + if stats: + print(f" raw (tire,cycle) combos : {stats['raw']}") + print(f" canonical (tire,cycle) classes: {stats['canonical']}") + print(f" proper C_n colorings : {all_colorings}") + print(f" distinct level-cycle supports : {len(supports)}") + print(f" most restrictive support : {min_size}/{all_colorings} ({len(min_supports)} support types)") + print(f" least restrictive support : {max_size}/{all_colorings}") + print(f" overlap among max-restrict : min {min(pair_overlaps)}, max {max(pair_overlaps)}") + print(" examples:") + + printed = 0 + for support, metas in sorted(supports.items(), key=lambda item: (len(item[0]), len(item[1]))): + if len(support) != min_size: + continue + for meta in metas[:examples]: + print( + f" size={len(support)} m={meta['m']} k={meta['k']} " + f"path={meta['path']} chords={describe_chords(meta['chords'])} " + f"cycle={meta['cycle']}" + ) + printed += 1 + if printed >= examples: + return + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--n-min", type=int, default=3) + parser.add_argument("--n-max", type=int, default=7) + parser.add_argument("--outer-min", type=int, default=3) + parser.add_argument("--outer-max", type=int, default=7) + parser.add_argument("--inner-min", type=int, default=3) + parser.add_argument("--inner-max", type=int, default=9) + parser.add_argument("--max-chords", type=int, default=None) + parser.add_argument("--max-paths", type=int, default=None) + parser.add_argument("--examples", type=int, default=4) + parser.add_argument( + "--no-canonicalize", + action="store_true", + help="disable tire automorphism deduplication", + ) + parser.add_argument( + "--progress", + action="store_true", + help="emit a one-line update after each (m, k, chord set)", + ) + args = parser.parse_args() + + print("Level-cycle support inside parent tire inner graph O") + print(" tested cycles: bounded face cycles of O, excluding O's outer boundary") + print(f" outer length range : {args.outer_min}..{args.outer_max}") + print(f" inner O size range : {args.inner_min}..{args.inner_max}") + print(f" max chords : {args.max_chords if args.max_chords is not None else 'all'}") + print(f" max paths/type : {args.max_paths if args.max_paths is not None else 'all'}") + + for n in range(args.n_min, args.n_max + 1): + rough_paths = sum( + comb(m + k, m) + for m in range(args.outer_min, args.outer_max + 1) + for k in range(max(args.inner_min, n), args.inner_max + 1) + ) + print(f"computing n={n} (rough path count before chords: {rough_paths})") + supports, stats = enumerate_level_cycle_supports( + n, + args.outer_min, + args.outer_max, + args.inner_min, + args.inner_max, + args.max_chords, + args.max_paths, + canonicalize=not args.no_canonicalize, + progress=args.progress, + ) + summarize(n, supports, args.examples, stats) + + +if __name__ == "__main__": + main() diff --git a/papers/coloring_nested_tire_graphs/experiments/level_cycle_support_findings.md b/papers/coloring_nested_tire_graphs/experiments/level_cycle_support_findings.md new file mode 100644 index 0000000..e54e9fe --- /dev/null +++ b/papers/coloring_nested_tire_graphs/experiments/level_cycle_support_findings.md @@ -0,0 +1,241 @@ +# Level-Cycle Support Findings + +This is the corrected version of the boundary-overlap test. + +The key modeling change is that a tested cycle must appear in the +level-cycle/seam role: as a bounded face cycle inside the parent +tire's inner outerplanar graph `O`, not as an arbitrary `B_out` or as +the outer-face boundary of `O`. + +Executable: + +```bash +python3 papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py +``` + +## Model + +For a parent tire `T = (B_out, O, E_ann)`, enumerate simple bounded +face cycles of `O`. + +A cycle is admissible only if: + +- it is a bounded face cycle of `O`; +- it is distinct from the outer-face boundary of `O`; +- it is tested in this inner/next-level role, never as an outer + boundary of a tire at the same level. + +For each admissible cycle `C`, compute the set of proper 4-colorings +of `C` that extend to a proper 4-coloring of the parent tire graph. +Supports are normalized under color relabeling and dihedral symmetries +of `C`. + +## Capped Validation Run + +Command: + +```bash +python3 -u papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py \ + --n-min 3 --n-max 6 --outer-max 5 --inner-max 7 --max-chords 2 --max-paths 30 +``` + +Result: + +```text +n=3 + proper C_n colorings : 24 + distinct level-cycle supports : 1 + most restrictive support : 24/24 + overlap among max-restrict : 24 + +n=4 + proper C_n colorings : 84 + distinct level-cycle supports : 3 + most restrictive support : 60/84 + overlap among max-restrict : 60 + +n=5 + proper C_n colorings : 240 + distinct level-cycle supports : 2 + most restrictive support : 120/240 + overlap among max-restrict : 120 + +n=6 + proper C_n colorings : 732 + distinct level-cycle supports : 13 + most restrictive support : 252/732 + overlap among max-restrict : 252 +``` + +The most restrictive admissible `n=4` level-cycle support found here +has size `60/84`, and there is only one worst support type in this +capped window. + +Representative `n=4` admissible witness: + +```text +size=60 +m=3, k=5 +path=OOOIIIII +chords=0-2 +cycle=(2, 3, 4, 0) +``` + +Here `O` is a 5-cycle with chord `0-2`. The tested 4-cycle is the +bounded face cycle `(2,3,4,0)`, not the outer-face boundary of `O`. + +## Current Interpretation + +With level-cycle admissibility enforced, the first-pass data no longer +supports a zero-overlap obstruction at `n=4`. In the capped search, +the maximum-restriction support type is unique for `n=3,4,5,6`, so +overlap among maximum restrictions is trivially the whole worst +support. + +This is not yet a proof. The exact all-path `n=4` run with +`--max-chords 2` was stopped after it ran too long interactively. The +next implementation step should canonicalize outerplanar `O` and +annular paths, or switch to a transfer/DP support computation, before +claiming exhaustive results beyond small capped windows. + +## Canonical Tire Quotient + +The script now quotients out the dihedral symmetry of the tire on the +rung sequence. Concretely, a tire `T = (m, k, path, chords)` together +with a chosen level cycle `C` admits an order-`2(m+k)` action: + +- cyclic shift `s`: "start counting from rung `s` of the original path". + This rotates the inner labelling by `-b_s mod k`, where `b_s` is the + number of inner steps in the first `s` moves; chords and `C` are + carried by the same shift. +- reflection: traverse the tire in the opposite direction. The path + string reverses and each inner label `j` flips to `(k - j) mod k`, + taking chord `(a, b)` to `((k - a) mod k, (k - b) mod k)` and reversing + `C`. + +The canonical form of `(T, C)` is the lex-min `(path, chords, cycle)` +under that action, with `C` itself further minimised under its own +`D_n` (which the support already quotients out, so this stage is free). + +Two `(T, C)` pairs in the same canonical class produce identical +normalised supports, so we compute the support once per class. Sanity +checks at `n = 3, 4, 5` reproduce the prior capped support sizes +(`24/24`, `60/84`, `120/240`) exactly while reducing raw work by +roughly 20×–25×. + +## Exhaustive `n=6` Run + +With the canonical quotient in place we can now exhaust a meaningful +window for `n = 6`. The smallest admissible inner ring is `k = 7` +(a `k`-cycle plus chords cannot host a non-outer bounded `6`-face for +`k < 7`). + +Command: + +```bash +python3 -u papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py \ + --n-min 6 --n-max 6 --outer-min 3 --outer-max 6 --inner-min 7 --inner-max 8 \ + --progress --examples 6 +``` + +Result: + +```text +n=6 + raw (tire,cycle) combos : 238506 + canonical (tire,cycle) classes: 9144 + proper C_6 colorings : 732 + distinct level-cycle supports : 17 + most restrictive support : 252/732 (1 support type) + least restrictive support : 732/732 + overlap among max-restrict : 252 +``` + +Compared to the earlier `--outer-max 5 --inner-max 7 --max-chords 2 +--max-paths 30` capped run, the distinct-support count climbs from +`13` to `17`, but the **minimum support size is unchanged at `252/732` +and is still realised by a unique support type**. The minimum is +witnessed by the same minimal-`O` configuration that already showed up +at smaller `n`: + +```text +size=252 +m=3, k=7 +chords=0-2 +cycle=(2, 3, 4, 5, 6, 0) +``` + +i.e. the bounded `6`-face of a `7`-cycle with one chord, sitting inside +the thinnest annulus `(m=3)`. Adding outer length (`m` up to `6`), +inner length (`k` up to `8`), and unrestricted chord sets produces +strictly *less* restrictive supports — never a new floor. + +### Wider sweep: `outer 3..7, inner 7..9` + +Command: + +```bash +python3 -u papers/coloring_nested_tire_graphs/experiments/level_cycle_support.py \ + --n-min 6 --n-max 6 --outer-min 3 --outer-max 7 --inner-min 7 --inner-max 9 \ + --progress --examples 6 +``` + +Result: + +```text +n=6 + raw (tire,cycle) combos : 5662518 + canonical (tire,cycle) classes: 186818 + proper C_6 colorings : 732 + distinct level-cycle supports : 19 + most restrictive support : 252/732 (1 support type) + least restrictive support : 732/732 + overlap among max-restrict : 252 +``` + +Adding `k = 9` and `m = 7` brings 2 new distinct supports (`17 → 19`), +**both strictly above the floor**. The most restrictive support is +**still `252/732`, still unique, and still realised by the same +minimal-`O` witness** (`m=3, k=7, chord=0-2, cycle=(2,3,4,5,6,0)`). +The `m = 3` configurations dominated the floor in every (`m`, `k`) +sweep examined. + +### What this changes + +- The "most restrictive support is unique" claim for `n = 6` is now + backed by an exhaustive (not capped) sweep over `m ∈ [3, 7]`, + `k ∈ [7, 9]`, all chord sets, all annular paths. +- The earlier-feared zero-overlap obstruction at `n = 4` does not + reappear at `n = 6` either: overlap among maximum-restriction + supports is trivially the whole worst support, because there is only + one worst support. +- The witness for the floor is structurally the same across + `n = 3, 4, 5, 6`: a single chord on a `(n+1)`-cycle, embedded in the + thinnest annulus, with the tested cycle taken as the bounded + `n`-face. That stability is suggestive (it hints that the worst + level-cycle support is governed by a small canonical configuration) + but is not yet a theorem. + +### Floor-stability-in-`m` conjecture + +Across every searched `n`, the floor witness has been `m = 3`. This +is consistent with a **support-monotonicity** picture: subdividing any +outer or annular edge of an `m = 3` floor witness produces an `m = 4` +tire whose support strictly contains the original (the new degree-`2` +vertex adds coloring freedom, and the subdivided edge's endpoints are +no longer adjacent, so they may share a color). By induction the +floor over `m ≥ 3` is bounded above by the `m = 3` floor. + +The empirical run confirms this bound is tight up to `m ≤ 7, k ≤ 9` at +`n = 6`: no `m ≥ 4` tire produced a support strictly smaller than the +`m = 3` floor. A theorem-style statement would be: + +> **(Conjecture)** For every `n`, every `k ≥ n + 1`, and every chord +> set on `C_k` containing an `n`-face, the level-cycle support floor +> over all annular paths and all `m ≥ 3` is achieved at `m = 3`. + +If true, this collapses the exhaustive search to `m = 3` only and +opens the door to closed-form support computation for the worst case. +The obstruction to a quick proof is exotic `m ≥ 4` configurations not +reachable by subdivision from an `m = 3` floor witness — none has +appeared empirically, but their non-existence is not yet ruled out.