Skip to content

Find one gridded permutation for Tiling.is_empty by placing the requirements - #539

Open
ulfarsson wants to merge 33 commits into
developfrom
is-empty-depth-first
Open

ulfarsson wants to merge 33 commits into
developfrom
is-empty-depth-first

Conversation

@ulfarsson

@ulfarsson ulfarsson commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Tiling.is_empty is now much faster on tilings with many requirement lists, such as those of TileScope's insertion encodings. A gridded permutation is a permutation with each point in a cell of a grid. A tiling has obstructions, gridded permutations that no gridded permutation of the tiling may contain, and requirement lists, each asking that every gridded permutation of the tiling contain one of the list's gridded permutations. is_empty used to ask MinimalGriddedPerms for the first of the tiling's minimal gridded permutations, which it builds shortest first. It now asks a new search, GriddedPermSearch, for any one.

The change is the new module tilings/algorithms/gridded_perm_search.py, its export from tilings/algorithms/__init__.py, the call in Tiling.is_empty in tilings/tiling.py, two entries in the CHANGELOG, and the tests in tests/algorithms/test_gridded_perm_search.py and tests/test_tiling.py. MinimalGriddedPerms is unchanged, as its other users need the minimal gridded permutations.

For example, on a row of ten cells, each with a point requirement and no obstructions, is_empty used to build every order of the points in the first nine cells before it found one: 2.1 s under PyPy 3.12 and 5.8 s under CPython 3.15. It now takes 0.3 ms and 0.09 ms:

>>> from tilings import GriddedPerm, Tiling
>>> row = Tiling(requirements=[[GriddedPerm.point_perm((i, 0))] for i in range(10)])
>>> row.is_empty()
False

In a ten-minute TileScope search for Av(123465, 564321) with TileScopePack.regular_insertion_encoding(DIR_SOUTH), the 25,168 calls of is_empty took 185.5 s before and 7.5 s after, under PyPy.

Below, two cells side by side, each with a point requirement, and the decreasing pair across them an obstruction: the search finds the increasing pair. With the increasing pair an obstruction too, the tiling is empty.

>>> from tilings import GriddedPerm, Tiling
>>> from tilings.algorithms import GriddedPermSearch
>>> left = GriddedPerm.point_perm((0, 0))
>>> right = GriddedPerm.point_perm((1, 0))
>>> decreasing = GriddedPerm((1, 0), ((0, 0), (1, 0)))
>>> tiling = Tiling([decreasing], [[left], [right]])
>>> GriddedPermSearch(tiling.obstructions, tiling.requirements).find()
GriddedPerm((0, 1), ((0, 0), (1, 0)))
>>> tiling.is_empty()
False
>>> increasing = GriddedPerm((0, 1), ((0, 0), (1, 0)))
>>> Tiling([increasing, decreasing], [[left], [right]]).is_empty()
True

Every class timed below is faster, or level where almost no tiling needs a search, and the answers are the same on every tiling checked. Some small tilings take a few milliseconds longer. Two families built against the search's order, one of them also in a form given straight to GriddedPermSearch, take many times as long as before, the more so the larger they are: at 90 point lists under CPython 3.15, one 58 s where it took 0.044 s, the other over 60 s where it took 8.5 s. No TileScope search checked meets either ("Where it is slower", below).

Details

Why it was slow. Tiling.is_empty asked MinimalGriddedPerms.minimal_gridded_perms(yield_non_minimal=True) for a first gridded permutation. That search takes its work packets shortest first, as listing the minimal gridded permutations needs, so before it yields one it builds every shorter gridded permutation that avoids the obstructions. On a row of n cells, each with a point requirement, that is every allowed order of the points in the first n − 1 cells, up to (n − 1)! of them.

Such tilings come up in TileScope searches with requirement insertions. In a search for Av(123465, 564321) with TileScopePack.regular_insertion_encoding(DIR_SOUTH), the emptiness checks grow from about 0.01 s each to several seconds, until they take most of the search. The slowest are all rows of nine cells, each with a point requirement and none a point cell, with up to about 1200 obstructions, and none of them is empty.

The search. is_empty needs one gridded permutation, not the minimal ones. MinimalGriddedPerms does much that only listing them needs: a goal, one requirement of each list, fixed at the start; an initial gridded permutation merged from it; points inserted into a cell only to the right of the last one inserted there; and a rule for a packet made a second time. GriddedPermSearch has none of these. It places the points of one requirement at a time, in the code's words:

  • A state is a rest, a gridded permutation gridded_perm that avoids the obstructions, or a placing: such a gridded_perm, a requirement being placed in it, and images, the indices of the points of gridded_perm that the requirement's leftmost points went to. At a rest, the first list that gridded_perm does not satisfy, that is, of which it contains no gridded permutation, is placed: there is a placing for each of its requirements. A rest that satisfies every list is returned.
  • The requirement's next point goes to a point of gridded_perm in its cell, or to a new point inserted into gridded_perm there: to the right of where the requirement's point before it went, above the points that its points below it went to, and below those that its points above it went to. So every point inserted is a point of a requirement, put where the requirement's own order allows. When the requirement is placed in full, the gridded permutation is a rest again, one list more of it satisfied.
  • If a gridded permutation on the tiling contains gridded_perm, and the requirement with its first points at the points that images give, then it contains a child's too: the point of it that the requirement's next point is at is a point of its occurrence of gridded_perm, or it contains gridded_perm with that point inserted, which so avoids the obstructions. So the search finds a gridded permutation whenever there is one.
  • A state's children depend on the state alone, so a state made a second time is skipped. Whether a gridded permutation is found does not depend on the order in which states are taken, the order of the lists, or which list a rest places; which one is found can.

Making it fast.

  • Depth first, each state's children made only as they are taken, so a state that leads to a gridded permutation costs no obstruction check of its siblings; the points of gridded_perm before new points. A new point in a cell is checked only against the obstructions that use the cell and no cell the gridded permutation does not use, the obstructions grouped by their cells once a call. This does what MinimalGriddedPerms.insert_point and its obstruction checks do, written again for speed.
  • A first pass of at most 64 states, with no look-ahead, the lists whose requirements each lie in one cell first, and of lists alike in that, the one with the longest requirement first. Of the 194,901 tilings of the twelve searches below that reach a search, it decides all but 3.
  • If it does not, the lists are split into groups: two lists are in one group if their cells meet, or an obstruction uses cells of both. Each group is searched alone, and the gridded permutations found for the groups are put together, in each column and each row the points of one group before those of the next. That is a gridded permutation on the tiling: an occurrence of an obstruction in it would use the cells of one group only, and so be in that group's gridded permutation. So the tiling has one if and only if each group has.
  • The groups go smallest first, by the sum over their lists of the length of each list's longest requirement, and with two groups or more each has a first pass of its own. Then the groups not yet decided are searched in rounds, one search of each in a round, each round with twice the budget of the one before (128, 256, …). A search that finishes decides its group, and the rounds stop as soon as a group has no gridded permutation, or every group has one. So an empty group is found at the cost of its own searches, beside a group whose search is long.
  • Each search of the rounds places first the lists that have met the most dead ends so far: placings whose next point could go nowhere, and rests dropped by the look-aheads below. A list that keeps failing late is so placed early, and what it rules out is then ruled out by the obstruction checks of the points placed after it. The first passes keep the search's order.
  • An obstruction joins two lists if it uses a cell of each. A dead end also counts, as a dead end of a joined list, for each list that its rest satisfies and that an obstruction joins to the list that met it, as that list's points may be what shut it out. Of three lists that cannot all hold, only the last one placed meets dead ends; so the other two come forward with it.
  • A list meets dead ends only once a search reaches it, so three more keys order the lists that have met as many: first the most dead ends of joined lists; then the most obstructions on the list's cells, counting only those all of whose cells the lists use, since the search puts points in no other cell (as a constraint solver tries first a variable that the most constraints use); then the shortest longest requirement, which tends to be the quickest to place, or to find that it cannot be placed. Without the dead ends of joined lists, or without the obstructions, or with every obstruction on the list's cells counted, the order loses on a family built against it, which this order decides in milliseconds (below).
  • These searches have two look-aheads, each dropping a rest that no gridded permutation on the tiling contains: one with a list it does not satisfy that cannot be placed in it alone (the placing above, with that list only), or that needs, in each of its requirements, more points in a cell than the rest has there, where no point can be inserted into the rest without an obstruction. A look-ahead's search takes its states from the budget of the search it looks ahead for, so that a search takes at most its budget's states in all. With the lists in the order given, on a row of three cells with two obstructions and four lists, where the first requirement of the first list contradicts the third list through both obstructions, the search takes 108 states with the look-aheads, theirs among them, and 64,330 without.

Timings, by CPU time, both versions on the same tilings in one process, in turn.

Every distinct tiling that is_empty was asked about in the first two minutes of a search (one minute for Av(12453), Av(123) and Av(1243, 2134), and all of the search, for Av(12354, 45321)), timed all at once, the least of three passes after a first under PyPy and of two under CPython, before → after, in seconds:

Class Pack Tilings PyPy 3.12 CPython 3.15
Av(123465, 564321) regular_insertion_encoding(DIR_SOUTH) 10,938 10.68 → 0.492 34.81 → 1.12
Av(12354, 45321) regular_insertion_encoding(DIR_SOUTH) 6,977 3.40 → 0.237 10.01 → 0.639
Av(1234) insertion_row_and_col_placements() 74,380 9.56 → 1.37 20.81 → 3.23
Av(45123, 53412, 462513, 536142, 562143, 562413, 563142, 4627153) insertion_point_placements() 18,018 3.11 → 0.709 5.96 → 1.28
Av(12453) insertion_point_placements() 34,779 4.04 → 0.700 8.80 → 1.56
Av(12543, 13254, 13524, 13542, 21543, 125364, 125634, 215364, 215634, 315264, 315624, 315642) point_placements(length=2) 11,428 0.422 → 0.194 0.692 → 0.351
Av(4231) point_placements() 47,567 0.332 → 0.223 0.790 → 0.509
Av(1342) pattern_placements() 18,381 0.214 → 0.190 0.524 → 0.458
Av(123) point_and_row_and_col_placements() 33,130 0.130 → 0.102 0.279 → 0.238
Av(1243, 2134) requirement_and_row_and_col_placements() 24,241 0.091 → 0.084 0.181 → 0.172
Av(1324) requirement_placements() 72,586 0.064 → 0.057 0.086 → 0.087
Av(2413, 3142) row_and_col_placements(row_only=True) 8,501 0.015 → 0.014 0.022 → 0.022

In Av(1324) and Av(2413, 3142), almost no tiling needs a search (191 of 72,586, and none of 8,501), and the two are about level; their totals, and Av(1243, 2134)'s, move by up to a fifth from run to run.

In ten-minute searches under PyPy 3.12, every call of is_empty answered both ways, alternately first, in seconds:

Class Calls Before After
Av(123465, 564321) 25,168 185.5 7.5
Av(45123, 53412, 462513, 536142, 562143, 562413, 563142, 4627153) 103,348 51.2 14.6
Av(12543, 13254, 13524, 13542, 21543, 125364, 125634, 215364, 215634, 315264, 315624, 315642) 70,238 13.7 8.0

The five slowest tilings of the first search take 7.78 s in all before under PyPy 3.12 (32.8 s under CPython 3.15), and 0.007 s after (0.004 s).

Random tilings. On 5,655 random tilings that need a search, of two or three cells in a row or a column, or 2 × 2, with up to three obstructions and two to four requirement lists of two to four points (33 of them empty), the least of three runs each: 23.3 s before and 0.52 s after under PyPy 3.12 (24.7 s and 0.58 s in a second run), and 52.7 s and 0.37 s under CPython 3.15. The 33 empty, one run each, take 11.9 s before and 0.056 s after under PyPy, and 32.9 s and 0.033 s under CPython.

Families built to be hard, the least of three calls, before → after, in seconds:

Family Size PyPy 3.12 CPython 3.15
A list chosen early that no gridded permutation takes n = 6 0.043 → 0.044 0.108 → 0.059
n = 7 0.075 → 0.042 0.196 → 0.087
n = 7, 01234 for 0123 0.618 → 0.050 1.86 → 0.102
Three lists that cannot all hold m = 5 0.0009 → 0.0012 0.0003 → 0.0009
m = 8 0.0008 → 0.0014 0.0003 → 0.0009
the same, the m cells left of the three m = 4 1.39 → 0.0013 4.89 → 0.0008
An empty group beside a hard one m = 5, n = 6 0.0029 → 0.0026 0.0005 → 0.0008
m = 5, n = 7 0.0020 → 0.0037 0.0005 → 0.0008
the two, the other way round m = 4, n = 6 4.22 → 0.0046 13.89 → 0.0047
the hard group alone m = 4, n = 6 6.52 → 0.0041 8.59 → 0.0052
The two joined into one group m = 5, n = 6 0.019 → 0.0046 0.0038 → 0.0022
m = 5, n = 7 0.013 → 0.0038 0.0039 → 0.0024
Several hard groups joined to the three lists two, m = 5, n = 6 0.034 → 0.0068 0.010 → 0.0032
three 0.029 → 0.0053 0.027 → 0.0030
four 0.045 → 0.0065 0.070 → 0.0029
Point lists beside three longer lists that cannot all hold 48 in their row 0.025 → 0.012 0.033 → 0.0085
192 in a column 0.040 → 0.016 0.257 → 0.022
the same, each point list joined to each of the three, beside six copies of the hard group 12 point lists 1.10 → 0.013 3.02 → 0.0080
48 1.56 → 0.012 5.13 → 0.015
the same, with padding cells above each point cell, given straight to the search 24 point lists, 80 cells 1.21 → 0.029 3.71 → 0.015
Point lists each joined to every other, the first to the three 50 0.014 → 0.010 0.036 → 0.014
  • A list chosen early that no gridded permutation takes: three cells A, B and C in a row; if B has a point, A is monotone (16 obstructions); the lists n…21 in A, 0123 in A or in C, and a point in B. The gridded permutations on the tiling put 0123 in C. Without the restarts, the search placed 0123 in A in every way before it met the point in B.
  • Three lists that cannot all hold, beside m lists that have nothing to do with them: a point in each of three cells in a row, every order of the three points an obstruction, and m cells in a row above, each needing 01. Without the groups and the restarts, the search placed the 01s in every order of their values, and met the third point last under each.
  • An empty group beside a hard one: the three point lists that cannot all hold, and beside them, in cells a, b and c of their own, the hard group: n…21 in a; 12…m in a, or in c; a point in b; and 12…m in a, with a point in b at each of its m + 1 values, an obstruction. The hard group has a gridded permutation, which puts 12…m in c, and many to make before it is found. Without the rounds and the smaller group first, the search found it before it looked at the three point lists.
  • The two joined into one group: three lists, each a point in one of two cells, with every order of a point from each pair an obstruction, and the obstruction 021 with its first point in the first of those six cells and two in c, which joins the two parts. Without the look-aheads' states counted in their search's budget, one look-ahead placed 12…m in a in every way.
  • Several hard groups joined to the three lists: H copies of the hard group, each in cells of its own, each joined to the three lists in the same way. With the lists that have as many dead ends left in the search's order, each round put one more copy's point in b first, and the three lists only after all of them, each round twice as long as the one before. With the three lists made of 12…L in place of points, with three copies, the search takes 0.17 s at L = 5 and 2.3 s at L = 7 under PyPy, and 0.70 s and 11.0 s under CPython, where develop takes over 30 s, or passes 2 GB of memory, from L = 4.
  • Point lists beside three longer lists that cannot all hold: three lists, 01 in one of two cells each, every order of a point from each pair an obstruction, and K point cells right of them in their row, each with room for one point and joined to the first cell by 10; or, in a column, each in a row of its own, joined by 021. The first pass meets the dead ends of the last of the three only. Without the dead ends of joined lists, and the obstructions on a list's cells, the rounds put every point list before the other two of the three, and each search placed all K points before it met them.
  • The same, each point list joined to each of the three, beside six copies of the hard group, which the first pass places first: when the three meet their dead ends, the point lists are as joined to them as the three are to each other. Without the obstructions on a list's cells, the points went first.
  • The same, with cells above each point cell that no list uses, each joined to it by 01: more obstructions use a point cell than a cell of the three, but only for those cells, where the search puts no point. With every obstruction on a list's cells counted, the points went first.
  • Point lists each joined to every other by 10, the first joined to the three: more obstructions use a point cell than a cell of the three, of those all of whose cells the lists use, but the first pass meets the last of the three at rests that satisfy the other two. Without the dead ends of joined lists, the points went first.

Where it is slower.

  • Random tilings: on 6 of the 5,655 random tilings, under PyPy 3.12 and under CPython 3.15, the search takes more than twice the time before and 1 ms on top, all of them past the first pass: the worst 0.9 → 12.4 ms and 2.2 → 9.3 ms under PyPy, and 1.0 → 12.4 ms under CPython. Three of them are empty, and take 0.1 to 0.3 ms before and 1.4 to 1.9 ms after. In the slowest, two of its three lists have two requirements of four points each; its look-aheads take their states from its searches' budgets, so two searches run over before one finishes.
  • Three lists that cannot all hold, in the table above: about 1.2 to 1.4 ms after under PyPy, where they took 0.8 to 0.9 ms before, and 0.9 ms under CPython, where they took 0.3 ms. Given straight to GriddedPermSearch, with each of the m cells above joined to the first of the three cells by an obstruction that needs two points there, which no gridded permutation of the search has, so that the lists are one group: 4.3 to 5.2 ms for m = 4 to 12 under PyPy, against 1.5 to 3.0 ms before, and 1.2 to 1.3 ms under CPython, against 0.3 to 1.0 ms. A Tiling built from these drops the joining obstructions.
  • The smallest calls of a search: in the ten-minute search for Av(12543, 13254, 13524, 13542, 21543, 125364, 125634, 215364, 215634, 315264, 315624, 315642), the 20,796 calls that take under 0.1 ms before took 0.59 s before and 0.60 s after in the run above, and 1.12 to 1.32 times as long before in earlier runs. Every other group of calls by their time before is faster after, or level, in each of the three searches, among them the 139 empty calls of 1 to 10 ms of the search for Av(45123, 53412, 462513, 536142, 562143, 562413, 563142, 4627153), 0.36 s before and 0.29 s after.
  • Families built against the rounds' order. No tiling of the TileScope searches above goes past the first pass, so none reaches the rounds, and none has a list with a requirement across two cells or more than 10 lists.
    • Point lists joined to each of the three lists that cannot all hold, and to each other twice (by 10, and by 021, which needs two points where the search puts one), beside five or more copies of the hard group, which the first pass places first.
      • From 45 point lists on, as many of the obstructions the order counts use a point cell as use each of the second and third of the three, and the shortest goes first: the rounds place every point list before those two.
      • From 51 on, with six copies, the same holds of the first of the three as well, and the dead ends of joined lists count for the points as for the three: no key of the order tells them apart, and the rounds place every point list first.
      • With four copies or fewer the first pass reaches the three, and the search is quick.
      • At 44 point lists with six copies, 1.3 s before and 0.030 s after under PyPy 3.12, and 4.7 s and 0.016 s under CPython 3.15; at 45, 1.3 and 1.8 s, and 4.8 and 9.1 s; at 60, 1.5 and 11.3 s, and 5.9 s and over 60 s; at 90, 1.9 and 8.5 s before, and over 60 s after under both.
    • Lists across two cells that cannot all hold, beside point lists each joined to every other, one of them joined to the lists: three lists, 01 across two cells each, with every order of a point in their right-hand cells an obstruction (or two such lists), and K point cells right of them in their row, each with room for one point and joined to every other by 10.
      • The lists span two cells, so the search's order puts the point lists first, and the first pass ends among them. The rounds start with no dead end, so the order is the obstructions', and the points go first.
      • Each search then places every point before it meets the lists, and the budget doubles until one gets through.
      • It needs 28 point lists or more with three lists, 32 with two. With three lists, through Tiling.is_empty: at 28 point lists, 0.013 s before and 0.56 s after under CPython 3.15, and 0.005 and 0.12 s under PyPy 3.12; at 60, 0.025 and 10.9 s, and 0.006 and 1.7 s; at 90, 0.044 and 58 s, and 0.008 and 7.7 s.
      • In four of its eight symmetries the code before is the slow one, making over 800,000 gridded permutations at 40 point lists where the search makes under 90,000.
    • Point lists joined to each other beside three lists that cannot all hold, with five copies of the hard group, given straight to GriddedPermSearch: the three lists 01 in one cell each, and K point cells each two joined by 10, the first joined to the first of the three. At 30 point lists, 0.21 s before and 2.6 s after under CPython 3.15, and at 60, 0.33 and 16 s; under PyPy 3.12, 3.1, 8.8 and 38 times the time before at 10, 30 and 60. A Tiling rewrites its joins to the hard groups, and in the form a Tiling keeps it is the family above, which at 90 point lists makes 41 times the gridded permutations the code before makes.

