Skip to content

Add the Knuth slide, an equivalence that reverses blocks of columns through RSK - #532

Open
ulfarsson wants to merge 24 commits into
developfrom
knuth-slide
Open

ulfarsson wants to merge 24 commits into
developfrom
knuth-slide

Conversation

@ulfarsson

@ulfarsson ulfarsson commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Adds the Knuth slide, an equivalence strategy that reverses a block of adjacent columns, or of adjacent rows, of a tiling through RSK. It reaches classes that Tilings' sliding does not: with slides of up to three columns, Av(15432) has a specification.

The idea

A block is 2 to max_width adjacent columns with the same rows of active cells. The slide mirrors the block and rearranges its points with RSK and evacuation (the Schützenberger involution), so that:

  • the values of the block's points in every window (the rows that an obstruction's points in the block span) keep their Knuth class;
  • every run (a stretch of adjacent columns of the block) takes the RSK shape of its mirror image.

So the slide is a bijection onto the child tiling when every obstruction on the block depends only on those Knuth classes or those shapes, and every requirement and tracking assumption on it is on whole columns or whole rows of the block. Blocks of rows slide the same way, through the inverse tiling.

What it adds

  • KnuthSlideFactory(max_width=3, *, one_line_only=False, slide_rows=True) and KnuthSlideStrategy(start, width, of_rows=False) (tilings/strategies/knuth_slide.py): every slide of a tiling that changes it, as equivalence rules with forward and backward maps. No built-in pack has it: pack = pack.add_initial(KnuthSlideFactory()).
  • tilings.algorithms.KnuthSlide (tilings/algorithms/knuth_slide.py): finds a tiling's blocks, says whether each slides and why not, and gives the child and the map of gridded permutations. The module's docstring gives the conditions and why they make the slide a bijection.
  • A specification of Av(15432), saved as tests/strategies/knuth_slide_15432.json: row placements, untracked fusion and the slide find an untracked one, and the GuidedSearcher, with that pack, makes from it a tracked one that counts the class.
  • CHANGELOG entries.

The design was agreed with Henning Ulfarsson on 2026-10-19 and 20, who asked for a more general version of Tilings' sliding.

Who will use it

TileScope users with classes that sliding cannot reach; Henning's TileScope runs use it.

Needs a Permuta release

It uses Permuta's robinson_schensted, evacuation and Knuth-closure tests (Permuta#211, not yet merged), which no Permuta release has yet. This pull request should be merged after Permuta#211 and a release with it, with permuta>= raised to it. Until then, CI here, which installs Permuta from PyPI, fails at import tilings.

Tests

In tests/strategies/test_knuth_slide.py, 133 tests:

  • every slide checked as a bijection, and each rule by its sanity check, against brute force over gridded permutations;
  • blocks of two to four columns and one to three rows, in one row of cells and several, of columns and of rows;
  • each condition on the obstructions, requirements and assumptions, with blocks that must not slide and why;
  • the encoding round trip, the keyword-only options, and the specification of Av(15432), which counts the class.

How it was checked

Before submission, over five rounds, by an automated reviewer, against Permuta's branch for #211 and comb_spec_searcher's develop:

  • merged with develop, flake8, mypy, pylint and black pass, and all tests pass on CPython 3.15 and PyPy 3.12 (and on the branch alone, on CPython 3.14 and PyPy 3.11);
  • the block-by-block decisions agree between rounds on 48,000 seeded tilings (173,657 blocks), and random and adversarial searches of about 49,000 tilings find no slide that is not a bijection;
  • mutation testing of both modules, about 90 hand-made faults on the cells, rows and columns the code reads, and brute-force counts of slides to size 5;
  • the slide's own cost, and standard packs on develop and on this branch, which run equally fast.

AI assistance: written with Claude Code (Opus 5.5) as the TileScope agent, and reviewed before submission by Claude Code (Opus 5.5) as an automated reviewer; Henning Ulfarsson checked and opened it.

