From b5adc76eb2222cd7364297606fd081766d79e315 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Sun, 25 Oct 2026 22:18:37 +0000 Subject: [PATCH 1/5] Add permuta.permutils.rsk: RSK, evacuation and Knuth-closure tests The Robinson-Schensted correspondence on words of distinct letters (rsk, rsk_inverse), Schutzenberger's evacuation of standard Young tableaux (evacuation), the word with the same insertion tableau and the evacuated recording tableau (evacuate_recording_tableau), and whether the avoiders of some patterns are closed under Knuth equivalence (is_knuth_closed) or determined by their RSK shape (is_shape_closed), all exported from permuta.permutils. Tilings' Knuth slide (PermutaTriangle/Tilings#532) is built on them; they are about permutations, so they are Permuta's. The BiSC properties yt_perm_avoids_22 and yt_perm_avoids_32 take their insertion tableau from rsk; the private _perm_to_yt goes, and its test moves to tests/permutils/test_rsk.py. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++ permuta/bisc/perm_properties.py | 28 +-- permuta/permutils/__init__.py | 14 ++ permuta/permutils/rsk.py | 181 ++++++++++++++++++ tests/bisc/test_perm_properties.py | 21 --- tests/permutils/test_function_imports.py | 16 ++ tests/permutils/test_rsk.py | 230 +++++++++++++++++++++++ 7 files changed, 456 insertions(+), 46 deletions(-) create mode 100644 permuta/permutils/rsk.py create mode 100644 tests/permutils/test_rsk.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ecb90a..5eb503f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added +- `permuta.permutils.rsk`: the Robinson-Schensted correspondence on words of + distinct letters (`rsk`, `rsk_inverse`), Schützenberger's evacuation of + standard Young tableaux (`evacuation`), the word with the same insertion + tableau and the evacuated recording tableau (`evacuate_recording_tableau`), + and whether the avoiders of some patterns are closed under Knuth equivalence + (`is_knuth_closed`) or determined by their RSK shape (`is_shape_closed`). + +### Changed +- The insertion tableau of the BiSC properties `yt_perm_avoids_22` and + `yt_perm_avoids_32` is `rsk`'s. + ### Fixed - Fixed bug in `Perm.rtlmax_ltrmin_decomposition` and fixed associated tests. diff --git a/permuta/bisc/perm_properties.py b/permuta/bisc/perm_properties.py index 733ca2ee..d2e253d1 100644 --- a/permuta/bisc/perm_properties.py +++ b/permuta/bisc/perm_properties.py @@ -1,9 +1,9 @@ -from itertools import islice from typing import List from permuta.patterns.meshpatt import MeshPatt from permuta.patterns.perm import Perm from permuta.permutils.groups import dihedral_group +from permuta.permutils.rsk import rsk _SMOOTH_PATT = (Perm((0, 2, 1, 3)), Perm((1, 0, 3, 2))) @@ -57,28 +57,6 @@ def in_alternating_group(perm: Perm) -> bool: return perm.count_inversions() % 2 == 0 -def _perm_to_yt(perm: Perm) -> List[List[int]]: - # transform perm to standard young table - - def insert_in_row(i, k): - cur_row = res[i] - found = next(((ind, cur) for ind, cur in enumerate(cur_row) if cur > k), None) - if not found: - cur_row.append(k) - else: - ind, cur = found - cur_row[ind] = k - if len(res) <= i + 1: - res.append([cur]) - else: - insert_in_row(i + 1, cur) - - res: List[List[int]] = [[perm[0]]] if perm else [] - for val in islice(perm, 1, None): - insert_in_row(0, val) - return res - - def _tableau_contains_shape(tab: List[List[int]], shape: List[int]) -> bool: # Return True if the tableaux tab contains the shape return len(tab) >= len(shape) and all(s <= t for s, t in zip(shape, map(len, tab))) @@ -86,12 +64,12 @@ def _tableau_contains_shape(tab: List[List[int]], shape: List[int]) -> bool: def yt_perm_avoids_22(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [2,2].""" - return not _tableau_contains_shape(_perm_to_yt(perm), [2, 2]) + return not _tableau_contains_shape(rsk(perm)[0], [2, 2]) def yt_perm_avoids_32(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [3,2].""" - return not _tableau_contains_shape(_perm_to_yt(perm), [3, 2]) + return not _tableau_contains_shape(rsk(perm)[0], [3, 2]) _AV_231_AND_MESH_PATT = ( diff --git a/permuta/permutils/__init__.py b/permuta/permutils/__init__.py index e0e4b41c..34bfc812 100644 --- a/permuta/permutils/__init__.py +++ b/permuta/permutils/__init__.py @@ -2,6 +2,14 @@ from .groups import dihedral_group from .insertion_encodable import InsertionEncodablePerms from .polynomial import PolyPerms +from .rsk import ( + evacuate_recording_tableau, + evacuation, + is_knuth_closed, + is_shape_closed, + rsk, + rsk_inverse, +) from .symmetry import ( all_symmetry_sets, antidiagonal_set, @@ -41,4 +49,10 @@ "rotate_90_clockwise_set", "rotate_180_clockwise_set", "rotate_270_clockwise_set", + "evacuate_recording_tableau", + "evacuation", + "is_knuth_closed", + "is_shape_closed", + "rsk", + "rsk_inverse", ] diff --git a/permuta/permutils/rsk.py b/permuta/permutils/rsk.py new file mode 100644 index 00000000..ca43fb80 --- /dev/null +++ b/permuta/permutils/rsk.py @@ -0,0 +1,181 @@ +""" +The Robinson-Schensted correspondence (RSK) on words of distinct letters, +Schützenberger's evacuation of standard Young tableaux, and tests of whether the +permutations avoiding some patterns are closed under Knuth equivalence, or are +determined by their RSK shape. +""" + +from bisect import bisect_left +from functools import lru_cache +from typing import FrozenSet, Iterable, Iterator, List, Sequence, Tuple + +from permuta.patterns.perm import Perm + +Tableau = List[List[int]] + + +def rsk(word: Sequence[int]) -> Tuple[Tableau, Tableau]: + """Return the insertion and recording tableaux of a word of distinct letters, + by row insertion. The recording tableau records the steps 1, 2, ..., and both + have the same shape. + + Examples: + >>> rsk((1, 3, 0, 2)) + ([[0, 2], [1, 3]], [[1, 2], [3, 4]]) + >>> rsk((7, 2, 9, 4)) + ([[2, 4], [7, 9]], [[1, 3], [2, 4]]) + """ + insertion_tableau: Tableau = [] + recording_tableau: Tableau = [] + for step, letter in enumerate(word, 1): + for insertion_row, recording_row in zip(insertion_tableau, recording_tableau): + position = bisect_left(insertion_row, letter) + if position == len(insertion_row): + insertion_row.append(letter) + recording_row.append(step) + break + letter, insertion_row[position] = insertion_row[position], letter + else: + insertion_tableau.append([letter]) + recording_tableau.append([step]) + return insertion_tableau, recording_tableau + + +def rsk_inverse( + insertion_tableau: Sequence[Sequence[int]], + recording_tableau: Sequence[Sequence[int]], +) -> List[int]: + """Return the word whose insertion and recording tableaux are the given ones: + tableaux of the same shape, the recording tableau standard, of the steps 1, 2, + .... + + Examples: + >>> rsk_inverse([[0, 2], [1, 3]], [[1, 2], [3, 4]]) + [1, 3, 0, 2] + """ + insertion_rows = [list(row) for row in insertion_tableau] + recording_rows = [list(row) for row in recording_tableau] + row_of = { + step: row_index for row_index, row in enumerate(recording_rows) for step in row + } + word = [0] * len(row_of) + for step in range(len(word), 0, -1): + row_index = row_of[step] + recording_rows[row_index].pop() + letter = insertion_rows[row_index].pop() + for upper in range(row_index - 1, -1, -1): + insertion_row = insertion_rows[upper] + position = bisect_left(insertion_row, letter) - 1 + letter, insertion_row[position] = insertion_row[position], letter + word[step - 1] = letter + return word + + +def evacuation(tableau: Sequence[Sequence[int]]) -> Tableau: + """Return Schützenberger's evacuation of a standard Young tableau of the entries + 1 to n: delete the smallest entry and slide the hole out by jeu de taquin, n + times, writing n, n - 1, ... in the cells vacated. It is an involution that + keeps the shape, and the recording tableau of a permutation's reverse-complement + is the evacuation of the permutation's. + + Examples: + >>> evacuation([[1, 2], [3]]) + [[1, 3], [2]] + """ + current = [list(row) for row in tableau] + result: Tableau = [[0] * len(row) for row in current] + size = sum(len(row) for row in current) + for label in range(size, 0, -1): + row, column = 0, 0 + while True: + right = current[row][column + 1] if column + 1 < len(current[row]) else None + below = ( + current[row + 1][column] + if row + 1 < len(current) and column < len(current[row + 1]) + else None + ) + if right is None and below is None: + break + if below is None or (right is not None and right < below): + assert right is not None + current[row][column] = right + column += 1 + else: + current[row][column] = below + row += 1 + current[row].pop() + if not current[row]: + current.pop() + result[row][column] = label + return result + + +def evacuate_recording_tableau(word: Sequence[int]) -> List[int]: + """Return the word with the word's insertion tableau and the evacuation of its + recording tableau, RSK^-1(P(w), evac(Q(w))) for the word w of m distinct + letters. It is Knuth equivalent to w, as its insertion tableau is w's, and its + factor at positions m + 1 - j to m + 1 - i has the RSK shape of w's factor at + positions i to j. The map is an involution. + + Examples: + >>> evacuate_recording_tableau((1, 3, 0, 2)) + [1, 3, 0, 2] + >>> evacuate_recording_tableau((0, 2, 1)) + [2, 0, 1] + """ + insertion_tableau, recording_tableau = rsk(word) + return rsk_inverse(insertion_tableau, evacuation(recording_tableau)) + + +def _knuth_neighbors(perm: Perm) -> Iterator[Perm]: + """Yield the permutations one elementary Knuth move from the permutation: + y z x <-> y x z and x z y <-> z x y, for x < y < z.""" + word = list(perm) + for index in range(len(word) - 2): + first, second, third = word[index], word[index + 1], word[index + 2] + if min(second, third) < first < max(second, third): + yield Perm(word[: index + 1] + [third, second] + word[index + 3 :]) + if min(first, second) < third < max(first, second): + yield Perm(word[:index] + [second, first] + word[index + 2 :]) + + +def is_knuth_closed(basis: Iterable[Perm]) -> bool: + """Return True if the permutations avoiding all the patterns are closed under + Knuth equivalence. It is decided locally: an occurrence that a Knuth move + destroys uses both letters it exchanges, and with the letter that allows the + move it lies in a permutation at most one entry longer than the pattern. + + Examples: + >>> is_knuth_closed([Perm((0, 2, 1))]) + False + >>> is_knuth_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + True + """ + return _is_knuth_closed(frozenset(basis)) + + +@lru_cache(maxsize=1 << 16) +def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: + return not any( + neighbor.avoids(*patterns) + for pattern in patterns + for container in (pattern, *pattern.coveredby()) + for neighbor in _knuth_neighbors(container) + ) + + +def is_shape_closed(basis: Iterable[Perm]) -> bool: + """Return True if whether a permutation avoids the patterns depends only on its + RSK shape: the patterns and their inverses are both Knuth closed. Monotone + patterns are. + + Examples: + >>> is_shape_closed([Perm((2, 1, 0))]) + True + >>> is_shape_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + False + """ + patterns = frozenset(basis) + return _is_knuth_closed(patterns) and _is_knuth_closed( + frozenset(pattern.inverse() for pattern in patterns) + ) diff --git a/tests/bisc/test_perm_properties.py b/tests/bisc/test_perm_properties.py index be18d120..d52b3e0a 100644 --- a/tests/bisc/test_perm_properties.py +++ b/tests/bisc/test_perm_properties.py @@ -1,6 +1,5 @@ from permuta import Perm from permuta.bisc.perm_properties import ( - _perm_to_yt, av_231_and_mesh, baxter, dihedral, @@ -153,26 +152,6 @@ def test_in_alternating_group(): assert 2520 == sum(1 for p in Perm.of_length(7) if in_alternating_group(p)) -def test__perm_to_yt(): - z = {i: set() for i in range(8)} - for i in range(8): - for p in Perm.of_length(i): - z[i].add(tuple(map(tuple, _perm_to_yt(p)))) - expected = [1, 1, 2, 4, 10, 26, 76, 232] - for i in range(8): - assert len(z[i]) == expected[i] - assert _perm_to_yt(Perm((1, 0))) == [[0], [1]] - assert _perm_to_yt(Perm((1, 0, 3, 5, 2, 4))) == [[0, 2, 4], [1, 3, 5]] - assert _perm_to_yt(Perm((1, 0, 2, 3))) == [[0, 2, 3], [1]] - assert _perm_to_yt(Perm((3, 1, 4, 0, 2))) == [[0, 2], [1, 4], [3]] - assert _perm_to_yt(Perm((5, 4, 1, 0, 3, 2))) == [[0, 2], [1, 3], [4], [5]] - assert _perm_to_yt(Perm((0, 1))) == [[0, 1]] - assert _perm_to_yt(Perm((0,))) == [[0]] - assert _perm_to_yt(Perm((2, 3, 0, 1))) == [[0, 1], [2, 3]] - assert _perm_to_yt(Perm((1, 2, 3, 0))) == [[0, 2, 3], [1]] - assert _perm_to_yt(Perm((4, 0, 1, 2, 5, 3))) == [[0, 1, 2, 3], [4, 5]] - - def test_yt_perm_avoids_22(): assert yt_perm_avoids_22(Perm()) assert yt_perm_avoids_22(Perm((0,))) diff --git a/tests/permutils/test_function_imports.py b/tests/permutils/test_function_imports.py index 01d1f6a2..12c6cbe9 100644 --- a/tests/permutils/test_function_imports.py +++ b/tests/permutils/test_function_imports.py @@ -1,10 +1,16 @@ from permuta import Perm from permuta.permutils import ( + evacuate_recording_tableau, + evacuation, is_insertion_encodable, is_insertion_encodable_maximum, is_insertion_encodable_rightmost, + is_knuth_closed, is_non_polynomial, is_polynomial, + is_shape_closed, + rsk, + rsk_inverse, ) @@ -92,3 +98,13 @@ def test_functions_insertion_encodable(): Perm((3, 1, 4, 2, 0)), ] ) + + +def test_functions_rsk(): + word = (2, 0, 1) + insertion_tableau, recording_tableau = rsk(word) + assert rsk_inverse(insertion_tableau, recording_tableau) == list(word) + assert evacuation(evacuation(recording_tableau)) == recording_tableau + assert evacuate_recording_tableau(evacuate_recording_tableau(word)) == list(word) + assert is_knuth_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + assert not is_shape_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) diff --git a/tests/permutils/test_rsk.py b/tests/permutils/test_rsk.py new file mode 100644 index 00000000..4fff7869 --- /dev/null +++ b/tests/permutils/test_rsk.py @@ -0,0 +1,230 @@ +import random +from collections import defaultdict +from itertools import combinations, permutations + +import pytest + +from permuta import Perm +from permuta.permutils.rsk import ( + evacuate_recording_tableau, + evacuation, + is_knuth_closed, + is_shape_closed, + rsk, + rsk_inverse, +) + + +def shape(word): + return tuple(len(row) for row in rsk(word)[0]) + + +def perms(size): + return (list(perm) for perm in permutations(range(size))) + + +@pytest.mark.parametrize("size", range(8)) +def test_rsk_and_its_inverse(size): + for perm in perms(size): + insertion_tableau, recording_tableau = rsk(perm) + assert [len(row) for row in insertion_tableau] == [ + len(row) for row in recording_tableau + ] + assert rsk_inverse(insertion_tableau, recording_tableau) == perm + + +def test_rsk_of_words_with_gaps(): + word = [7, 2, 9, 4] + insertion_tableau, recording_tableau = rsk(word) + assert insertion_tableau == [[2, 4], [7, 9]] + assert recording_tableau == [[1, 3], [2, 4]] + assert rsk_inverse(insertion_tableau, recording_tableau) == word + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuation_is_a_shape_preserving_involution(size): + for perm in perms(size): + recording_tableau = rsk(perm)[1] + evacuated = evacuation(recording_tableau) + assert [len(row) for row in evacuated] == [ + len(row) for row in recording_tableau + ] + assert evacuation(evacuated) == recording_tableau + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuation_records_the_reverse_complement(size): + """Schutzenberger: Q(w^#) = evac(Q(w)) for the reverse-complement w^#.""" + for perm in perms(size): + reverse_complement = [size - 1 - value for value in reversed(perm)] + assert rsk(reverse_complement)[1] == evacuation(rsk(perm)[1]) + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuating_the_recording_tableau(size): + """It keeps the insertion tableau, gives every factor the shape of the mirrored + factor of the word, and is an involution.""" + for perm in perms(size): + image = evacuate_recording_tableau(perm) + assert rsk(image)[0] == rsk(perm)[0] + assert evacuate_recording_tableau(image) == perm + for i in range(size): + for j in range(i, size): + assert shape(image[size - 1 - j : size - i]) == shape(perm[i : j + 1]) + + +def test_the_knuth_class_is_kept_on_every_window_of_values(): + for perm in perms(6): + image = evacuate_recording_tableau(perm) + for low in range(6): + for high in range(low, 6): + window = [value for value in perm if low <= value <= high] + image_window = [value for value in image if low <= value <= high] + assert rsk(window)[0] == rsk(image_window)[0] + + +def brute_force_knuth_closed(patterns, length): + for size in range(length + 1): + classes = defaultdict(list) + for perm in permutations(range(size)): + classes[tuple(map(tuple, rsk(perm)[0]))].append(Perm(perm)) + for knuth_class in classes.values(): + if len({perm.avoids(*patterns) for perm in knuth_class}) > 1: + return False + return True + + +def test_knuth_closure_against_brute_force(): + """Any failure of closure shows on permutations at most one entry longer than + the longest pattern, so the brute force to that length decides it.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + generator = random.Random(2026) + bases = [ + list(chosen) + for size in range(1, 7) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [ + generator.sample(perms_of_length_4, generator.randint(1, 6)) for _ in range(40) + ] + bases += [ + generator.sample(perms_of_length_3, 1) + + generator.sample(perms_of_length_4, generator.randint(1, 3)) + for _ in range(20) + ] + for basis in bases: + length = max(len(pattern) for pattern in basis) + 1 + assert is_knuth_closed(frozenset(basis)) == brute_force_knuth_closed( + basis, length + ), basis + + +def test_shape_closure(): + def basis(*patterns): + return frozenset(Perm.to_standard(pattern) for pattern in patterns) + + for size in range(1, 6): + assert is_shape_closed(frozenset([Perm(range(size))])) + assert is_shape_closed(frozenset([Perm(range(size - 1, -1, -1))])) + shape_31 = frozenset( + Perm(perm) for perm in permutations(range(4)) if shape(list(perm)) == (3, 1) + ) + assert len(shape_31) == 9 and is_shape_closed(shape_31) + # Henning's stable Knuth class: Knuth-closed, but not determined by the shape + stable_knuth_class = basis("1243", "1423", "4123") + assert is_knuth_closed(stable_knuth_class) and not is_shape_closed( + stable_knuth_class + ) + assert not is_knuth_closed(basis("132")) + assert is_knuth_closed(basis("132", "312")) + assert not is_shape_closed(basis("132", "312")) + assert is_knuth_closed(frozenset()) and is_shape_closed(frozenset()) + + +def knuth_class_moves(word): + """The words one elementary Knuth move from word, as Knuth stated them: for + x < y < z, y z x <-> y x z and x z y <-> z x y. Written from that statement, not + from the module under test.""" + for i in range(len(word) - 2): + triple = tuple(word[i : i + 3]) + low, middle, high = sorted(triple) + for one, other in [ + ((middle, high, low), (middle, low, high)), + ((low, high, middle), (high, low, middle)), + ]: + for before, after in [(one, other), (other, one)]: + if triple == before: + yield tuple(word[:i]) + after + tuple(word[i + 3 :]) + + +@pytest.mark.parametrize("size", range(8)) +def test_the_insertion_tableau_identifies_the_knuth_classes(size): + """Knuth's theorem: two permutations are Knuth equivalent exactly when their + insertion tableaux are equal. The classes are found here by the moves alone, and + compared with the fibers of rsk's first tableau.""" + by_tableau = defaultdict(set) + for perm in permutations(range(size)): + by_tableau[tuple(map(tuple, rsk(perm)[0]))].add(perm) + seen = set() + for start in permutations(range(size)): + if start in seen: + continue + component, frontier = {start}, [start] + while frontier: + for neighbor in knuth_class_moves(frontier.pop()): + if neighbor not in component: + component.add(neighbor) + frontier.append(neighbor) + seen |= component + assert component == by_tableau[tuple(map(tuple, rsk(start)[0]))] + + +def test_knuth_closure_of_unions_of_knuth_classes(): + """Most sets of patterns are not closed, so the brute force above mostly agrees on + the answer False for patterns of four letters. Unions of Knuth classes are often + closed: the answers True are compared too.""" + classes = {} + for size in (3, 4): + by_tableau = defaultdict(list) + for perm in permutations(range(size)): + by_tableau[tuple(map(tuple, rsk(perm)[0]))].append(Perm(perm)) + classes[size] = list(by_tableau.values()) + generator = random.Random(2027) + closed = 0 + for _ in range(60): + basis = [ + perm + for size in (3, 4) + for knuth_class in generator.sample(classes[size], generator.randint(0, 2)) + for perm in knuth_class + ] + if not basis: + continue + length = max(len(pattern) for pattern in basis) + 1 + expected = brute_force_knuth_closed(basis, length) + assert is_knuth_closed(frozenset(basis)) == expected, basis + closed += expected + assert closed >= 10 + + +def test_the_insertion_tableaux(): + """The insertion tableaux of the permutations of each length are the standard + Young tableaux of that size, as many as the involutions; and some examples.""" + tableaux = {length: set() for length in range(8)} + for length in range(8): + for perm in Perm.of_length(length): + tableaux[length].add(tuple(map(tuple, rsk(perm)[0]))) + expected = [1, 1, 2, 4, 10, 26, 76, 232] + for length in range(8): + assert len(tableaux[length]) == expected[length] + assert rsk(Perm((1, 0)))[0] == [[0], [1]] + assert rsk(Perm((1, 0, 3, 5, 2, 4)))[0] == [[0, 2, 4], [1, 3, 5]] + assert rsk(Perm((1, 0, 2, 3)))[0] == [[0, 2, 3], [1]] + assert rsk(Perm((3, 1, 4, 0, 2)))[0] == [[0, 2], [1, 4], [3]] + assert rsk(Perm((5, 4, 1, 0, 3, 2)))[0] == [[0, 2], [1, 3], [4], [5]] + assert rsk(Perm((0, 1)))[0] == [[0, 1]] + assert rsk(Perm((0,)))[0] == [[0]] + assert rsk(Perm((2, 3, 0, 1)))[0] == [[0, 1], [2, 3]] + assert rsk(Perm((1, 2, 3, 0)))[0] == [[0, 2, 3], [1]] + assert rsk(Perm((4, 0, 1, 2, 5, 3)))[0] == [[0, 1, 2, 3], [4, 5]] From 9b9de6e7d34fd12d89c36f2753ce026da8476bb5 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Mon, 26 Oct 2026 06:51:55 +0000 Subject: [PATCH 2/5] Tests from the review: any iterable, the shape closure by brute force, and more The review's tests reader wrote tests for what the moved tests do not reach, each failing on a fault that passed the tests before: - the closure tests on six kinds of iterable, in any order and with repeats, and on bases with redundant patterns; - is_shape_closed against brute force on 45 bases, which caught a check of the inverses alone; - the empty word and a single letter; words of letters other than 0, 1, ...; tableaux given as tuples and left as they were; patterns of length at most 2, the empty permutation among them; - the BiSC properties of the shape against Greene's theorem, with the BiSC tests. Co-Authored-By: Claude Opus 5.5 --- tests/bisc/test_perm_properties.py | 32 ++++++ tests/permutils/test_rsk.py | 177 +++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/tests/bisc/test_perm_properties.py b/tests/bisc/test_perm_properties.py index d52b3e0a..5ebe4b1e 100644 --- a/tests/bisc/test_perm_properties.py +++ b/tests/bisc/test_perm_properties.py @@ -1,3 +1,5 @@ +import pytest + from permuta import Perm from permuta.bisc.perm_properties import ( av_231_and_mesh, @@ -11,6 +13,7 @@ yt_perm_avoids_22, yt_perm_avoids_32, ) +from permuta.permutils.rsk import rsk def test_smooth(): @@ -276,3 +279,32 @@ def test_hard_mesh(): assert not hard_mesh(Perm((7, 0, 5, 4, 6, 3, 2, 9, 1, 8))) assert not hard_mesh(Perm((2, 6, 9, 1, 8, 0, 10, 5, 7, 3, 4))) assert 692 == sum(1 for p in Perm.of_length(7) if hard_mesh(p)) + + +def first_two_rows_by_greene(word): + """The first two rows of the RSK shape of a word of distinct letters, by Greene's + theorem and not by rsk: the first row is the length of the longest increasing + subsequence, and the first two together are the most letters in two increasing + subsequences.""" + # the most letters of two increasing subsequences that end in the two letters + best = {(-1, -1): 0} + for letter in word: + for (first, second), count in list(best.items()): + if letter > first: + best[letter, second] = max(best.get((letter, second), 0), count + 1) + if letter > second: + best[first, letter] = max(best.get((first, letter), 0), count + 1) + longest_increasing = max(count for (_, second), count in best.items() if second < 0) + union_of_two = max(best.values()) + return longest_increasing, union_of_two - longest_increasing + + +@pytest.mark.parametrize("size", range(8)) +def test_the_bisc_properties_of_the_shape_against_greene(size): + for perm in Perm.of_length(size): + first_row, second_row = first_two_rows_by_greene(perm) + insertion_tableau, _ = rsk(perm) + rows = [len(row) for row in insertion_tableau] + [0, 0] + assert (first_row, second_row) == (rows[0], rows[1]) + assert yt_perm_avoids_22(perm) == (second_row < 2) + assert yt_perm_avoids_32(perm) == (first_row < 3 or second_row < 2) diff --git a/tests/permutils/test_rsk.py b/tests/permutils/test_rsk.py index 4fff7869..9270d070 100644 --- a/tests/permutils/test_rsk.py +++ b/tests/permutils/test_rsk.py @@ -228,3 +228,180 @@ def test_the_insertion_tableaux(): assert rsk(Perm((2, 3, 0, 1)))[0] == [[0, 1], [2, 3]] assert rsk(Perm((1, 2, 3, 0)))[0] == [[0, 2, 3], [1]] assert rsk(Perm((4, 0, 1, 2, 5, 3)))[0] == [[0, 1, 2, 3], [4, 5]] + + +def test_the_empty_word_and_a_single_letter(): + assert rsk([]) == ([], []) + assert rsk([5]) == ([[5]], [[1]]) + assert rsk_inverse([], []) == [] + assert rsk_inverse([[5]], [[1]]) == [5] + assert evacuation([]) == [] + assert evacuation([[1]]) == [[1]] + assert evacuate_recording_tableau([]) == [] + assert evacuate_recording_tableau([5]) == [5] + + +@pytest.mark.parametrize("size", range(7)) +def test_only_the_order_of_the_letters_matters(size): + """A word of distinct letters that are not 0, 1, ...: relabeling the letters of a + permutation by an increasing map relabels its insertion tableau and the evacuated + word, and leaves the recording tableau alone.""" + + def relabel(letters): + return [3 * letter - 4 for letter in letters] + + for perm in perms(size): + word = relabel(perm) + insertion_tableau, recording_tableau = rsk(word) + standard_insertion, standard_recording = rsk(perm) + assert insertion_tableau == [relabel(row) for row in standard_insertion] + assert recording_tableau == standard_recording + assert rsk_inverse(insertion_tableau, recording_tableau) == word + assert evacuate_recording_tableau(word) == relabel( + evacuate_recording_tableau(perm) + ) + + +def test_the_tableaux_given_are_not_changed_and_may_be_tuples(): + for perm in perms(5): + insertion_tableau, recording_tableau = rsk(perm) + copies = ( + [list(row) for row in insertion_tableau], + [list(row) for row in recording_tableau], + ) + assert rsk_inverse(insertion_tableau, recording_tableau) == perm + evacuated = evacuation(recording_tableau) + assert (insertion_tableau, recording_tableau) == copies + insertion_tuples = tuple(map(tuple, insertion_tableau)) + recording_tuples = tuple(map(tuple, recording_tableau)) + assert rsk_inverse(insertion_tuples, recording_tuples) == perm + assert evacuation(recording_tuples) == evacuated + + +@pytest.mark.parametrize( + "convert", + [ + list, + tuple, + set, + frozenset, + iter, + lambda patterns: (pattern for pattern in patterns), + ], + ids=["list", "tuple", "set", "frozenset", "iterator", "generator"], +) +def test_the_closure_tests_take_any_iterable_of_patterns(convert): + def patterns(*perms_): + return convert([Perm(perm) for perm in perms_]) + + # Knuth closed, but not determined by the shape + assert is_knuth_closed(patterns((0, 2, 1), (2, 0, 1))) is True + assert is_shape_closed(patterns((0, 2, 1), (2, 0, 1))) is False + # neither + assert is_knuth_closed(patterns((0, 2, 1))) is False + assert is_shape_closed(patterns((0, 2, 1))) is False + # both + assert is_knuth_closed(patterns((2, 1, 0))) is True + assert is_shape_closed(patterns((2, 1, 0))) is True + # the empty basis: everything avoids it + assert is_knuth_closed(patterns()) is True + assert is_shape_closed(patterns()) is True + + +def test_the_closure_tests_ignore_the_order_and_the_duplicates_of_the_patterns(): + pair = [Perm((0, 2, 1)), Perm((2, 0, 1))] + for patterns in ( + pair, + pair[::-1], + pair + pair, + [pair[0], pair[1], pair[0], pair[1], pair[0]], + ): + assert is_knuth_closed(patterns) is True + assert is_shape_closed(patterns) is False + assert is_knuth_closed([pair[0]] * 3) is False + assert is_shape_closed([pair[1]] * 3) is False + + +def brute_force_shape_closed(patterns, length): + for size in range(length + 1): + classes = defaultdict(list) + for perm in permutations(range(size)): + classes[shape(perm)].append(Perm(perm)) + for shape_class in classes.values(): + if len({perm.avoids(*patterns) for perm in shape_class}) > 1: + return False + return True + + +@pytest.mark.parametrize( + "basis", + [ + [Perm()], + [Perm(), Perm((0, 2, 1))], + [Perm((0,))], + [Perm((0,)), Perm((0, 2, 1))], + [Perm((0, 1))], + [Perm((1, 0))], + [Perm((0, 1)), Perm((1, 0))], + [Perm((0, 1)), Perm((0, 2, 1))], + [Perm((1, 0)), Perm((1, 2, 0)), Perm((2, 0, 1, 3))], + ], +) +def test_a_pattern_of_length_at_most_2_closes_the_basis(basis): + """Its avoiders are decreasing, or increasing, or the empty permutation alone, and + Knuth moves and their duals leave these alone: every such basis is closed, and the + empty permutation as a pattern, which nothing avoids, is too.""" + length = max(len(pattern) for pattern in basis) + 1 + assert is_knuth_closed(basis) == brute_force_knuth_closed(basis, length) + assert is_shape_closed(basis) == brute_force_shape_closed(basis, length) + + +def test_shape_closure_against_brute_force(): + """Whether the avoiders are determined by the shape, to one entry beyond the + longest pattern, and so for every length.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + generator = random.Random(2028) + bases = [ + list(chosen) + for size in range(1, 4) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [ + generator.sample(perms_of_length_4, generator.randint(1, 8)) for _ in range(30) + ] + bases += [ + generator.sample(perms_of_length_3, 1) + + generator.sample(perms_of_length_4, generator.randint(1, 3)) + for _ in range(15) + ] + closed = 0 + for basis in bases: + length = max(len(pattern) for pattern in basis) + 1 + expected = brute_force_shape_closed(basis, length) + assert is_shape_closed(basis) == expected, basis + closed += expected + assert closed >= 5 + + +def test_a_basis_with_redundant_patterns_is_closed_as_its_minimal_basis_is(): + """The avoiders of a basis do not change when the patterns that contain another + pattern of it are added.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + bases = [ + list(chosen) + for size in (1, 2) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [[Perm((0, 1))], [Perm((1, 0))], [Perm((0,))]] + for minimal in bases: + redundant = [ + perm + for perm in perms_of_length_3 + perms_of_length_4 + if perm not in minimal and not perm.avoids(*minimal) + ] + assert redundant + for extended in (minimal + redundant, redundant[::-1] + minimal): + assert is_knuth_closed(extended) == is_knuth_closed(minimal), extended + assert is_shape_closed(extended) == is_shape_closed(minimal), extended From 475ce0bdf9c9906ad596a0b54247698877deb45c Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Mon, 26 Oct 2026 06:52:29 +0000 Subject: [PATCH 3/5] The module is permuta.permutils.robinson_schensted The review's question 2: permutils exports the function rsk, which hid the module permuta.permutils.rsk, so importing the module gave the function, and monkeypatching it by name failed. Henning chose to name the module for its topic, robinson_schensted, before a release makes the name public; the functions keep their names, and its tests are tests/permutils/test_robinson_schensted.py. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- permuta/bisc/perm_properties.py | 2 +- permuta/permutils/__init__.py | 2 +- permuta/permutils/{rsk.py => robinson_schensted.py} | 0 tests/bisc/test_perm_properties.py | 2 +- tests/permutils/{test_rsk.py => test_robinson_schensted.py} | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename permuta/permutils/{rsk.py => robinson_schensted.py} (100%) rename tests/permutils/{test_rsk.py => test_robinson_schensted.py} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb503f4..4443e288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased ### Added -- `permuta.permutils.rsk`: the Robinson-Schensted correspondence on words of +- `permuta.permutils.robinson_schensted`: the Robinson-Schensted correspondence on words of distinct letters (`rsk`, `rsk_inverse`), Schützenberger's evacuation of standard Young tableaux (`evacuation`), the word with the same insertion tableau and the evacuated recording tableau (`evacuate_recording_tableau`), diff --git a/permuta/bisc/perm_properties.py b/permuta/bisc/perm_properties.py index d2e253d1..25865a55 100644 --- a/permuta/bisc/perm_properties.py +++ b/permuta/bisc/perm_properties.py @@ -3,7 +3,7 @@ from permuta.patterns.meshpatt import MeshPatt from permuta.patterns.perm import Perm from permuta.permutils.groups import dihedral_group -from permuta.permutils.rsk import rsk +from permuta.permutils.robinson_schensted import rsk _SMOOTH_PATT = (Perm((0, 2, 1, 3)), Perm((1, 0, 3, 2))) diff --git a/permuta/permutils/__init__.py b/permuta/permutils/__init__.py index 34bfc812..d945bc60 100644 --- a/permuta/permutils/__init__.py +++ b/permuta/permutils/__init__.py @@ -2,7 +2,7 @@ from .groups import dihedral_group from .insertion_encodable import InsertionEncodablePerms from .polynomial import PolyPerms -from .rsk import ( +from .robinson_schensted import ( evacuate_recording_tableau, evacuation, is_knuth_closed, diff --git a/permuta/permutils/rsk.py b/permuta/permutils/robinson_schensted.py similarity index 100% rename from permuta/permutils/rsk.py rename to permuta/permutils/robinson_schensted.py diff --git a/tests/bisc/test_perm_properties.py b/tests/bisc/test_perm_properties.py index 5ebe4b1e..afa8487c 100644 --- a/tests/bisc/test_perm_properties.py +++ b/tests/bisc/test_perm_properties.py @@ -13,7 +13,7 @@ yt_perm_avoids_22, yt_perm_avoids_32, ) -from permuta.permutils.rsk import rsk +from permuta.permutils.robinson_schensted import rsk def test_smooth(): diff --git a/tests/permutils/test_rsk.py b/tests/permutils/test_robinson_schensted.py similarity index 99% rename from tests/permutils/test_rsk.py rename to tests/permutils/test_robinson_schensted.py index 9270d070..4a50863b 100644 --- a/tests/permutils/test_rsk.py +++ b/tests/permutils/test_robinson_schensted.py @@ -5,7 +5,7 @@ import pytest from permuta import Perm -from permuta.permutils.rsk import ( +from permuta.permutils.robinson_schensted import ( evacuate_recording_tableau, evacuation, is_knuth_closed, From c7446128d7d31318bf0807b815f77dd9974378e6 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Mon, 26 Oct 2026 06:54:16 +0000 Subject: [PATCH 4/5] What is not checked said, tableaux of different shapes refused, the loops commented The review's suggestions 3 to 5: - rsk_inverse raises ValueError for tableaux of different shapes, where it dropped letters or raised a bare KeyError or IndexError; a test checks it. The module's docstring, and each function's, say what is not checked: a word with a repeated letter, a tableau that is not standard, a single Perm for a basis. - The loops a reader must stop at are commented: rsk's for ... else, rsk_inverse's reverse bump, and the closure test's generator. evacuation's locals say what they hold, and its branches are ordered so that the assert for mypy goes. - evacuate_recording_tableau's docstring says it returns a word and defines P, Q and the positions; is_shape_closed's says what is closed under what; the cache's size is explained, and the docstring says that answers are cached. - The BiSC properties unpack the insertion tableau; a test comment names the Knuth class of 1243 rather than a person. - The CHANGELOG's Added entry is a list, and its Changed entry says that the BiSC properties answer as before, and faster (0.043 s against 0.064 s for 9,000 permutations of lengths 8 to 60 on PyPy, the same tableaux). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +++-- permuta/bisc/perm_properties.py | 6 +- permuta/permutils/robinson_schensted.py | 95 ++++++++++++++-------- tests/permutils/test_robinson_schensted.py | 13 ++- 4 files changed, 88 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4443e288..bc117208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased ### Added -- `permuta.permutils.robinson_schensted`: the Robinson-Schensted correspondence on words of - distinct letters (`rsk`, `rsk_inverse`), Schützenberger's evacuation of - standard Young tableaux (`evacuation`), the word with the same insertion - tableau and the evacuated recording tableau (`evacuate_recording_tableau`), - and whether the avoiders of some patterns are closed under Knuth equivalence - (`is_knuth_closed`) or determined by their RSK shape (`is_shape_closed`). +- `permuta.permutils.robinson_schensted`, whose functions `permuta.permutils` + exports: + - `rsk` and `rsk_inverse`, the Robinson-Schensted correspondence on words of + distinct letters; + - `evacuation`, Schützenberger's evacuation of standard Young tableaux, and + `evacuate_recording_tableau`, the word with the same insertion tableau and + the evacuated recording tableau; + - `is_knuth_closed` and `is_shape_closed`, whether the permutations avoiding + some patterns are closed under Knuth equivalence, or determined by their RSK + shape. ### Changed -- The insertion tableau of the BiSC properties `yt_perm_avoids_22` and - `yt_perm_avoids_32` is `rsk`'s. +- The BiSC properties `yt_perm_avoids_22` and `yt_perm_avoids_32` take their + insertion tableau from `rsk`. Their answers are unchanged, and they are faster. ### Fixed - Fixed bug in `Perm.rtlmax_ltrmin_decomposition` and fixed associated tests. diff --git a/permuta/bisc/perm_properties.py b/permuta/bisc/perm_properties.py index 25865a55..ecaba56f 100644 --- a/permuta/bisc/perm_properties.py +++ b/permuta/bisc/perm_properties.py @@ -64,12 +64,14 @@ def _tableau_contains_shape(tab: List[List[int]], shape: List[int]) -> bool: def yt_perm_avoids_22(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [2,2].""" - return not _tableau_contains_shape(rsk(perm)[0], [2, 2]) + insertion_tableau, _ = rsk(perm) + return not _tableau_contains_shape(insertion_tableau, [2, 2]) def yt_perm_avoids_32(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [3,2].""" - return not _tableau_contains_shape(rsk(perm)[0], [3, 2]) + insertion_tableau, _ = rsk(perm) + return not _tableau_contains_shape(insertion_tableau, [3, 2]) _AV_231_AND_MESH_PATT = ( diff --git a/permuta/permutils/robinson_schensted.py b/permuta/permutils/robinson_schensted.py index ca43fb80..ed505603 100644 --- a/permuta/permutils/robinson_schensted.py +++ b/permuta/permutils/robinson_schensted.py @@ -3,6 +3,10 @@ Schützenberger's evacuation of standard Young tableaux, and tests of whether the permutations avoiding some patterns are closed under Knuth equivalence, or are determined by their RSK shape. + +A word here is a sequence of distinct integers, of which only the order matters. A +word with a repeated letter, or a tableau that is not standard where a standard one +is asked for, is not checked for, and gives no meaningful answer. """ from bisect import bisect_left @@ -15,9 +19,9 @@ def rsk(word: Sequence[int]) -> Tuple[Tableau, Tableau]: - """Return the insertion and recording tableaux of a word of distinct letters, - by row insertion. The recording tableau records the steps 1, 2, ..., and both - have the same shape. + """Return the insertion tableau P and the recording tableau Q of a word of + distinct letters, by row insertion. Q holds the step, 1, 2, ..., at which each + cell was added, and both have the same shape, the word's RSK shape. Examples: >>> rsk((1, 3, 0, 2)) @@ -28,6 +32,7 @@ def rsk(word: Sequence[int]) -> Tuple[Tableau, Tableau]: insertion_tableau: Tableau = [] recording_tableau: Tableau = [] for step, letter in enumerate(word, 1): + # insert the letter into the first row, and each letter it bumps into the next for insertion_row, recording_row in zip(insertion_tableau, recording_tableau): position = bisect_left(insertion_row, letter) if position == len(insertion_row): @@ -36,6 +41,7 @@ def rsk(word: Sequence[int]) -> Tuple[Tableau, Tableau]: break letter, insertion_row[position] = insertion_row[position], letter else: + # bumped out of the last row: a new row below it insertion_tableau.append([letter]) recording_tableau.append([step]) return insertion_tableau, recording_tableau @@ -45,9 +51,10 @@ def rsk_inverse( insertion_tableau: Sequence[Sequence[int]], recording_tableau: Sequence[Sequence[int]], ) -> List[int]: - """Return the word whose insertion and recording tableaux are the given ones: - tableaux of the same shape, the recording tableau standard, of the steps 1, 2, - .... + """Return the word whose insertion and recording tableaux are the given ones, as + rsk gives them: tableaux of the same shape, the recording tableau standard, of + the steps 1 to n. Raise ValueError if their shapes differ; whether the recording + tableau is standard is not checked. The tableaux given are not changed. Examples: >>> rsk_inverse([[0, 2], [1, 3]], [[1, 2], [3, 4]]) @@ -55,15 +62,19 @@ def rsk_inverse( """ insertion_rows = [list(row) for row in insertion_tableau] recording_rows = [list(row) for row in recording_tableau] + if [len(row) for row in insertion_rows] != [len(row) for row in recording_rows]: + raise ValueError("The insertion and recording tableaux have different shapes.") row_of = { step: row_index for row_index, row in enumerate(recording_rows) for step in row } word = [0] * len(row_of) for step in range(len(word), 0, -1): + # undo the step: take out the cell it added, and bump its letter back up row_index = row_of[step] recording_rows[row_index].pop() letter = insertion_rows[row_index].pop() for upper in range(row_index - 1, -1, -1): + # the letter bumped out of this row was the largest one smaller than it insertion_row = insertion_rows[upper] position = bisect_left(insertion_row, letter) - 1 letter, insertion_row[position] = insertion_row[position], letter @@ -76,46 +87,53 @@ def evacuation(tableau: Sequence[Sequence[int]]) -> Tableau: 1 to n: delete the smallest entry and slide the hole out by jeu de taquin, n times, writing n, n - 1, ... in the cells vacated. It is an involution that keeps the shape, and the recording tableau of a permutation's reverse-complement - is the evacuation of the permutation's. + is the evacuation of the permutation's. Whether the tableau is standard is not + checked, and the tableau given is not changed. Examples: >>> evacuation([[1, 2], [3]]) [[1, 3], [2]] """ - current = [list(row) for row in tableau] - result: Tableau = [[0] * len(row) for row in current] - size = sum(len(row) for row in current) + remaining = [list(row) for row in tableau] + evacuated: Tableau = [[0] * len(row) for row in remaining] + size = sum(len(row) for row in remaining) for label in range(size, 0, -1): + # the smallest entry is in the corner: slide its hole out of the tableau, + # each time into the smaller of the entries to its right and below it row, column = 0, 0 while True: - right = current[row][column + 1] if column + 1 < len(current[row]) else None - below = ( - current[row + 1][column] - if row + 1 < len(current) and column < len(current[row + 1]) + right_entry = ( + remaining[row][column + 1] if column + 1 < len(remaining[row]) else None + ) + entry_below = ( + remaining[row + 1][column] + if row + 1 < len(remaining) and column < len(remaining[row + 1]) else None ) - if right is None and below is None: - break - if below is None or (right is not None and right < below): - assert right is not None - current[row][column] = right + if right_entry is not None and ( + entry_below is None or right_entry < entry_below + ): + remaining[row][column] = right_entry column += 1 - else: - current[row][column] = below + elif entry_below is not None: + remaining[row][column] = entry_below row += 1 - current[row].pop() - if not current[row]: - current.pop() - result[row][column] = label - return result + else: + break + remaining[row].pop() + if not remaining[row]: + remaining.pop() + evacuated[row][column] = label + return evacuated def evacuate_recording_tableau(word: Sequence[int]) -> List[int]: - """Return the word with the word's insertion tableau and the evacuation of its - recording tableau, RSK^-1(P(w), evac(Q(w))) for the word w of m distinct - letters. It is Knuth equivalent to w, as its insertion tableau is w's, and its - factor at positions m + 1 - j to m + 1 - i has the RSK shape of w's factor at - positions i to j. The map is an involution. + """Return the word whose insertion tableau is the word's, and whose recording + tableau is the evacuation of the word's: RSK^-1(P(w), evac(Q(w))) for the word + w, with P and Q the tableaux rsk gives. The word returned is Knuth equivalent to + w, as its insertion tableau is w's. For w of m letters, its letters in positions + m + 1 - j to m + 1 - i have the RSK shape of w's letters in positions i to j. + The map is an involution. Examples: >>> evacuate_recording_tableau((1, 3, 0, 2)) @@ -143,7 +161,9 @@ def is_knuth_closed(basis: Iterable[Perm]) -> bool: """Return True if the permutations avoiding all the patterns are closed under Knuth equivalence. It is decided locally: an occurrence that a Knuth move destroys uses both letters it exchanges, and with the letter that allows the - move it lies in a permutation at most one entry longer than the pattern. + move it lies in a permutation at most one entry longer than the pattern. The + basis is an iterable of patterns, such as a list, not a single Perm. The answers + are cached by set of patterns. Examples: >>> is_knuth_closed([Perm((0, 2, 1))]) @@ -154,8 +174,12 @@ def is_knuth_closed(basis: Iterable[Perm]) -> bool: return _is_knuth_closed(frozenset(basis)) +# Tilings' Knuth slide asks about the patterns of many tilings in a search, so the +# cache keeps more answers than Permuta's others. @lru_cache(maxsize=1 << 16) def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: + # a permutation one Knuth move from a pattern, or from one of its one-point + # extensions, that avoids every pattern shows that the avoiders are not closed return not any( neighbor.avoids(*patterns) for pattern in patterns @@ -165,9 +189,10 @@ def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: def is_shape_closed(basis: Iterable[Perm]) -> bool: - """Return True if whether a permutation avoids the patterns depends only on its - RSK shape: the patterns and their inverses are both Knuth closed. Monotone - patterns are. + """Return True if whether a permutation avoids all the patterns depends only on + its RSK shape. It does when the set of the patterns and the set of their + inverses are both Knuth closed (is_knuth_closed), as a set of monotone patterns + is. The basis is an iterable of patterns, such as a list, not a single Perm. Examples: >>> is_shape_closed([Perm((2, 1, 0))]) diff --git a/tests/permutils/test_robinson_schensted.py b/tests/permutils/test_robinson_schensted.py index 4a50863b..d1294996 100644 --- a/tests/permutils/test_robinson_schensted.py +++ b/tests/permutils/test_robinson_schensted.py @@ -131,7 +131,7 @@ def basis(*patterns): Perm(perm) for perm in permutations(range(4)) if shape(list(perm)) == (3, 1) ) assert len(shape_31) == 9 and is_shape_closed(shape_31) - # Henning's stable Knuth class: Knuth-closed, but not determined by the shape + # the Knuth class of 1243: Knuth closed, but not determined by the shape stable_knuth_class = basis("1243", "1423", "4123") assert is_knuth_closed(stable_knuth_class) and not is_shape_closed( stable_knuth_class @@ -405,3 +405,14 @@ def test_a_basis_with_redundant_patterns_is_closed_as_its_minimal_basis_is(): for extended in (minimal + redundant, redundant[::-1] + minimal): assert is_knuth_closed(extended) == is_knuth_closed(minimal), extended assert is_shape_closed(extended) == is_shape_closed(minimal), extended + + +@pytest.mark.parametrize( + "insertion_tableau, recording_tableau", + [([[0, 1]], [[1], [2]]), ([[0], [1]], [[1, 2]]), ([[0, 2], [1]], [[1, 2]])], +) +def test_tableaux_of_different_shapes_have_no_word( + insertion_tableau, recording_tableau +): + with pytest.raises(ValueError): + rsk_inverse(insertion_tableau, recording_tableau) From 06102f70dffd313bb7d610781d9ece5d8af9d787 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Mon, 26 Oct 2026 07:22:07 +0000 Subject: [PATCH 5/5] The review's second round: the shape check's tests, its message, two wordings - rsk_inverse's refusal of tableaux of different shapes is tested on the other ways two shapes can differ: rows of other lengths in the same number of rows, the same first row, the same size, and an empty tableau against a non-empty one (the review's ten cases); a check of the number of rows alone passed before. Its message is matched, anchored. - is_shape_closed's docstring said it holds when "the set of the patterns and the set of their inverses are both Knuth closed", which reads as the sets being closed under Knuth moves, and that is not enough: the four permutations of shape (2, 2) are such a set, and the shape class (3, 1, 1) of length 5 splits, 01432 avoiding them and 14203 not. It now says the avoiders are closed, exactly. - The cache's comment compared it with Permuta's others, wrongly, as pin_words has unbounded ones; it says what it keeps. Co-Authored-By: Claude Opus 5.5 --- permuta/permutils/robinson_schensted.py | 10 ++++--- tests/permutils/test_robinson_schensted.py | 34 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/permuta/permutils/robinson_schensted.py b/permuta/permutils/robinson_schensted.py index ed505603..5a96c89a 100644 --- a/permuta/permutils/robinson_schensted.py +++ b/permuta/permutils/robinson_schensted.py @@ -175,7 +175,7 @@ def is_knuth_closed(basis: Iterable[Perm]) -> bool: # Tilings' Knuth slide asks about the patterns of many tilings in a search, so the -# cache keeps more answers than Permuta's others. +# cache keeps up to 65,536 sets of patterns. @lru_cache(maxsize=1 << 16) def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: # a permutation one Knuth move from a pattern, or from one of its one-point @@ -190,9 +190,11 @@ def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: def is_shape_closed(basis: Iterable[Perm]) -> bool: """Return True if whether a permutation avoids all the patterns depends only on - its RSK shape. It does when the set of the patterns and the set of their - inverses are both Knuth closed (is_knuth_closed), as a set of monotone patterns - is. The basis is an iterable of patterns, such as a list, not a single Perm. + its RSK shape. It does exactly when the permutations avoiding the patterns, and + those avoiding their inverses, are both closed under Knuth equivalence + (is_knuth_closed of the patterns and of their inverses), as for monotone + patterns. The basis is an iterable of patterns, such as a list, not a single + Perm. Examples: >>> is_shape_closed([Perm((2, 1, 0))]) diff --git a/tests/permutils/test_robinson_schensted.py b/tests/permutils/test_robinson_schensted.py index d1294996..ad7e4f5b 100644 --- a/tests/permutils/test_robinson_schensted.py +++ b/tests/permutils/test_robinson_schensted.py @@ -414,5 +414,37 @@ def test_a_basis_with_redundant_patterns_is_closed_as_its_minimal_basis_is(): def test_tableaux_of_different_shapes_have_no_word( insertion_tableau, recording_tableau ): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="^The insertion and recording tableaux have different shapes[.]$", + ): + rsk_inverse(insertion_tableau, recording_tableau) + + +@pytest.mark.parametrize( + "insertion_tableau, recording_tableau", + [ + # the same number of rows, a row longer in one and shorter in the other + ([[0, 1, 2]], [[1, 2]]), + ([[0, 1]], [[1, 2, 3]]), + ([[0, 2, 3], [1]], [[1, 2], [3]]), + ([[0, 2], [1]], [[1, 2, 3], [4]]), + # the same first row, a lower row longer or shorter + ([[0, 2], [1, 3]], [[1, 2], [3]]), + ([[0, 2], [1]], [[1, 2], [3, 4]]), + # the same size and number of rows, and different row lengths + ([[0, 2, 3], [1]], [[1, 2], [3, 4]]), + ([[0, 2], [1, 3]], [[1, 2, 4], [3]]), + # an empty tableau against a non-empty one, in both orders + ([], [[1]]), + ([[0]], []), + ], +) +def test_tableaux_of_other_different_shapes_have_no_word( + insertion_tableau, recording_tableau +): + with pytest.raises( + ValueError, + match="^The insertion and recording tableaux have different shapes[.]$", + ): rsk_inverse(insertion_tableau, recording_tableau)