Tests (tests/algorithms/test_gridded_perm_search.py, and tests/test_tiling.py):

  • test_find_against_a_brute_force: 60 random small tilings, with a fixed seed (up to 3 × 1 or 2 × 2 cells, two or three lists of one or two requirements of up to three points, at most five points in a minimal gridded permutation, up to three obstructions), against every gridded permutation up to the length a minimal one can have; with the lists, and the requirements in each, in the search's order and reversed; with the first pass as it is, and with none, so that every tiling is also split into groups and searched in rounds with the look-aheads; and Tiling.is_empty on each.
  • test_find_agrees_with_minimal_gridded_perms_on_the_brute_force_tilings: the same 60 tilings against MinimalGriddedPerms, the way is_empty asked before.
  • test_find_every_order_of_the_lists, test_find_every_order_but_one (four cells in a row, or in a column, each with a point requirement, and every order of the points but one an obstruction), and test_find_the_only_gridded_perm (the tiling whose only gridded permutation is 516374280).
  • The states: test_search_makes_a_rest_and_a_placing_once, test_can_place_makes_a_placing_once and test_find_a_list_whose_gridded_perms_start_alike (the requirement is part of a placing's key: two requirements of a list can place their first points alike), test_search_counts_its_states and test_search_counts_the_states_of_its_look_aheads, test_can_place_takes_the_states_left, test_search_remembers_the_lists_a_rest_satisfies, test_unsatisfied_returns_the_lists_it_checked, test_avoids_keeps_the_obstructions_to_check, the order of the lists (among them test_requirements_puts_a_list_across_cells_after_the_single_cell_lists: a list goes with the lists in one cell only if each of its requirements lies in one cell) and of each list, and _bounding_box against GriddedPerm.get_bounding_box.
  • The groups and the rounds: test_groups, test_groups_smallest_first, test_groups_are_ordered_by_the_longest_gridded_perm_of_each_list, test_groups_an_empty_list_weighs_nothing, test_combined and test_combined_puts_an_earlier_gridded_perm_left_and_below, test_find_groups_alone, test_find_two_groups_apart_are_searched_alone, test_each_group_has_a_first_pass, test_two_groups_each_have_a_first_pass_of_their_own, test_a_single_group_has_no_first_pass_of_its_own, test_each_group_has_a_first_pass_in_the_search_order, test_groups_are_searched_in_rounds, test_find_stops_at_the_first_group_with_none, test_find_does_not_search_a_group_after_one_with_none, test_small_tilings_split_into_groups and test_find_few_lists_past_the_first_pass.
  • The restarts and the dead ends: test_dead_ends_put_a_list_first, test_find_places_the_lists_with_the_most_dead_ends_first (more dead ends first, then the shortest, ties as short in the search's order), test_find_places_more_dead_ends_before_a_shorter_list, test_each_round_orders_the_lists_by_the_dead_ends_so_far, test_restarts_double_their_budget (with a first pass of four states, so that the rounds take several budgets), test_restarts_with_no_first_pass_start_at_two_states, test_find_with_the_dead_ends_first (with a timeout of 1 s), test_find_twice_on_one_search and test_find_twice_starts_the_dead_ends_of_joined_lists_afresh, test_unmade_children_counts_each_dead_end, test_dropped_counts_a_dead_end, test_dropped_counts_each_dead_end and test_dropped_counts_the_dead_end_for_an_empty_list_not_for_one_before_it, test_can_place_counts_the_dead_ends_it_meets, test_find_past_the_first_pass (timeout 1 s) and test_find_two_lists_that_cannot_both_hold.
  • The order's keys: test_count_dead_end_counts_it_for_the_joined_lists_its_rest_satisfies, test_count_dead_end_counts_every_joined_list_its_rest_satisfies, test_count_dead_end_adds_to_the_dead_ends_of_joined_lists, test_count_dead_end_joins_lists_by_an_obstruction_and_not_by_a_shared_cell and test_count_dead_end_reads_the_lists_it_is_given_by_their_indices; test_unmade_children_counts_a_dead_end_for_the_joined_lists_it_satisfies, test_unmade_children_and_dropped_read_the_lists_they_are_given and test_can_place_counts_no_dead_end_of_a_joined_list; test_list_cells (a list's cells, the cells joined to them, and the obstructions on them that the order counts, each counted once) test_list_cells_keep_what_they_work_out (each of its five caches asked twice) and test_list_cells_count_the_points_of_each_gridded_perm_in_each_cell; test_ordered_by_dead_ends_breaks_ties_by_each_key_in_turn, test_ordered_by_dead_ends_puts_each_key_before_the_next, test_ordered_by_dead_ends_weighs_a_list_by_its_longest_gridded_perm, test_ordered_by_dead_ends_keeps_the_order_of_lists_alike_in_every_key, test_ordered_by_dead_ends_weighs_an_empty_list_as_nothing, and test_find_point_lists_joined_to_each_other_beside_lists_that_cannot_all_hold (timeout 1 s), which the search exceeds without the dead ends of joined lists.
  • test_find_with_a_list_a_gridded_perm_and_an_obstruction_given_twice and test_find_takes_obstructions_and_lists_that_are_iterators: the search is given what it is given, where a Tiling keeps nothing twice, and takes generators, as its types say.
  • The look-aheads: test_look_aheads_cut_the_search (at most 200 states, of the 108 the search takes with them), test_can_place, test_closed_out and test_closed_out_a_cell_with_the_points_the_list_needs.
  • An empty list, the empty permutation as an obstruction, a requirement that contains an obstruction alone in its list, a requirement whose points cannot be in their cells, one list or none, an empty list sorted after the others, a point of the gridded permutation used by two requirements (which pins the order: points of gridded_perm before new points), a cell no list needs in full, the second requirement of a list, and a column of two cells whose lists put points in both, in turn.
  • The examples in the docstrings of GriddedPermSearch and find, which run as doctests.
  • Every test of the search's file has a timeout, so that a loop that never ends fails its test: 10 s, and 1 s for the three above. The file's docstring says that many of its tests read or patch the search's private parts, and so pin how it works as well as what it finds.
  • In tests/test_tiling.py: twelve cells in a row, in a column, or on a diagonal, each with a point requirement, with a timeout of 1 s, which is_empty exceeds before this change under CPython 3.15 and PyPy 3.12 in the row and the column; 01 in each of 24 cells on a diagonal, past the first pass; rows of three cells with requirements of three and four points; two cells with 01…7 and n…21 in one (n = 22, 24); the families above, a list chosen early (n = 6, 7), three lists that cannot all hold (m = 5, 6), an empty group beside a hard one (m = 5, n = 6 and 7), the three lists joined to one, three or four hard groups (m = 5, n = 6, and for one also n = 7); 48 point lists beside three longer lists that cannot all hold; twelve point lists joined to each of the three, beside six copies of the hard group, and the same with the three able to hold, the gridded permutation found checked against the tiling; and, given straight to GriddedPermSearch, as a Tiling takes most of a second over so many obstructions, 28 point lists each with 80 padding cells above it. Each has a timeout of 1 s (2 s where the three can hold), which the search exceeds without the groups, the restarts, the rounds, the look-aheads' states in their budget, the shortest lists first among those with as many dead ends, the obstructions on a list's cells, or only the obstructions all of whose cells the lists use counted. Also the tiling whose list's two requirements begin alike, and is_empty with one list, which does not search.

Beside these: on 4,836 random tilings (635 of them empty: up to three cells in a row or a column, 2 × 2 cells, and rows of three to six cells with a point requirement each and obstructions across them), each searched with its lists as given and shuffled, with the first pass and with none, the search agrees with a check of every gridded permutation up to the length a minimal one can have. On every tiling of the first table, class by class, the two versions agree, and every gridded permutation the search returns is one of its tiling; so they do on every call of the ten-minute searches.

One line in _ObstructionIndex reads a gridded permutation's _cells, the set of its cells that it keeps, with pylint's protected-access disabled there: making a set of each obstruction's cells, or grouping them by their positions, made a call about 1.8 times as long on tilings of about a thousand obstructions. A public property on GriddedPerm would be a change of its own.

Checked with tox: flake8, mypy, pylint and black pass, and the 3835 tests and doctests pass on CPython 3.15 and on PyPy 3.12.

How it was checked before it was put up here, by an automated reviewer, at this head:

  • tox, as above, and on its merge with develop: 3,838 tests on each Python.
  • The answers: every tiling of ten TileScope searches, 282,682 of them, has the same answer as before, and every gridded permutation found avoids the obstructions and contains a requirement of each list. A brute force over 60,000 small random tilings, with the first pass at 64, 16, 6 and 0 states, found no wrong answer.
  • Mutation testing on the changed functions, with mutmut and 44 faults made by hand: every mutant and every fault fails a test.
  • The new tests fail where they should: the families' tests time out on the code before, or on the order of an earlier round, under both Pythons.
  • Speed, in the reviewer's own timings under both Pythons: the tilings of nine of the searches above, and the calls of the three ten-minute searches, are faster or level, and no tiling of the nine takes more than twice its time before and 1 ms more.
  • The reviewer built most of the hard families above, which the design was changed to meet, and wrote several of the tests, among them the other families' tests in tests/test_tiling.py.

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

ulfarsson and others added 30 commits November 3, 2026 20:21
Tiling.is_empty took the first gridded perm that
MinimalGriddedPerms.minimal_gridded_perms(yield_non_minimal=True) yields.
That search takes its work packets shortest first, so it builds every
shorter gridded perm before it yields one: on a row of n cells, each with
a point requirement, every order of the points in the first n - 1 cells.

MinimalGriddedPerms.find_gridded_perm takes the same work packets, made by
the same rules, depth first, and returns the first gridded perm that avoids
the obstructions and contains the requirements, or None. It skips a packet
only if the same packet, its minimum indices too, was made before, so it
takes every packet that minimal_gridded_perms takes before its first
gridded perm, in some order, and finds one whenever minimal_gridded_perms
yields one. minimal_gridded_perms is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A depth-first search can spend seconds in branches that hold no gridded
permutation, on tilings where taking the shortest packet first finds one
within milliseconds. find_gridded_perm now takes the packet of least bound
first: the length of its gridded permutation, plus the points it lacks in
each cell, and at least one, a lower bound on the length of every gridded
permutation made from it. Of equal bounds the longest is taken first, so
that where every point inserted is one the packet lacks, as for a row of
cells each with a point requirement, the search still goes straight to a
gridded permutation.

A packet made again is skipped if one made before had minimum indices no
larger in each cell that it, and the packets made from it, may still
insert into. The initial packets come from _prepare_queue, and
minimal_gridded_perms works out its minimum indices with
_next_minimum_indices too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed_perm

minimal_gridded_perms now works out its minimum indices with
_next_minimum_indices, and no test saw a fault on that line: worked out
as if each point inserted were one place further right, the minimum
indices lose minimal gridded perms, and with them the gridded perms that
GriddedPermsOnTiling builds, while every test passes.
test_minimal_gridded_perms_against_a_brute_force compares the minimal
gridded perms with every gridded perm up to the length a minimal one can
have, on three small tilings on which that fault loses some.

Two tests insert a point into a cell in which no requirement list needs
points, a path of find_gridded_perm that no test took. The test of a row
of point requirements has twelve cells, in a row and in a column, so that
it fails before this change on a fast machine too.

find_gridded_perm's docstring states its argument in plainer words,
defines the open cells apart, and says that the gridded perm returned
need not be minimal. _packets_made_in_cell has the comments of
_process_work_packet, whose steps it repeats, and each points at the
other; the heap's entries are made in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unds

Where the requirements have several points in a cell, the bound by counts
is often loose: many packets one point short of a gridded perm share the
least bound, and find_gridded_perm, taking the longest first, took them
all, though most can never be finished, as the pattern in a cell is wrong,
or the points of cells it can no longer insert into interleave wrongly.
On some tilings of two or three cells it took several times as long as
minimal_gridded_perms.

After its first eight packets, find_gridded_perm compares each packet it
takes with the patterns of the requirements in each cell. Nothing made
from a packet inserts into a cell that is not open, so a requirement is
in no gridded perm made from it if it lacks a point in such a cell, or if
its points in those cells, as a gridded perm, are not contained in the
packet's; the packet is dropped if that holds of every requirement of a
list. Otherwise a requirement lacks, in each cell, its points there less
the most of them that form a pattern the packet's points there contain,
which gives a bound no lower than that by counts, and still a lower bound
on the length of every gridded perm made from the packet that contains
the requirements: a packet whose bound that raises goes back on the heap.

Most searches take fewer than eight packets, and so pay nothing for this.
The tests set PACKETS_TAKEN_BEFORE_PRUNING to 0, to check the dropping on
small tilings against every gridded perm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in a list

A fault that changes only which packets are dropped, or which go back on
the heap, or how far a bound is raised, or the least taken in a list or
the most over the lists, changes the order of the search but no answer,
so the tests through find_gridded_perm do not see it.

test_packets_taken takes five packets from a heap with the threshold at
0: one that can make no gridded perm is dropped, and one whose bound the
patterns raise is taken after the longer packets of bound up to its new
one. test_points_lacking_by_patterns_least_in_a_list_most_over_lists has
a list of two requirements, and two lists that lack a point in one cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_cell_patterns took each cell's pattern with get_gridded_perm_in_cells,
which builds a gridded perm, and goes over all the points, for every cell:
5.5 microseconds for eight points in three cells, against 1.5 in one pass
over the points. On tilings where most packets are compared with the
requirements' patterns, that was about 2% of the time. Three docstrings
and a comment are shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tiling.is_empty looked for a gridded perm with MinimalGriddedPerms, a
search built to make every minimal gridded perm once: a goal fixed at
the start, an initial gridded perm, minimum indices, and a rule for a
packet made twice whose answer can depend on the order of its heap.
is_empty needs one gridded perm, not the minimal ones.

GriddedPermSearch places the points of one requirement at a time, each
at a point already there or at a new point, where the requirement's
order allows. Its state is the gridded perm and the requirement being
placed, so a state made twice is skipped, in any order, and every
gridded perm on the tiling leads it to one. It goes depth first, its
children made as they are taken; a first pass of 64 states; then each
pair of lists alone, as a pair with no gridded perm leaves the tiling
with none; then the search again, dropping a gridded perm that no
gridded perm on the tiling contains: one with a list that cannot be
placed in it alone, or that needs more points in a cell than it can
take there.

MinimalGriddedPerms is develop's again: find_gridded_perm, its bounds
and their tests are gone. The tests of is_empty stay, and a test of a
long requirement in one cell joins them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GriddedPermSearch.find checked every obstruction for the empty perm, and
grouped the obstructions by a set of each one's cells made afresh: two
passes over the obstructions a call, which on tilings of about a
thousand obstructions made a call about 1.8 times as long. It now groups
them by the set of cells a gridded perm keeps, whose hash the set keeps,
and notes the empty perm in the same pass; checking a gridded perm
against the obstructions is a loop again, not any() over a generator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_find_past_the_first_pass gave the times of an earlier version of
the search, and test_find_a_column_of_two_cells named develop's search
for a fault that was in a search of minimal gridded perms like it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GriddedPermSearch searched each pair of lists alone after its first pass,
and then all the lists in one order, with the look-aheads. Where a list
placed early is ruled out only by a list placed late, or where three lists
cannot all hold behind lists that have nothing to do with them, that search
placed the early lists in every way before it met the late ones, and took
seconds where MinimalGriddedPerms took a tenth of a second.

After the first pass, the lists are now split into groups whose cells are
apart and that no obstruction joins. Each group is searched alone, and the
gridded perms found for the groups are put together. The lists of a group
are searched again and again with the look-aheads, each search taking twice
the states of the one before, until one finishes; each places first the
lists that have met the most dead ends so far. The pairs of lists are no
longer searched.

The class docstring opens with how to use the class, and gives the argument
under a heading of its own. The variables are named for what they hold, and
the obstructions are grouped when the search is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… call

A Counter takes longer to make than a defaultdict, and the list of the
obstructions' sets of cells copied what the grouping already holds. On the
tilings of a search for Av(123465, 564321), the two made a call about one
per cent longer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the first pass, the groups of lists were searched one at a time, each
to its end. Beside a group whose gridded perm takes long to find, an empty
group that a first pass decides at once waited for that whole search, and
took thousands of times as long as MinimalGriddedPerms. Each group now has a
first pass of its own, and the groups not yet decided are searched in
rounds, one search of each in a round, each round with twice the budget of
the one before. A search that finishes decides its group, and the rounds
stop as soon as a group has no gridded perm, or every group has one.

A look-ahead's search of one list had no budget, and one could cost as much
as the whole search of a hard group. It now takes its states from those
left to the search it looks ahead for, so that a search takes at most its
budget's states in all.

_combined ends with Perm.to_standard, and _groups uses Permuta's UnionFind
and gives both directions of its argument. The obstructions' index has
names that do not speak of groups, the dead ends are a Counter, and the
class docstring defines what a list's being satisfied and a dead end are,
and says why the search ends. The tests add the two families, the rounds,
the look-aheads' states, the dead ends' counts and _combined's order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One group with no gridded perm decides the search, so the groups now go
smallest first, by the sum over their lists of the length of each list's
longest gridded perm. Beside a large group that has a gridded perm, a small
empty group is so decided before the large one's first pass.

test_find_past_the_first_pass's docstring gives the time its test allows,
not a time it does not test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The count started as math.inf, so that a look-ahead called alone has no
bound, and became an int when a search set its budget. Under PyPy an
attribute that changes from a float to an int made every call slower,
and most on tilings that a search decides at once. It now starts at
sys.maxsize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list meets dead ends only once a search reaches it, so a list with
none may not have been reached. Of the lists with as many dead ends, the
one whose longest gridded perm is shortest now goes first: it is the
quickest to place, or to find that it cannot be placed.

Three lists that cannot all hold, joined to several copies of the hard
lists, were decided only after a round for each copy, each round twice
as long as the one before. They are now decided in the first round. The
test of lists that cannot all hold joined to the hard lists takes up to
four copies, and the test of the restarts' budgets a tiling that still
takes three rounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GriddedPermSearch and its find have examples, which run as doctests,
after a one-line summary each. The groups' docstring says that their
order bounds the length of a minimal gridded perm, as
Tiling.maximum_length_of_minimum_gridded_perm does, and that a group is
close to a factor with interleaving. Names, comments and docstrings now
say what they hold and do: the gridded perm found for each group, a
budget only for a search that is not a look-ahead's, and why the count
of states is an int.

Five tests of the groups' first passes and order, and of a group after
one with no gridded perm; one helper records the searches and how each
ended, in place of two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LEFT, MIDDLE and RIGHT in place of A, B and C, and the lists in them
named the same way, as tests/test_tiling.py names its cells. The tests
are otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of lists with as many dead ends, the one whose longest gridded perm is
shortest goes first, and not the one with the shortest gridded perm or
the fewest points in all. The groups' first passes keep the search's
order, and only the rounds after them put the lists in the order of
their dead ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_ordered_by_dead_ends, _search_undecided_groups, PlacingKey and
_combined's part_index say what each is. _longest writes once the length
of a list's longest gridded perm, which three places worked out. _find
puts the groups' first pass in an if of its own, and orders each round's
groups itself, so that _search_undecided_groups searches the groups in
the order given, and look_ahead switches the look-aheads only. The
searches are the same: a group's searches change the dead ends of its
own lists alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
searches_recorded returns each search as a Search, whose lists, budget,
look_ahead and outcome the tests name where they counted underscores. A
list of one point in the left, the middle or the right cell is IN_LEFT,
IN_MIDDLE or IN_RIGHT, where tests built it again, and point now means
a gridded perm alone. test_groups_are_searched_in_rounds finds a group
by the set of its lists, as the rounds can change their order, and
_combined's test names its cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of lists with as many dead ends, the rounds placed the shortest first.
That put many short lists ahead of longer lists that cannot all hold
together, of which only the last placed meets dead ends: each search
placed every short list first, and its work grew as the square of their
number.

A dead end now also counts, as a dead end of a joined list, for each
list the rest satisfies that an obstruction joins to the list that met
it, as its points may be what shut that list out. Of lists with as many
dead ends, those with the most dead ends of joined lists go first, then
those that the most obstructions the search can make use, then the
shortest. An obstruction that uses a cell no list uses never occurs in
the search, and is not counted.

Tests: the review's tiling of 48 point lists beside the three lists, and
three tilings built so that each of the middle two keys, and the
obstructions counted only if the search can make them, is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lists alike in every key keep their order; an empty list weighs nothing;
more dead ends go before a shorter list, by their number; a list, a
gridded perm and an obstruction given twice; and the three lists that
cannot all hold made 12...L, joined to three copies of the hard lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The class's docstring says what the search does, then its limits, then
its examples, as Perm's do; the examples show a list satisfied by either
of its gridded perms, and Tiling.is_empty's call on a tiling, and find's
a gridded perm that is not minimal. Each private docstring opens with a
summary line, the detail after it. The code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_ListCells works out, for a list once it is needed, its cells, the
cells an obstruction joins to them, and the obstructions the search can
make on them, which were four caches of the search's own. The search is
the same. Its test counts two obstructions on one set of cells, so that
a count of the sets of cells fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each search of the rounds must place the lists in the order of the dead
ends the searches before it left; an order made once, before the rounds,
passed every test. The padded test's name now says that it calls
GriddedPermSearch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GriddedPermSearch's docstring keeps what the search finds, its limits
and its examples, so help(GriddedPermSearch) shows what a caller needs.
The sections on how the search works, why it finds a gridded perm when
there is one, and what makes it fast move, unchanged but dedented, into
the module's docstring, for those who work on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Define a joined list before the word is used, and say that a
look-ahead's own search counts its dead ends for its list alone. Say
which obstructions the order counts, those all of whose cells the lists
use, in place of "the obstructions the search can make". The class's
limits now say that the order is a heuristic, slower on tilings built
against it. The examples' captions are sentences, _combined states its
precondition, PlacingKey has a comment, two docstrings are reflowed, and
the CHANGELOG says that find's gridded permutation need not be minimal.
The code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eir cells

_ObstructionIndex holds the obstructions by the cells they use, and
whether one is the empty perm. The search builds it first and hands it
to _ListCells, which had shared two of the search's dictionaries and
worked only because __init__ made them before it. _ListCells now also
keeps the points of each list's gridded perms in each cell, which
_closed_out kept in the search with an idiom of its own. The names say
what they hold: joined_cells, obstruction_count,
_dead_ends_of_joined_lists and _longest_length.

The search takes the same states and finds the same gridded perms as
before, on the families of the tests and on random tilings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… builders

Nine tests in the search's file: the dead ends of joined lists add up,
an obstruction joins two lists and a shared cell alone does not, the
lists given are read by their indices, and a look-ahead's own search
counts no dead end of a joined list; the list cells keep what they work
out; each key of the rounds' order comes before the next; and find
agrees with MinimalGriddedPerms on the brute force's tilings. In
tests/test_tiling.py, the point lists beside three lists that can all
hold, with the gridded perm found checked against the tiling.

The family tests share builders for the three lists that cannot all hold
and for the hard groups joined to them, and build the same tilings as
before. The padded family has 28 point lists, where counting every
obstruction fails it by far under either Python. The test of longer
lists that cannot all hold is gone: no fault of the rounds' order that
was tried fails it without failing the test of points too. Records of
searches are named recorded, an outcome over budget is OVER_BUDGET, the
tie-break test gives its lists in the reverse of their order, and two
docstrings say what their tests check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ulfarsson and others added 3 commits November 7, 2026 19:22
"Joins" is defined where the module docstring first uses it, in the
groups' bullet. A look-ahead's own search counts its dead ends for its
list alone because it is given that list alone. The bullet on the
obstructions counted gives its reason and its analogy apart. The class
docstring says that the gridded perm found depends on the order of the
lists and of a list's gridded perms, not on that of the obstructions,
and names its module. _ObstructionIndex gives a sentence to each
attribute, obstruction_count's summary says which obstructions it
counts, PlacingKey's comment reads as a sentence, and the CHANGELOG
says where GriddedPermSearch is imported from. The code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…full

_ObstructionIndex.has_empty_obstruction, where empty read as an empty
index; _ListCells.cells_of_lists, and obstruction_index for its local.
In the tests: pattern and random_source for patt and rng, entry for a
point's value, has_gridded_perm for the brute-force test's flag, and
obstruction for ob; the builders take requirement_length and copy_count,
and first_column by name. The search takes the same states and finds
the same gridded perms, and the builders build the same tilings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cache test asks cell_counts and cells_of_lists twice too, and a new
test gives cell_counts' values. Others pin a dead end for an empty list,
which is falsy, and not for the list before it; generators taken as the
obstructions and lists, as the types allow; and the search's order of
the lists, which puts a list across two cells after those whose gridded
perms each lie in one cell. The tests of the dead ends of joined lists,
of the list cells and of the order's keys now sit with their siblings,
and six docstrings say what they mean more plainly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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