ulfarsson and others added 22 commits October 20, 2026 08:23
KnuthSlideFactory and KnuthSlideStrategy reverse a block of k >= 2 adjacent
columns (or rows) whose active cells are the same rows in each column. The
block's word w becomes RSK^-1(P(w), evac(Q(w))), the column sizes are reversed,
and each value keeps its row. This keeps P(w), so the Knuth class of w on every
window of values. By Schutzenberger's Q(w#) = evac(Q(w)), every run of columns
gets the RSK shape of its mirror image. The map is an involution.

So the slide is a bijection when:
- every obstruction touching the block constrains a window of values with a
  Knuth-closed set of patterns, or a run of columns with a set determined by the
  RSK shape;
- every requirement touching it is points on whole columns or rows of the block;
- every tracking assumption touching it covers whole columns or rows of it.

Knuth-closure is decided by a local test on permutations one entry longer than
the patterns, cached by pattern set.

The slide contains Tilings' sliding: on Av(1432)'s search, every sliding child
is a Knuth slide child. Blocks of three columns let row placements with fusion
find a specification for Av(15432).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
MonotoneSlidingFactory's rules have no forward or backward maps. On the tilings
of Av(1432)'s untracked search with row placements, and of the tracked search
it guides, every child of MonotoneSlidingFactory is a child of a two-column
Knuth slide, assumptions included (73 slides, 55 with assumptions). So the
Knuth slide contains Tilings' monotone sliding as well as its sliding, and
gives those rules their maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…every condition

Two reviews of the branch found:
- A tracking assumption on a gridded permutation of more than one point was
  accepted, since only each pattern's first cell was looked at, and the rule
  then counted wrong (a pattern touching the block from inside or from
  outside). The slide's proof covers only assumptions on points, as for
  requirements, and now every point of an assumption's patterns is checked.
- A tiling that keeps an empty row or column (not one a search makes) lost it
  in the child, so the map's coordinates no longer fit. Such a tiling is now
  refused.
- Several conditions and paths were never exercised: mutants removing the
  requirement check, either assumption check, the inverse in the transposed
  parameter map or decomposition function, or the transpose option left the
  tests green. New tests cover each: a requirement and an assumption on part
  of a block spanning two rows, with the naive children's counts; assumptions
  on more than a point and counting components; transposed slides with
  assumptions; the factory's transpose option and string; and Av(1432) with
  column placements, a specification of transposed slides, counted, sampled,
  sanity-checked and round-tripped through JSON. Every such mutant now fails.

The closure tests' caches are bounded, for long searches, and the factory's
string shows its options. The CHANGELOG mentions monotone sliding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
As a reviewer asked, the names this pull request introduces say what they
hold: obstruction, requirement, assumption, gridded_perm, pattern, patterns,
indices, positions, column, block_columns, insertion_tableau and
recording_tableau in place of ob, req, ass, gp, patt, patts, idx, pos, col,
cols, p_tab and q_tab, and in the tests size, value, perm, slide_rule, LEFT and
RIGHT in place of n, v, p, r, X and Y. What is public or serialized keeps its
name: the strategy's col, width and transpose, and the parameters the base
classes name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's tests reader found six conditions of the slide that can each
be lost with every test passing, each loss giving wrong slides: the open
upper row bound of a regridding, the number of a window's points below it,
the cells outside the block in the key of a family of regriddings, and the
points and assumptions read past one outside the block. Its tests, added
here, catch each, and also:

- a run not determined by the shape whose obstructions are windows;
- a slide that is the identity exactly when its requirements and
  assumptions are symmetric;
- the factory's rows option, the blocks of a tiling and has_blocks;
- a requirement or an assumption on a whole row of the block;
- a strategy applied to a tiling alone, as when a specification is read
  from JSON, and its extra parameters without the children;
- why_not for blocks of no or one column;
- the insertion tableau against Knuth classes found by the moves alone,
  and the closure test against brute force on unions of Knuth classes,
  which are often closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tilings keeps an obstruction that no gridding can have, and it may be all
that keeps a cell of the block active. Its regriddings were cut to none by
the row bounds, so the child lost the cell, and with it a column (the
review's suggestion 1). The slide now refuses a block with a contradictory
obstruction in it; Tilings' searches never make one.

The row bounds of a regridding go: a window's points outside the block lie
below or above all its block points, in value and so in row, and a run has
none, so for an obstruction that is not contradictory the bounds never cut
a row. On 24,000 random tilings and their inverses, the slides of 9faa8d1
and of this commit agree on all 36,101 blocks but the 95 with a
contradictory obstruction, 6 of which 9faa8d1 slid, and every regridding
made is a valid gridding. _regriddings has fewer locals, and its pylint
disable goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's finding 2: three new pylint disables the description did not
justify.

- The cyclic-import disable under TYPE_CHECKING goes: Tilings' pylintrc
  disables cyclic-import for the whole package.
- _analyse, 97 lines under a disable of too-many-locals, too-many-branches
  and too-many-return-statements that held to the end of the class body, is
  split: _analyze decides whether the block slides and says, in its
  docstring, in which order the proof chooses how to read an obstruction;
  _classify reads each obstruction as a window or a run constraint,
  _closure checks that the runs are determined by the RSK shape and the
  windows closed under Knuth moves, and _window gives an obstruction's
  window key if it is a window constraint. _Constraints holds a block's
  constraints and their patterns, and the window key is a named tuple.
- _regriddings' too-many-locals went with its row bounds (02459fc).

No pylint disable is left. On 12,000 random tilings and their inverses,
the analysis agrees with 9faa8d1's on every block but those with a
contradictory obstruction.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's suggestion 3, duplication:

- _containers was Permuta's Perm.coveredby with the pattern itself;
- mirror_gp was GriddedPerm.apply_map with the mirror of the block's
  columns, now KnuthSlide.mirror, a map of cells;
- _pattern was GriddedPerm.get_gridded_perm_in_cells(block).patt;
- the strategy's transposed maps, through _flip, were TilingInverse's
  gp_transform and inverse_gp_transform, which it now uses.

On 6,000 random tilings, the factory of 9faa8d1 and this one give the same
807 rules (256 with assumptions, 29 of rows), with the same children and
extra parameters, and the same forward and backward maps on all 104,279
gridded permutations of the parents and the children to size 4, but on
two tilings with a contradictory obstruction, where this one gives fewer
rules (02459fc). On 12,000 more, the analyses agree on every block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's suggestion 5:

- slide_gp is slide_gridded_perm, _knuth_neighbours _knuth_neighbors, and
  the strategy's _frame _inverse_if_transposed, with _flip gone (264963c):
  transpose, the serialized option, and inverse, Tilings' own word, are
  the names left for the one thing.
- The docstrings name the parameters there are, in place of patts, (col,
  width), idx and cols x rows; is_identity's says what it checks, the
  runs, requirements and assumptions mirrored, and the module's that a
  contradictory obstruction stops the slide.
- The strategy's to_jsonable starts from the base class's, as Tilings'
  other strategies do, with the same JSON as before; its __str__, the
  base class's own, goes; and its backward map of no object raises, as
  every disjoint union's does, where it yielded nothing.

A test pins the strategy's JSON, as saved specifications have it, and the
backward map of no object.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…heck

- The CHANGELOG's slides of any width had no test past three columns. The
  staircase [Av(54321) | Av(4321) | Av(321) | Av(21)] of Av(165432)'s
  specification, each run of columns up to the last avoiding a decreasing
  pattern together, slides all at once to its mirror image, which a test
  now checks with its counts, its maps and Rule.sanity_check, with the
  blocks that slide and the factory's max_width.
- test_factory_options' <= {2} passed for no rules at all; it is == {2}.
- The two tests against Tilings' sliding and monotone sliding say what
  they check: every child of those rules is the child of a Knuth slide,
  on the tilings of searches of Av(1432).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's suggestion 4 and question 2: RSK, its inverse, evacuation,
the slide of a word and the Knuth- and shape-closure tests are about
permutations, not tilings. Henning chose Permuta for them. They move to
permuta.permutils.rsk (PermutaTriangle/Permuta, branch rsk), where the slide
of a word is evacuate_recording_tableau, and tilings.algorithms.knuth_slide
imports them; its __all__ is KnuthSlide alone. Their tests,
tests/algorithms/test_knuth_slide_alg.py, move with them, to Permuta's
tests/permutils/test_rsk.py.

This needs a Permuta release with permuta.permutils.rsk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's suggestion 6 and question 3: col, a public abbreviation that
is serialized, held a row when transpose is set. Henning chose start, which
fits a block of columns and a block of rows, before the JSON key is frozen:
the parameter, the attribute, the repr and the JSON key of
KnuthSlideStrategy are start, and so is the tests' fixture's key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's suggestion 7: one dense entry left out KnuthSlide, and claimed
slides of any width that no test showed past three columns. The entries
now give the strategy and its factory, the algorithm, KnuthSlide, and what
it finds; slides of four columns have a test (74ed1aa), and RSK and the
closure tests are Permuta's (3ed3e13).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Four single edits gave wrong slides on valid tilings with every test
passing; the review's tests reader wrote a test that each fails:

- windows whose outside points are in other cells, which the window key's
  cells tell apart;
- the families of regriddings of a window, which the rows they span tell
  apart in the completeness cache;
- a requirement or an assumption of more than a point in the block, which
  stops the slide;
- the requirements and assumptions of a transposed block of three.

And for its suggestions: a tiling with an empty row, a contradictory
obstruction away from the block, two assumptions' parameters changing
places, blocks that do not fit the tiling, requirements and assumptions
next to the block, and no contradictory obstruction in a child.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Henning's answer to the review's question 1, round 2: rename the
factory's serialized options before merge.

- rows, true by default, let a block span several rows of cells, but said
  nothing of it, and inside KnuthSlide rows is a tuple of rows. It is
  one_line_only, false by default and inverted, as Tilings'
  CellInsertionFactory(one_cell_only) is: with it, a block of columns lies
  in one row of cells, and a block of rows in one column of cells.
- transpose meant "slide rows too" in the factory, and "this slide is of
  rows" in the strategy, which keeps it. The factory's is slide_rows.

KnuthSlide.blocks and has_blocks take one_line_only too. The factory's
docstring says what each option means for slides of rows and of columns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The review's second round, suggestions 2 to 4:

- A block's columns, rows and cells, passed together through four
  methods, are a named tuple, _Block, which also gives the indices and the
  pattern of a gridded permutation's points in it, written five times.
- The mirror of a column is _mirror_column, where it was computed three
  ways, and last no longer means two things.
- _closure is _check_closures, as it also moves obstructions from the runs
  to the windows; _window is _as_window_constraint; _analysis is
  _cached_analysis.
- _analyze says why its order of choices is right, rather than pointing to
  a proof not in the repository; the module docstring says why each
  condition keeps the slide a bijection, that the map is an involution,
  and what the Ferrers version is.
- _regriddings' docstring is in short sentences, without "consecutive in
  position"; is_identity's says why the windows are the same mirrored;
  slide's says that it raises.
- The strategy's two maps through the inverse share _in_columns.
- The block of four columns is counted to size 6, not 7, half the file's
  time on CPython, with nothing lost.
- The CHANGELOG names max_width and the options, says in words what the
  slide does, and that Tilings needs a newer Permuta.

On 12,000 random tilings and their inverses the analysis agrees with
9faa8d1's on every block but those with a contradictory obstruction, and on
3,000 more the factory gives the same rules, children, parameters and maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… more

The review's block, as handed over:

- blocking 1: a window in the top row, or the bottom row, of a block of
  two rows must be closed under Knuth moves, for Av(021) and Av(102) with
  the 10s of three of the cells forbidden; the block's pattern read
  without either row passed every other test and changed the counts;
- a window with a point to the right of the block slides;
- one_line_only leaves out the blocks of several columns of cells, for
  blocks of rows too;
- a requirement or an assumption away from the block does not stop the
  slide;
- the factory's JSON keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A run whose patterns are not determined by the shape leaves out those
  of its obstructions that are window constraints, as the windows hold
  them already: such an obstruction lies in one column, and its gridding
  with its last point in the next column, or its first in the column
  before, is forbidden; the obstructions being minimal, that gridding is
  an obstruction itself, of the same pattern and rows, so of the same
  window. Moving them into the windows, as before, added nothing, which
  is why the review's three faults of that move passed every test
  (question 4). _classify leaves them out, and _check_closures is a pure
  check.
- The runs keep each obstruction with its pattern, and their patterns
  are read from them. The analysis keeps the obstructions to regrid, each
  with its columns and rows, so the slide has one loop of regriddings.
  The mirrored span is _mirrored_span, the requirements' and assumptions'
  points in the block are read by _point_cells, and _block is
  _Block.of. _as_window_constraint is _window_constraint_key, as it
  returns a key.
- The strategy: "the inverse if transposed" is _columns_frame, for the
  strategy and the factory; _in_columns is _apply_in_columns, whose
  function's argument is in_columns; d is jsonable.
- The module docstring defines P, Q, evac and an obstruction's window,
  says why every regridding must be forbidden and why a contradictory
  obstruction is refused, counts the reversed columns without an
  undefined c, gives the window condition its rows, says which of a
  requirement's gridded permutations must be points, and no longer says
  that a column's points move to its mirror. _regriddings' "any of" and
  _analyze's sentence on a column's shape are back in words.
- The factory's docstring and the CHANGELOG give the options' defaults
  and say that columns always slide (question 3); the CHANGELOG says
  how to add the slide to a pack and to search for Av(15432).

On 12,000 random tilings and 6,000 with blocks of two and three rows,
every block's reason, identity and child are those of c37d70c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Henning's answers to the review's questions 1 and 2 (2026-10-26):

- The strategy's transpose, which the factory's slide_rows names
  differently, is of_rows: KnuthSlideStrategy(start, width, of_rows),
  and so in its JSON, its repr, has_blocks and the factory's loop. Saved
  specifications with transpose are not read by this release, as those
  with col are not.
- The factory's one_line_only and slide_rows, and one_line_only in
  KnuthSlide.blocks and has_blocks, are keyword-only. The factory's
  second argument was rows=True before the rename, so a caller who passed
  it by position would now get the opposite; such a call fails loudly.
  A test says so.

The tests, the encoding test and the CHANGELOG follow; the tests named
for transposed slides are named for slides of rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Round 4 leaves the window constraints out of a run only when the run's
patterns are not determined by the RSK shape. Leaving them out of every
run passed every test, though it refuses valid slides: a subset of a
shape-closed set need not be shape-closed. Here each cell's basis is
shape-closed, but without 132 and 312, which the two cells also avoid
together, it would not be, as 213 and 231 have the shape of 132. The
slide swaps the bases, checked through its maps and with the rule's
sanity check to size 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ulfarsson and others added 2 commits October 28, 2026 09:54
…s over two rows

No test built a block of more than two rows, so nothing told the rows a
window's points span from its lowest and highest row, or the block's
rows from its first and last. Reading a block without its middle row,
in _window_key or in _Block.pattern, passed every test and slid tilings
with wrong counts. The first test is a tiling whose run is {201}, which
the shape does not determine, refused for that reason, with its counts
to size 5. The second slides a Ferrers block of three rows, with a
requirement and an assumption on each row in turn, checked as a
bijection and by the rule's sanity check. The third slides a staircase
of three columns over two rows to its mirror image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ings, the CHANGELOG

- The leave-out of window constraints from a run whose patterns are not
  determined by the shape is a method of its own,
  _leave_out_window_constraints, so that _classify only classifies. Its
  docstring gives the argument in numbered steps, with the step the old
  comment skipped: an obstruction using the moved point has points in
  both columns, so its own regriddings are forbidden, and the one that
  moves the point back lies inside the original. It also says why a run
  whose patterns are determined keeps them: a subset of a shape-closed
  set need not be shape-closed.
- The module docstring defines a regridding where it first appears,
  says that an obstruction of one column can be of both types and what
  a run that is not shape-closed does with it, and that the patterns of
  a run must be shape-closed once it has done so. It no longer says that
  Tilings makes no contradictory obstruction, which nothing checks.
- has_blocks takes of_rows by keyword only, as blocks and the factory
  take their options, and the test of keyword-only options says so.
- The factory's docstring: blocks of columns are tried whatever
  slide_rows is, where "columns always slide" read as every column.
  has_blocks' docstring reads "or, with of_rows, a block of rows".
- The CHANGELOG says what a window and a run are, that add_initial
  returns a new pack, and mends the sentence on Av(15432).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant