From 7498e6a9b3baed5433f3a6bc76a6b462ee41c315 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 15 Oct 2026 12:30:22 +0000 Subject: [PATCH 1/5] Add RotationallyStableStrategy, for classes closed under cyclic shifts A class whose basis is closed under rotation (moving the first entry of a pattern to the end) is closed under rotation too, so it has n times as many permutations of length n as its min cut, the class of the permutations p with 1 + p in the class, has of length n - 1. The new fast enumeration strategy checks this for the basis and its symmetries, gives the min and max cuts, follows the cuts while they are rotationally stable again, and counts the class through them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.rst | 20 +++ permuta/enumeration_strategies/__init__.py | 4 +- .../rotationally_stable.py | 128 ++++++++++++++++ .../test_rotationally_stable.py | 141 ++++++++++++++++++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 permuta/enumeration_strategies/rotationally_stable.py create mode 100644 tests/enumeration_strategies/test_rotationally_stable.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ecb90..2c2f566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added +- `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/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/tests/enumeration_strategies/test_rotationally_stable.py b/tests/enumeration_strategies/test_rotationally_stable.py new file mode 100644 index 0000000..c69b471 --- /dev/null +++ b/tests/enumeration_strategies/test_rotationally_stable.py @@ -0,0 +1,141 @@ +from itertools import combinations + +import pytest + +from permuta import Av, 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 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_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) + ) From d02adedebe4a4f25284e5957c52fea2c7881891d Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 15 Oct 2026 12:46:33 +0000 Subject: [PATCH 2/5] Add CyclicPerm for cyclic pattern containment and avoidance Implements the model of Domagalski et al., "Cyclic Pattern Containment and Avoidance" (arXiv:2106.02534): a cyclic permutation is the class of all rotations of a permutation, stored by its rotation that starts with its minimum, so [sigma] contains [p] when that rotation of sigma contains some rotation of p. The tests check Callan's enumeration of the single patterns of length 4, three-pattern results of Domagalski et al., the cyclic Erdos-Szekeres bound, the trivial Wilf-equivalences, that the number of cyclic descents does not depend on the rotation, and that [42351] contains [1234] though 42351 avoids 1234. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + permuta/__init__.py | 2 + permuta/circular/__init__.py | 3 + permuta/circular/cyclic_perm.py | 207 ++++++++++++++++++++ tests/circular/__init__.py | 0 tests/circular/test_cyclic_perm.py | 290 +++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+) create mode 100644 permuta/circular/__init__.py create mode 100644 permuta/circular/cyclic_perm.py create mode 100644 tests/circular/__init__.py create mode 100644 tests/circular/test_cyclic_perm.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2f566..dda0ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ 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 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..8da1afc --- /dev/null +++ b/permuta/circular/cyclic_perm.py @@ -0,0 +1,207 @@ +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",) + + 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 self._rep[1:]) + + 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": + return CyclicPerm(self._rep.reverse()) + + def complement(self) -> "CyclicPerm": + return CyclicPerm(self._rep.complement()) + + def reverse_complement(self) -> "CyclicPerm": + 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: + 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: + 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/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 From a59928e39124b1167e6e5d361b0866ca5b142897 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 15 Oct 2026 12:46:34 +0000 Subject: [PATCH 3/5] Test RotationallyStableStrategy against the cyclic counts of CyclicPerm All rotations of a set of 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. Checked for every cyclic pattern of length 4 or 5 and every pair of length 4, to length 7. Co-Authored-By: Claude Opus 5.5 --- .../test_rotationally_stable.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/enumeration_strategies/test_rotationally_stable.py b/tests/enumeration_strategies/test_rotationally_stable.py index c69b471..ab1b106 100644 --- a/tests/enumeration_strategies/test_rotationally_stable.py +++ b/tests/enumeration_strategies/test_rotationally_stable.py @@ -2,7 +2,7 @@ import pytest -from permuta import Av, Perm +from permuta import Av, CyclicPerm, Perm from permuta.enumeration_strategies import find_strategies from permuta.enumeration_strategies.rotationally_stable import ( RotationallyStableStrategy, @@ -33,6 +33,11 @@ def lift(basis): ) +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) @@ -123,6 +128,20 @@ def test_count(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) From 09900791ee4ec1096d888913a77c56910b250289 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 16 Oct 2026 21:00:30 +0000 Subject: [PATCH 4/5] Type CyclicPerm for mypy, and document its last five methods CyclicPerm keeps its representative in the slot _rep, set in __new__ on the new object, so mypy never learned the attribute and failed with 27 errors. A class annotation declares it. With _rep typed as a Perm, whose slices mypy takes for empty tuples, sigma now drops the first entry with itertools.islice, as Perm itself does. reverse, complement, reverse_complement, avoids_set and count_occurrences_of get the docstrings pylint asks for. Co-Authored-By: Claude Opus 5.5 --- permuta/circular/cyclic_perm.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/permuta/circular/cyclic_perm.py b/permuta/circular/cyclic_perm.py index 8da1afc..3af711d 100644 --- a/permuta/circular/cyclic_perm.py +++ b/permuta/circular/cyclic_perm.py @@ -1,3 +1,4 @@ +import itertools from typing import Iterable, Iterator, Tuple from permuta.patterns.perm import Perm @@ -12,6 +13,7 @@ class CyclicPerm: """ __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. @@ -85,7 +87,7 @@ 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 self._rep[1:]) + 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.""" @@ -95,12 +97,15 @@ def rotations(self) -> Iterator[Perm]: 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: @@ -120,6 +125,7 @@ def avoids(self, *patts: "CyclicPerm") -> bool: 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: @@ -147,6 +153,7 @@ def occurrences_of( 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]: From c79a44c20d1dcf46b748d93269ca2599f1eea75f Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 16 Oct 2026 21:00:30 +0000 Subject: [PATCH 5/5] Silence pylint's abstract-method warning on VincularPatt and CovincularPatt pylint 4 counts a method that only raises NotImplementedError as abstract, and flags the subclasses that do not override it, on develop too. BivincularPatt leaves unrank and of_length unimplemented on purpose, and so do these two subclasses. Co-Authored-By: Claude Opus 5.5 --- permuta/patterns/bivincularpatt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: