Canonical orbit search for symmetric pruning masks / canonical_orbit.py

Checking mechanism…

Raw ⬇ ZIP
  1#!/usr/bin/env python3
  2"""Canonical orbit search for symmetric subset masks.
  3
  4Indices are zero based.  A permutation is a tuple p with p[i] the image of i.
  5"""
  6import itertools, json, math, time
  7from collections import Counter
  8
  9
 10def lex_key(s):
 11    return tuple(s)
 12
 13
 14def apply_perm(s, p):
 15    return tuple(sorted(p[i] for i in s))
 16
 17
 18def is_canonical(s, group):
 19    s = tuple(sorted(s))
 20    return all(apply_perm(s, p) >= s for p in group)
 21
 22
 23def canonical(s, group):
 24    return min(apply_perm(s, p) for p in group)
 25
 26
 27def all_permutations(n):
 28    return list(itertools.permutations(range(n)))
 29
 30
 31def compose(p, q):
 32    return tuple(p[q[i]] for i in range(len(p)))
 33
 34
 35def group_generated(n, generators):
 36    """Small closure by BFS; useful for non-full symmetry groups."""
 37    identity = tuple(range(n))
 38    seen = {identity}
 39    todo = [identity]
 40    while todo:
 41        a = todo.pop()
 42        for b in generators:
 43            c = compose(a, b)
 44            if c not in seen:
 45                seen.add(c); todo.append(c)
 46    return sorted(seen)
 47
 48
 49def product_block_group(blocks):
 50    """Direct product of full symmetric groups on disjoint blocks."""
 51    n = sum(len(b) for b in blocks)
 52    choices = [list(itertools.permutations(b)) for b in blocks]
 53    out = []
 54    for parts in itertools.product(*choices):
 55        p = list(range(n))
 56        for block, perm in zip(blocks, parts):
 57            for x, y in zip(block, perm): p[x] = y
 58        out.append(tuple(p))
 59    return out
 60
 61
 62def exhaustive(n, k, group):
 63    masks = list(itertools.combinations(range(n), k))
 64    reps = [s for s in masks if is_canonical(s, group)]
 65    return masks, reps
 66
 67
 68def canonical_dfs(n, k, group):
 69    visited = []
 70    calls = 0
 71    def dfs(s):
 72        nonlocal calls
 73        calls += 1
 74        if len(s) == k:
 75            visited.append(tuple(s)); return
 76        start = s[-1] + 1 if s else 0
 77        for i in range(start, n):
 78            t = tuple(s) + (i,)
 79            if is_canonical(t, group):
 80                dfs(t)
 81    dfs(())
 82    return visited, calls
 83
 84
 85def predecessor_check(n, k, group):
 86    reps = [s for s in itertools.combinations(range(n), k) if is_canonical(s, group)]
 87    bad = []
 88    for s in reps:
 89        if s and not is_canonical(s[:-1], group): bad.append((s, s[:-1]))
 90    return len(reps), bad
 91
 92
 93def invariant_score(s, group, seed=7):
 94    """A deterministic score invariant under the supplied group.
 95    Each orbit receives a score; this models exact symmetry of a mask evaluator.
 96    """
 97    # Hash orbit representative, rather than individual mask, makes symmetry exact.
 98    c = canonical(s, group)
 99    x = (seed * 1000003 + sum((j + 1) * (i + 11) for j, i in enumerate(c))) % 10000019
100    return 0.5 + (x / 10000019)
101
102
103def main():
104    # Core smallest useful checks, including a nontrivial block-product symmetry.
105    checks = []
106    groups = [
107        ("S6", all_permutations(6)),
108        ("S3xS3", product_block_group([tuple(range(3)), tuple(range(3, 6))])),
109        ("cyclic6", group_generated(6, [(1,2,3,4,5,0)])),
110    ]
111    for name, g in groups:
112        n = 6
113        for k in range(1, n):
114            reps, bad = predecessor_check(n, k, g)
115            assert not bad, (name, k, bad[:1])
116            masks, exhaustive_reps = exhaustive(n, k, g)
117            dfs_reps, calls = canonical_dfs(n, k, g)
118            assert set(dfs_reps) == set(exhaustive_reps)
119            # Every mask is in exactly one orbit, and reps are orbit count.
120            assert len({canonical(s, g) for s in masks}) == len(exhaustive_reps)
121        checks.append({"group": name, "group_size": len(g), "predecessor_closure": True})
122
123    # Mini experiment: exact symmetry means all masks in an orbit have identical score.
124    n, k = 12, 6
125    g = all_permutations(n)
126    t0 = time.perf_counter()
127    masks = list(itertools.combinations(range(n), k))
128    exhaustive_scores = [invariant_score(s, g) for s in masks]
129    exhaustive_time = time.perf_counter() - t0
130    t0 = time.perf_counter()
131    reps, calls = canonical_dfs(n, k, g)
132    canonical_scores = [invariant_score(s, g) for s in reps]
133    canonical_time = time.perf_counter() - t0
134    best_exhaustive = max(exhaustive_scores)
135    best_canonical = max(canonical_scores)
136    assert len(reps) == 1
137    assert best_exhaustive == best_canonical
138    assert all(invariant_score(s, g) == invariant_score(reps[0], g) for s in masks)
139
140    # A second, less degenerate symmetry pattern gives multiple orbit representatives.
141    gb = product_block_group([tuple(range(6)), tuple(range(6, 12))])
142    mb, rb = exhaustive(n, k, gb)
143    db, block_calls = canonical_dfs(n, k, gb)
144    assert set(rb) == set(db)
145    result = {
146        "math_checks": checks,
147        "mini_experiment": {
148            "n": n, "k": k, "all_masks": len(masks),
149            "exhaustive_representatives": len(set(canonical(s, g) for s in masks)),
150            "canonical_representatives": len(reps),
151            "canonical_dfs_nodes": calls,
152            "exhaustive_seconds": exhaustive_time,
153            "canonical_seconds": canonical_time,
154            "speedup_observed": exhaustive_time / canonical_time,
155            "best_score_exhaustive": best_exhaustive,
156            "best_score_canonical": best_canonical,
157            "best_scores_equal": best_exhaustive == best_canonical,
158        },
159        "block_symmetry": {
160            "group_size": len(gb), "all_masks": len(mb),
161            "orbit_representatives": len(rb), "dfs_representatives": len(db),
162            "dfs_nodes": block_calls,
163        },
164    }
165    print(json.dumps(result, indent=2, sort_keys=True))
166
167if __name__ == "__main__": main()