diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ecb90..dda0ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added +- `CyclicPerm`, for cyclic permutations and cyclic pattern containment and avoidance. A cyclic permutation is stored by its rotation that starts with its minimum. +- `RotationallyStableStrategy`, a fast enumeration strategy for a class whose basis, or a symmetry of it, is closed under rotation (cyclic shifts). It counts the class through its min cut, following the cuts as long as they are rotationally stable. + ### Fixed - Fixed bug in `Perm.rtlmax_ltrmin_decomposition` and fixed associated tests. diff --git a/README.rst b/README.rst index 1277f39..976165d 100644 --- a/README.rst +++ b/README.rst @@ -227,9 +227,29 @@ given class. ... print(strat.reference()) Enumeration of Permutation Classes and Weighted Labelled Independent Sets: Corollary 4.6 The class contains only finitely many simple permutations + >>> basis = [Perm((0, 1, 2, 3)), Perm((1, 2, 3, 0)), Perm((2, 3, 0, 1)), Perm((3, 0, 1, 2))] + >>> for strat in find_strategies(basis): + ... print(strat.reference()) + The class is closed under rotation (cyclic shifts): one in n of its permutations of length n starts with the minimum, and the min cut counts these The output is the name of a paper, followed by the statement in the paper where the enumeration strategy is discussed or stated. +The last basis is closed under rotation, the cyclic shift that moves the first entry +of each pattern to the end. Its min cut, the basis of the permutations p for which +1 + p is in the class, may be closed under rotation again, and the strategy follows +the cuts as far as they go. + +.. code-block:: python + + >>> from permuta.enumeration_strategies.rotationally_stable import ( + ... RotationallyStableStrategy, + ... ) + >>> strategy = RotationallyStableStrategy(basis) + >>> [str(b) for b in strategy.chain()] + ['{0123, 1230, 2301, 3012}', '{012, 2301}'] + >>> [strategy.count(n) for n in range(9)] + [1, 1, 2, 6, 20, 65, 198, 560, 1480] + Permutation statistics ###################### diff --git a/permuta/__init__.py b/permuta/__init__.py index 0dcdb97..2d461c0 100644 --- a/permuta/__init__.py +++ b/permuta/__init__.py @@ -1,3 +1,4 @@ +from .circular import CyclicPerm from .patterns import BivincularPatt, CovincularPatt, MeshPatt, Perm, VincularPatt from .perm_sets.permset import Av, Basis, MeshBasis @@ -12,4 +13,5 @@ "BivincularPatt", "CovincularPatt", "VincularPatt", + "CyclicPerm", ] diff --git a/permuta/circular/__init__.py b/permuta/circular/__init__.py new file mode 100644 index 0000000..09c435d --- /dev/null +++ b/permuta/circular/__init__.py @@ -0,0 +1,3 @@ +from .cyclic_perm import CyclicPerm + +__all__ = ("CyclicPerm",) diff --git a/permuta/circular/cyclic_perm.py b/permuta/circular/cyclic_perm.py new file mode 100644 index 0000000..3af711d --- /dev/null +++ b/permuta/circular/cyclic_perm.py @@ -0,0 +1,214 @@ +import itertools +from typing import Iterable, Iterator, Tuple + +from permuta.patterns.perm import Perm + +__all__ = ("CyclicPerm",) + + +class CyclicPerm: + """A cyclic permutation: the equivalence class of all rotations of a + linear permutation, stored via its unique ``1 ⊕ σ`` representative + (the rotation that places the smallest element first). + """ + + __slots__ = ("_rep",) + _rep: Perm # set in __new__ and _from_canonical; declared for type checkers + + def __new__(cls, iterable: Iterable[int] = ()) -> "CyclicPerm": + """Build a ``CyclicPerm`` from any rotation of the underlying class. + + Examples: + >>> CyclicPerm((3, 1, 2, 4, 0)) + CyclicPerm((0, 3, 1, 2, 4)) + >>> CyclicPerm((0, 3, 1, 2, 4)) == CyclicPerm((4, 0, 3, 1, 2)) + True + >>> CyclicPerm(()) + CyclicPerm(()) + """ + obj = object.__new__(cls) + p = iterable if isinstance(iterable, Perm) else Perm(iterable) + obj._rep = CyclicPerm._canonical(p) + return obj + + @staticmethod + def _canonical(p: Perm) -> Perm: + if len(p) == 0: + return p + return p.shift_left(p.index(0)) + + @classmethod + def _from_canonical(cls, p: Perm) -> "CyclicPerm": + """Fast path when ``p`` already starts with ``0``.""" + obj = object.__new__(cls) + obj._rep = p + return obj + + @classmethod + def from_perm(cls, p: Perm) -> "CyclicPerm": + """Build the cyclic class of a ``Perm``.""" + return cls(p) + + @classmethod + def to_standard(cls, iterable: Iterable) -> "CyclicPerm": + """Build a cyclic permutation by standardizing any iterable of distinct + comparable elements (1-indexed inputs work fine). + + Examples: + >>> CyclicPerm.to_standard("42351") + CyclicPerm((0, 3, 1, 2, 4)) + """ + return cls(Perm.to_standard(iterable)) + + @classmethod + def of_length(cls, n: int) -> Iterator["CyclicPerm"]: + """Yield every cyclic class of length ``n`` exactly once. + + Examples: + >>> sorted(str(c) for c in CyclicPerm.of_length(3)) + ['[012]', '[021]'] + >>> sum(1 for _ in CyclicPerm.of_length(5)) + 24 + """ + if n == 0: + yield cls._from_canonical(Perm(())) + return + if n == 1: + yield cls._from_canonical(Perm((0,))) + return + for sigma in Perm.of_length(n - 1): + yield cls._from_canonical(Perm((0,) + tuple(v + 1 for v in sigma))) + + def representative(self) -> Perm: + """The canonical ``1 ⊕ σ`` linear representative (starts with 0).""" + return self._rep + + def sigma(self) -> Perm: + """The ``σ`` in ``1 ⊕ σ`` as a ``Perm`` of length ``n - 1``.""" + if len(self._rep) <= 1: + return Perm(()) + return Perm(v - 1 for v in itertools.islice(self._rep, 1, None)) + + def rotations(self) -> Iterator[Perm]: + """All ``n`` linear rotations of the representative.""" + rep = self._rep + n = len(rep) + for k in range(n): + yield rep.shift_left(k) + + def reverse(self) -> "CyclicPerm": + """The cyclic class of the reverse of the representative.""" + return CyclicPerm(self._rep.reverse()) + + def complement(self) -> "CyclicPerm": + """The cyclic class of the complement of the representative.""" + return CyclicPerm(self._rep.complement()) + + def reverse_complement(self) -> "CyclicPerm": + """The cyclic class of the reverse-complement of the representative.""" + return CyclicPerm(self._rep.reverse_complement()) + + def contains(self, *patts: "CyclicPerm") -> bool: + """Cyclic containment. + + Examples: + >>> sigma = CyclicPerm((3, 1, 2, 4, 0)) + >>> sigma.contains(CyclicPerm((0, 1, 2, 3))) + True + >>> Perm((3, 1, 2, 4, 0)).avoids(Perm((0, 1, 2, 3))) + True + """ + return all(self._contains_one(p) for p in patts) + + def avoids(self, *patts: "CyclicPerm") -> bool: + """Cyclic avoidance. True iff no cyclic pattern in ``patts`` occurs.""" + return all(not self._contains_one(p) for p in patts) + + def avoids_set(self, patts: Iterable["CyclicPerm"]) -> bool: + """Cyclic avoidance of every cyclic pattern in the iterable ``patts``.""" + return self.avoids(*tuple(patts)) + + def _contains_one(self, patt: "CyclicPerm") -> bool: + rep = self._rep + if len(patt) > len(rep): + return False + if len(patt) == 0: + return True + return any(rep.contains(rot) for rot in patt.rotations()) + + def occurrences_of( + self, patt: "CyclicPerm" + ) -> Iterator[Tuple[int, Tuple[int, ...]]]: + """Yield ``(rotation_index, positions)`` for each linear copy of a + rotation of ``patt`` inside the canonical representative. + """ + rep = self._rep + if len(patt) > len(rep): + return + if len(patt) == 0: + yield (0, ()) + return + for k, rot in enumerate(patt.rotations()): + for positions in rot.occurrences_in(rep): + yield (k, positions) + + def count_occurrences_of(self, patt: "CyclicPerm") -> int: + """The number of occurrences that ``occurrences_of`` yields.""" + return sum(1 for _ in self.occurrences_of(patt)) + + def cyclic_descents(self) -> Iterator[int]: + """Yield indices ``i`` with ``π_i > π_{i+1 mod n}``.""" + rep = self._rep + n = len(rep) + for i in range(n): + if rep[i] > rep[(i + 1) % n]: + yield i + + def cdes(self) -> int: + """The cyclic descent number. + + Examples: + >>> CyclicPerm.to_standard("23514").cdes() + 2 + """ + return sum(1 for _ in self.cyclic_descents()) + + def __len__(self) -> int: + return len(self._rep) + + def __iter__(self) -> Iterator[int]: + return iter(self._rep) + + def __eq__(self, other: object) -> bool: + if isinstance(other, CyclicPerm): + return self._rep == other._rep + return NotImplemented + + def __hash__(self) -> int: + return hash(("CyclicPerm", self._rep)) + + def __lt__(self, other: "CyclicPerm") -> bool: + if not isinstance(other, CyclicPerm): + return NotImplemented + return (len(self._rep), tuple(self._rep)) < (len(other._rep), tuple(other._rep)) + + def __le__(self, other: "CyclicPerm") -> bool: + if not isinstance(other, CyclicPerm): + return NotImplemented + return (len(self._rep), tuple(self._rep)) <= ( + len(other._rep), + tuple(other._rep), + ) + + def __contains__(self, patt: "CyclicPerm") -> bool: + return self._contains_one(patt) + + def __repr__(self) -> str: + return f"CyclicPerm({tuple(self._rep)!r})" + + def __str__(self) -> str: + if not self._rep: + return "[ε]" + if len(self._rep) <= 10: + return "[" + "".join(str(i) for i in self._rep) + "]" + return "[" + ",".join(str(i) for i in self._rep) + "]" diff --git a/permuta/enumeration_strategies/__init__.py b/permuta/enumeration_strategies/__init__.py index 0cec83f..0bde062 100644 --- a/permuta/enumeration_strategies/__init__.py +++ b/permuta/enumeration_strategies/__init__.py @@ -6,9 +6,11 @@ from .core_strategies import core_strategies from .finitely_many_simples import FinitelyManySimplesStrategy from .insertion_encodable import InsertionEncodingStrategy +from .rotationally_stable import RotationallyStableStrategy fast_enumeration_strategies: List[Type[EnumerationStrategy]] = [ - InsertionEncodingStrategy + InsertionEncodingStrategy, + RotationallyStableStrategy, ] fast_enumeration_strategies.extend(core_strategies) diff --git a/permuta/enumeration_strategies/rotationally_stable.py b/permuta/enumeration_strategies/rotationally_stable.py new file mode 100644 index 0000000..1b69574 --- /dev/null +++ b/permuta/enumeration_strategies/rotationally_stable.py @@ -0,0 +1,128 @@ +"""Enumeration strategy for classes closed under rotation: the cyclic shift that +moves the first entry of a permutation to the end, Perm.shift_left. This is not +Perm.rotate, which turns a permutation a quarter turn. +""" + +from typing import Dict, FrozenSet, Iterable, List, Tuple + +from permuta import Av, Perm +from permuta.enumeration_strategies.abstract_strategy import ( + EnumerationStrategyWithSymmetry, +) +from permuta.enumeration_strategies.core_strategies import bstrip, fstrip +from permuta.perm_sets.basis import Basis +from permuta.permutils.symmetry import all_symmetry_sets + + +def is_rotationally_stable(basis: Iterable[Perm]) -> bool: + """Return True if the basis of the class is closed under rotation. The class + is then closed under rotation too. + + Examples: + >>> is_rotationally_stable(Basis.from_string("123_231_312")) + True + >>> is_rotationally_stable(Basis.from_string("123_3412")) + False + """ + patts = frozenset(Basis.from_iterable(basis)) + return all(patt.shift_left() in patts for patt in patts) + + +def min_cut(basis: Iterable[Perm]) -> Basis: + """Return the basis of the permutations p for which 1 + p is in the class. + + If the class is closed under rotation, each of its permutations has exactly + one rotation that starts with its minimum, so the class has n times as many + permutations of length n as the min cut has of length n - 1. + + Examples: + >>> min_cut(Basis.from_string("1234_2341_3412_4123")) + Basis((Perm((0, 1, 2)), Perm((2, 3, 0, 1)))) + """ + return Basis.from_iterable(fstrip(patt) if patt else patt for patt in basis) + + +def max_cut(basis: Iterable[Perm]) -> Basis: + """Return the basis of the permutations p for which p + 1 is in the class. + + If the class is closed under rotation, it has n times as many permutations of + length n as the max cut has of length n - 1, as for the min cut. + + Examples: + >>> max_cut(Basis.from_string("1324_3241_2413_4132")) + Basis((Perm((0, 2, 1)), Perm((2, 1, 3, 0)))) + """ + return Basis.from_iterable(bstrip(patt) if patt else patt for patt in basis) + + +class RotationallyStableStrategy(EnumerationStrategyWithSymmetry): + """Enumeration strategy for a class whose basis, or a symmetry of it, is + closed under rotation. Each permutation in the class then has exactly one + rotation that starts with its minimum, so the class is counted by its min + cut, which may be rotationally stable again: see chain and count. + """ + + def _applies_to_symmetry(self, basis: FrozenSet[Perm]) -> bool: + """Check if the basis is rotationally stable, leaving out the bases of the + empty class and of the class of all permutations.""" + return bool(basis) and Perm() not in basis and is_rotationally_stable(basis) + + @classmethod + def reference(cls) -> str: + return ( + "The class is closed under rotation (cyclic shifts): one in n of its " + "permutations of length n starts with the minimum, and the min cut " + "counts these" + ) + + def chain(self) -> List[Basis]: + """Return the bases B_0, B_1, ..., B_k of a longest chain of cuts. B_0 is + the basis or a symmetry of it. Each basis but the last is rotationally + stable, and the next one is its min cut or a symmetry of that, so + |Av_n(B_i)| = n |Av_{n-1}(B_{i+1})| for n >= 1. The last basis is not + rotationally stable under any symmetry. If the strategy does not apply, + the chain is the basis alone. + + Examples: + >>> strategy = RotationallyStableStrategy(Basis.from_string("123_231_312")) + >>> [str(basis) for basis in strategy.chain()] + ['{012, 120, 201}', '{01}'] + """ + return list(_chain(Basis.from_iterable(self.basis), {})) + + def count(self, length: int) -> int: + """Return the number of permutations of the given length in the class. For + a chain of k cuts, it generates only the permutations of the class of the + last basis, of length k less. + + Examples: + >>> basis = Basis.from_string("1234_2341_3412_4123") + >>> [RotationallyStableStrategy(basis).count(n) for n in range(9)] + [1, 1, 2, 6, 20, 65, 198, 560, 1480] + """ + *stable, last = self.chain() + result = 1 + for _ in stable: + if length == 0: + return result + result *= length + length -= 1 + if Perm() in last: + return 0 + return result * Av(last).count(length) + + +def _chain(basis: Basis, known: Dict[Basis, Tuple[Basis, ...]]) -> Tuple[Basis, ...]: + """Return a longest chain of cuts from the basis, as for chain, keeping in + known the chain found from each basis on the way.""" + if basis not in known: + best: Tuple[Basis, ...] = (basis,) + if basis and Perm() not in basis: + symmetries = {Basis(*sym) for sym in all_symmetry_sets(basis)} - {basis} + for sym in [basis, *sorted(symmetries)]: + if is_rotationally_stable(sym): + chain = (sym,) + _chain(min_cut(sym), known) + if len(chain) > len(best): + best = chain + known[basis] = best + return known[basis] diff --git a/permuta/patterns/bivincularpatt.py b/permuta/patterns/bivincularpatt.py index fea387b..f1dd597 100644 --- a/permuta/patterns/bivincularpatt.py +++ b/permuta/patterns/bivincularpatt.py @@ -109,7 +109,7 @@ def __hash__(self) -> int: return hash(super()) -class VincularPatt(BivincularPatt): +class VincularPatt(BivincularPatt): # pylint: disable=abstract-method """A vincular pattern class.""" def __init__(self, perm: Perm, adjacent_indices: Iterable[int]) -> None: @@ -132,7 +132,7 @@ def __repr__(self) -> str: return f"VincularPatt({repr(self.pattern)}, {adj_idx})" -class CovincularPatt(BivincularPatt): +class CovincularPatt(BivincularPatt): # pylint: disable=abstract-method """A covincular pattern class.""" def __init__(self, perm: Perm, adjacent_values: Iterable[int]) -> None: diff --git a/tests/circular/__init__.py b/tests/circular/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/circular/test_cyclic_perm.py b/tests/circular/test_cyclic_perm.py new file mode 100644 index 0000000..7deee24 --- /dev/null +++ b/tests/circular/test_cyclic_perm.py @@ -0,0 +1,290 @@ +"""Tests for :class:`permuta.CyclicPerm`. + +Many checks are pulled directly from Domagalski-Liang-Minnich-Sagan-Schmidt- +Sietsema, "Cyclic Pattern Containment and Avoidance" (arXiv:2106.02534). +""" + +from math import comb + +import pytest + +from permuta import CyclicPerm, Perm + +# --------------------------------------------------------------------------- +# Canonical form +# --------------------------------------------------------------------------- + + +def test_canonicalization_collapses_rotations(): + rotations = [ + (3, 1, 2, 4, 0), # 42351 + (1, 2, 4, 0, 3), # 23514 + (2, 4, 0, 3, 1), # 35142 + (4, 0, 3, 1, 2), # 51423 + (0, 3, 1, 2, 4), # 14235 + ] + classes = {CyclicPerm(r) for r in rotations} + assert len(classes) == 1 + (only,) = classes + assert only.representative() == Perm((0, 3, 1, 2, 4)) + + +def test_representative_always_starts_with_zero(): + for n in range(6): + for cp in CyclicPerm.of_length(n): + if n: + assert cp.representative()[0] == 0 + + +def test_of_length_count(): + # n! / n distinct cyclic classes for n >= 1 + for n in range(1, 7): + from math import factorial + + assert sum(1 for _ in CyclicPerm.of_length(n)) == factorial(n) // n + + +# --------------------------------------------------------------------------- +# Containment — the paper's opening example +# --------------------------------------------------------------------------- + + +def test_paper_opening_example(): + # "even though 42351 avoids 1234 we have that [42351] contains [1234]" + sigma_linear = Perm.to_standard("42351") + p_linear = Perm.to_standard("1234") + assert sigma_linear.avoids(p_linear) + + sigma_cyclic = CyclicPerm.to_standard("42351") + p_cyclic = CyclicPerm.to_standard("1234") + assert sigma_cyclic.contains(p_cyclic) + assert not sigma_cyclic.avoids(p_cyclic) + + +# --------------------------------------------------------------------------- +# Degenerate patterns in small S_k +# --------------------------------------------------------------------------- + + +def test_only_cyclic_pattern_in_S2(): + # "[12] is the unique cyclic permutation of length 2 and every [σ] of + # length ≥ 2 contains it." + p12 = CyclicPerm((0, 1)) + for n in range(2, 7): + for cp in CyclicPerm.of_length(n): + assert cp.contains(p12) + + +def test_S3_cyclic_avoidance(): + # "In [S_3] there are only the patterns [123] and [321], and these are + # only avoided by [δ_n] and [ι_n], respectively." + p123 = CyclicPerm((0, 1, 2)) + p321 = CyclicPerm((2, 1, 0)) + + for n in range(3, 8): + iota_n = CyclicPerm(Perm(range(n))) + delta_n = CyclicPerm(Perm(range(n - 1, -1, -1))) + + avoiders_of_123 = [cp for cp in CyclicPerm.of_length(n) if cp.avoids(p123)] + avoiders_of_321 = [cp for cp in CyclicPerm.of_length(n) if cp.avoids(p321)] + assert avoiders_of_123 == [delta_n] + assert avoiders_of_321 == [iota_n] + + +# --------------------------------------------------------------------------- +# Callan's single-pattern enumeration (paper §3) +# --------------------------------------------------------------------------- + + +def _callan_1234(n): + return 2**n + 1 - 2 * n - comb(n, 3) + + +def _callan_1243(n): + return 2 ** (n - 1) - n + 1 + + +def _fib(n): + a, b = 1, 1 + for _ in range(n - 1): + a, b = b, a + b + return a + + +def _callan_1324(n): + # F_{2n-3} with F_1 = F_2 = 1 + return _fib(2 * n - 3) + + +@pytest.mark.parametrize("n", [2, 3, 4, 5, 6]) +def test_callan_1234_and_1432(n): + p1234 = CyclicPerm((0, 1, 2, 3)) + p1432 = CyclicPerm.to_standard("1432") + count_1234 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1234)) + count_1432 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1432)) + assert count_1234 == _callan_1234(n) + assert count_1432 == _callan_1234(n) # trivial Wilf equivalence + + +@pytest.mark.parametrize("n", [2, 3, 4, 5, 6]) +def test_callan_1243_and_1342(n): + p1243 = CyclicPerm.to_standard("1243") + p1342 = CyclicPerm.to_standard("1342") + count_1243 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1243)) + count_1342 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1342)) + assert count_1243 == _callan_1243(n) + assert count_1342 == _callan_1243(n) + + +@pytest.mark.parametrize("n", [2, 3, 4, 5, 6]) +def test_callan_1324_and_1423(n): + p1324 = CyclicPerm.to_standard("1324") + p1423 = CyclicPerm.to_standard("1423") + count_1324 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1324)) + count_1423 = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p1423)) + assert count_1324 == _callan_1324(n) + assert count_1423 == _callan_1324(n) + + +# --------------------------------------------------------------------------- +# Cyclic Erdős–Szekeres (Theorem §2) +# --------------------------------------------------------------------------- + + +def test_cyclic_erdos_szekeres_small(): + # With m = n = 2 the theorem forces any [σ] in [S_{m*n+2}] = [S_6] to + # contain [ι_{m+2}] = [1234] or [δ_{n+2}] = [4321]. And [S_{mn+1}] = + # [S_5] still admits an avoider. + p_iota = CyclicPerm((0, 1, 2, 3)) + p_delta = CyclicPerm((3, 2, 1, 0)) + + # [S_6]: no avoider + for cp in CyclicPerm.of_length(6): + assert cp.contains(p_iota) or cp.contains(p_delta) + + # [S_5]: at least one avoider exists + assert any( + cp.avoids(p_iota) and cp.avoids(p_delta) for cp in CyclicPerm.of_length(5) + ) + + +# --------------------------------------------------------------------------- +# Three-pattern results (paper §4) +# --------------------------------------------------------------------------- + + +def test_three_pattern_1234_1324_1342(): + # Theorem 4.x: #Av_n([1234],[1324],[1342]) = 3 for n >= 4. + basis = [ + CyclicPerm((0, 1, 2, 3)), + CyclicPerm.to_standard("1324"), + CyclicPerm.to_standard("1342"), + ] + for n in range(4, 8): + count = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids_set(basis)) + assert count == 3, f"n={n}: got {count}" + + +def test_three_pattern_1234_1243_1342(): + # Theorem 4.x: #Av_n([1234],[1243],[1342]) = 2 for n >= 5. + basis = [ + CyclicPerm((0, 1, 2, 3)), + CyclicPerm.to_standard("1243"), + CyclicPerm.to_standard("1342"), + ] + for n in range(5, 8): + count = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids_set(basis)) + assert count == 2, f"n={n}: got {count}" + + +def test_three_pattern_1234_1342_1423(): + # Theorem 4.x: #Av_n([1234],[1342],[1423]) = n - 1 for n >= 2. + basis = [ + CyclicPerm((0, 1, 2, 3)), + CyclicPerm.to_standard("1342"), + CyclicPerm.to_standard("1423"), + ] + for n in range(2, 8): + count = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids_set(basis)) + assert count == n - 1, f"n={n}: got {count}" + + +# --------------------------------------------------------------------------- +# Symmetries — the trivial cyclic Wilf equivalences +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("n", [4, 5, 6]) +def test_trivial_wilf_equivalences(n): + # [π] ≡ [π^r] ≡ [π^c] ≡ [π^{rc}] + for p in CyclicPerm.of_length(4): + count = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(p)) + for sym in (p.reverse(), p.complement(), p.reverse_complement()): + sym_count = sum(1 for cp in CyclicPerm.of_length(n) if cp.avoids(sym)) + assert sym_count == count + + +# --------------------------------------------------------------------------- +# Cyclic descent statistic +# --------------------------------------------------------------------------- + + +def test_cdes_paper_example(): + # "π = 23514 has cyclic descents at indices 3 and 5 so cdes[π] = 2" + # In 0-indexed, 23514 → 12403; the paper indexes from 1 so positions + # 3 and 5 become 2 and 4 here (wrap-around descent 3 → 1). + assert CyclicPerm.to_standard("23514").cdes() == 2 + + +def test_cdes_is_rotation_invariant(): + base = CyclicPerm.to_standard("52143") + rep = base.representative() + expected = base.cdes() + for k in range(len(rep)): + rotated = CyclicPerm(rep.shift_left(k)) + assert rotated.cdes() == expected + + +# --------------------------------------------------------------------------- +# Housekeeping +# --------------------------------------------------------------------------- + + +def test_avoidance_of_longer_pattern_is_vacuous(): + short = CyclicPerm((0, 1)) + long_patt = CyclicPerm.to_standard("1234") + assert short.avoids(long_patt) + assert not short.contains(long_patt) + + +def test_eq_hash_repr(): + a = CyclicPerm((3, 1, 2, 4, 0)) + b = CyclicPerm((0, 3, 1, 2, 4)) + assert a == b + assert hash(a) == hash(b) + assert repr(a) == "CyclicPerm((0, 3, 1, 2, 4))" + assert str(a) == "[03124]" + + +def test_occurrences_of(): + sigma = CyclicPerm.to_standard("42351") + patt = CyclicPerm((0, 1, 2, 3)) # [1234] in paper notation + + copies = list(sigma.occurrences_of(patt)) + assert copies # paper guarantees at least one + for _rot_index, positions in copies: + assert len(positions) == 4 + values = [sigma.representative()[i] for i in positions] + standardized = Perm.to_standard(values) + rotations_of_patt = list(patt.rotations()) + assert standardized in rotations_of_patt + + +def test_empty_and_length_one(): + empty = CyclicPerm(()) + single = CyclicPerm((0,)) + assert len(empty) == 0 + assert len(single) == 1 + assert empty.avoids(CyclicPerm((0, 1))) + assert single.avoids(CyclicPerm((0, 1))) + assert single.cdes() == 0 diff --git a/tests/enumeration_strategies/test_rotationally_stable.py b/tests/enumeration_strategies/test_rotationally_stable.py new file mode 100644 index 0000000..ab1b106 --- /dev/null +++ b/tests/enumeration_strategies/test_rotationally_stable.py @@ -0,0 +1,160 @@ +from itertools import combinations + +import pytest + +from permuta import Av, CyclicPerm, Perm +from permuta.enumeration_strategies import find_strategies +from permuta.enumeration_strategies.rotationally_stable import ( + RotationallyStableStrategy, + is_rotationally_stable, + max_cut, + min_cut, +) +from permuta.perm_sets.basis import Basis +from permuta.permutils.symmetry import inverse_set + +r123 = Basis.from_string("123_231_312") +r132 = Basis.from_string("132_213_321") +s3 = Basis.from_string("123_132_213_231_312_321") +r1234 = Basis.from_string("1234_2341_3412_4123") +r1324 = Basis.from_string("1324_3241_2413_4132") +r1243 = Basis.from_string("1243_2431_4312_3124") +r1243_inverse = Basis.from_string("1243_2314_3421_4132") + + +def lift(basis): + """The rotations of the patterns 1 + p for p in the basis.""" + return Basis( + *( + Perm((0,)).direct_sum(patt).shift_left(i) + for patt in basis + for i in range(len(patt) + 1) + ) + ) + + +def cyclic_count(patts, n): + """The number of cyclic permutations of length n avoiding the patterns.""" + return sum(1 for perm in CyclicPerm.of_length(n) if perm.avoids_set(patts)) + + +def test_is_rotationally_stable(): + assert is_rotationally_stable(r123) + assert is_rotationally_stable(r132) + assert is_rotationally_stable(s3) + assert is_rotationally_stable(r1234) + assert is_rotationally_stable(lift(r1234)) + assert not is_rotationally_stable(Basis.from_string("123")) + assert not is_rotationally_stable(Basis.from_string("123_3412")) + assert not is_rotationally_stable(r1243_inverse) + # the basis of the class is tested, not the patterns as given + assert is_rotationally_stable([*r123, Perm((0, 1, 2, 3))]) + + +def test_applies_to_subsets_of_s3(): + applies = { + frozenset(basis) + for k in range(1, 7) + for basis in combinations(s3, k) + if RotationallyStableStrategy(basis).applies() + } + assert applies == {frozenset(r123), frozenset(r132), frozenset(s3)} + + +def test_applies_to_a_symmetry(): + # closed under rotating the values, so its inverse is closed under rotation + assert is_rotationally_stable(inverse_set(r1243_inverse)) + assert RotationallyStableStrategy(r1243_inverse).applies() + + +def test_does_not_apply(): + assert not RotationallyStableStrategy(Basis.from_string("123_3412")).applies() + assert not RotationallyStableStrategy([]).applies() + assert not RotationallyStableStrategy([Perm()]).applies() + + +def test_cuts(): + assert min_cut(r123) == max_cut(r123) == Basis(Perm((0, 1))) + assert min_cut(r1234) == max_cut(r1234) == Basis.from_string("123_3412") + assert min_cut(r1324) == Basis.from_string("213_4132") + assert max_cut(r1324) == Basis.from_string("132_3241") + assert min_cut(r1243) == Basis.from_string("132_3124_4312") + assert min_cut(lift(r123)) == r123 + assert min_cut([Perm((0,))]) == Basis(Perm()) + + +def test_chain(): + assert RotationallyStableStrategy(lift(lift(r123))).chain() == [ + lift(lift(r123)), + lift(r123), + r123, + Basis(Perm((0, 1))), + ] + assert RotationallyStableStrategy(lift(r1234)).chain() == [ + lift(r1234), + r1234, + Basis.from_string("123_3412"), + ] + assert RotationallyStableStrategy(r1243_inverse).chain() == [ + r1243, + Basis.from_string("132_3124_4312"), + ] + assert RotationallyStableStrategy([Perm((0,))]).chain() == [ + Basis(Perm((0,))), + Basis(Perm()), + ] + basis = Basis.from_string("123_3412") + assert RotationallyStableStrategy(basis).chain() == [basis] + + +@pytest.mark.parametrize( + "basis", + [ + r123, + s3, + r1234, + r1324, + r1243, + r1243_inverse, + lift(r123), + lift(lift(r123)), + lift(r1234), + Basis(Perm((0,))), + Basis.from_string("123_3412"), + ], +) +def test_count(basis): + strategy = RotationallyStableStrategy(basis) + assert [strategy.count(n) for n in range(9)] == Av(basis).enumeration(8) + + +def test_count_is_n_times_the_cyclic_count(): + # all rotations of some cyclic patterns are a rotationally stable basis, whose + # class has n times as many permutations of length n as there are cyclic + # permutations of length n avoiding the patterns + cases = [[patt] for k in (4, 5) for patt in CyclicPerm.of_length(k)] + cases.extend(list(pair) for pair in combinations(CyclicPerm.of_length(4), 2)) + for patts in cases: + basis = Basis(*(rotation for patt in patts for rotation in patt.rotations())) + strategy = RotationallyStableStrategy(basis) + assert strategy.applies() + for n in range(1, 8): + assert strategy.count(n) == n * cyclic_count(patts, n) + + +def test_counts_from_either_cut(): + for basis in (r123, r1234, r1324, r1243, lift(r123)): + counts = Av(basis).enumeration(8) + for cut in (min_cut(basis), max_cut(basis)): + assert counts[1:] == [n * Av(cut).count(n - 1) for n in range(1, 9)] + + +def test_find_strategies(): + assert any( + isinstance(strategy, RotationallyStableStrategy) + for strategy in find_strategies(r1234, long_runnning=False) + ) + assert not any( + isinstance(strategy, RotationallyStableStrategy) + for strategy in find_strategies(Basis.from_string("123_3412"), False) + )