diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c317fe0..f9dcc0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added +- `ReductionFactory` and `ReductionStrategy`, the reduction of a set of cells: the + cells are made increasing or decreasing, and their own tiling is counted beside + the reduced tiling, which keeps only what the rest of the tiling sees of them + (a Hadamard product, with object maps and random sampling). The factory's + `region` is `"cell"`, `"row"`, `"column"`, `"line"`, `"cut"` (the cells on one + side of a line between two columns or two rows) or `"any"`, the last two with + `max_cells`. The condition, the plan and the children are in + `tilings.algorithms.reduction`. + +### Changed +- `CellReductionFactory` and `CellReductionStrategy` are the reduction of single + cells: they count, generate and sample objects, and the kitchen sink pack uses + `ReductionFactory(tracked)`. A cell with no obstruction of its own, in a + component assumption, or in a tracked gridded permutation of two or more + points, is no longer reduced. +- `CellReductionStrategy.formal_step` names the direction, as in "reducing cell + (0, 0) to increasing", where it was "reducing cell (0, 0)". +- The cell reduction's reduced tiling is built with `Tiling`'s defaults + (simplified, empty rows and columns removed), where it was built with + `simplify=False`, `derive_empty=False` and `remove_empty_rows_and_cols=False`, + so a specification saved with the old `CellReductionStrategy` may not rebuild + its children identically. None could have been counted: the old constructor + raised `NotImplementedError`. +- `CellReductionConstructor` is `ReductionConstructor`, which takes the parent, + the children, the plan and the map of the parent's parameters to the reduced + tiling's. The old `CellReductionConstructor(parameter)` counted nothing: each + of its methods raised `NotImplementedError`. + +### Removed +- `CellReductionFactory.reducible_cells`, + `CellReductionFactory.can_reduce_cell_with_requirements_and_assumptions` and + `CellReductionFactory.gp_in_row_and_col`. The condition is + `tilings.algorithms.reduction.is_reducible`. ## [4.1.0] - 2026-01-15 ### Changed diff --git a/tests/strategies/test_encoding.py b/tests/strategies/test_encoding.py index 5999a0ef..3712c872 100644 --- a/tests/strategies/test_encoding.py +++ b/tests/strategies/test_encoding.py @@ -36,6 +36,7 @@ PatternPlacementFactory, PointingStrategy, RearrangeAssumptionFactory, + ReductionFactory, RelaxAssumptionFactory, RequirementCorroborationFactory, RequirementExtensionFactory, @@ -79,6 +80,7 @@ RequirementPointingStrategy, ) from tilings.strategies.rearrange_assumption import RearrangeAssumptionStrategy +from tilings.strategies.reduction import ReductionStrategy from tilings.strategies.requirement_insertion import RequirementInsertionStrategy from tilings.strategies.requirement_placement import RequirementPlacementStrategy from tilings.strategies.sliding import SlidingStrategy @@ -471,6 +473,17 @@ def indices_and_row(strategy): CellReductionStrategy((3, 3), False, True), CellReductionStrategy((4, 1), False, False), ] + + [ + ReductionFactory(tracked, region, max_cells) + for tracked in (True, False) + for region in ("cell", "row", "column", "line", "any") + for max_cells in (None, 3) + ] + + [ + ReductionStrategy(((0, 0), (1, 0)), True, True), + ReductionStrategy(((2, 1),), False, False), + ReductionStrategy(((0, 1), (2, 0), (3, 3)), False, True), + ] + [ DeflationStrategy((0, 0), True, True), DeflationStrategy((2, 1), True, False), diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py new file mode 100644 index 00000000..b8b54e19 --- /dev/null +++ b/tests/strategies/test_reduction.py @@ -0,0 +1,1209 @@ +import json +import math +import random +from collections import defaultdict + +import pytest + +from comb_spec_searcher import CombinatorialSpecification, DisjointUnion, Strategy +from comb_spec_searcher.exception import StrategyDoesNotApply +from permuta import Av, Perm +from permuta.misc import DIR_NORTH, DIR_SOUTH +from tilings import GriddedPerm, Tiling +from tilings import strategies as strat +from tilings.algorithms.reduction import ( + is_reducible, + is_rigid, + monotone_obstructions, + reduction_children, + reduction_plan, +) +from tilings.assumptions import TrackingAssumption +from tilings.strategies.cell_reduction import ( + CellReductionFactory, + CellReductionStrategy, +) +from tilings.strategies.reduction import ( + ReductionFactory, + ReductionStrategy, + _bounded_compositions, +) +from tilings.strategies.requirement_placement import RequirementPlacementStrategy +from tilings.strategy_pack import TileScopePack +from tilings.tilescope import TileScope + + +def tiling_from(cell_bases, crossing_obstructions, empty_cells, tracked_cells=()): + obstructions = [ + GriddedPerm.single_cell(patt, cell) + for cell, patts in cell_bases.items() + for patt in patts + ] + obstructions.extend( + GriddedPerm(patt, cells) for patt, cells in crossing_obstructions + ) + obstructions.extend(GriddedPerm.point_perm(cell) for cell in empty_cells) + return Tiling( + obstructions, + assumptions=[TrackingAssumption.from_cells([cell]) for cell in tracked_cells], + ) + + +@pytest.fixture +def two_rows(): + """Av(1234, 1342, 2413) with its minimum and the first point to the right of + the minimum placed, the points factored away: the class beside a decreasing + cell above, and Av(123, 2413) beside Av(123, 231) below.""" + return tiling_from( + { + (0, 1): [Perm((0, 1, 2, 3)), Perm((0, 2, 3, 1)), Perm((1, 3, 0, 2))], + (1, 0): [Perm((0, 1, 2)), Perm((1, 3, 0, 2))], + (2, 0): [Perm((0, 1, 2)), Perm((1, 2, 0))], + (3, 1): [Perm((0, 1))], + }, + [ + (Perm((0, 1, 2)), ((1, 0), (1, 0), (3, 1))), + (Perm((0, 1, 2)), ((2, 0), (2, 0), (3, 1))), + (Perm((0, 2, 1)), ((0, 1), (0, 1), (3, 1))), + (Perm((0, 2, 1)), ((1, 0), (1, 0), (2, 0))), + (Perm((1, 0, 2)), ((1, 0), (2, 0), (2, 0))), + (Perm((0, 1, 2, 3)), ((0, 1), (0, 1), (0, 1), (3, 1))), + (Perm((0, 1, 2, 3)), ((1, 0), (1, 0), (2, 0), (2, 0))), + ], + [(0, 0), (1, 1), (2, 1), (3, 0)], + ) + + +@pytest.fixture +def apart(): + """Two cells sharing no row or column, tied by a 123 with two points in the + lower one, beside a tracked increasing cell (from Av(1234, 2431)).""" + return tiling_from( + { + (0, 1): [Perm((1, 0))], + (1, 0): [Perm((0, 1, 2)), Perm((1, 3, 2, 0))], + (2, 2): [Perm((1, 0))], + }, + [ + (Perm((1, 0)), ((0, 1), (1, 0))), + (Perm((0, 1, 2)), ((1, 0), (1, 0), (2, 2))), + ], + [(0, 0), (0, 2), (1, 1), (1, 2), (2, 0), (2, 1)], + tracked_cells=[(0, 1), (2, 2)], + ) + + +@pytest.fixture +def row_and_column(): + """A tiling from Av(1243, 1324, 2431) whose first column and bottom row are + both reducible.""" + return tiling_from( + { + (0, 0): [Perm((0, 1))], + (0, 1): [Perm((0, 2, 1))], + (1, 0): [Perm((0, 2, 1))], + (2, 0): [Perm((0, 1, 3, 2)), Perm((0, 2, 1, 3)), Perm((1, 3, 2, 0))], + }, + [ + (Perm((1, 0)), ((0, 0), (2, 0))), + (Perm((0, 1, 2)), ((0, 0), (1, 0), (2, 0))), + (Perm((0, 1, 2)), ((1, 0), (1, 0), (2, 0))), + (Perm((0, 2, 1)), ((0, 0), (0, 1), (0, 1))), + (Perm((0, 2, 1)), ((0, 0), (1, 0), (1, 0))), + (Perm((1, 2, 0)), ((0, 0), (1, 0), (1, 0))), + (Perm((1, 2, 0)), ((1, 0), (2, 0), (2, 0))), + (Perm((0, 1, 3, 2)), ((0, 0), (2, 0), (2, 0), (2, 0))), + (Perm((0, 1, 3, 2)), ((1, 0), (2, 0), (2, 0), (2, 0))), + (Perm((0, 2, 1, 3)), ((0, 0), (2, 0), (2, 0), (2, 0))), + (Perm((0, 2, 1, 3)), ((1, 0), (2, 0), (2, 0), (2, 0))), + ], + [(1, 1), (2, 1)], + ) + + +@pytest.fixture +def one_cell(): + """A cell that the cell reduction reduces: the obstruction it shares with the + cell to its right has one point in it.""" + return tiling_from( + {(0, 0): [Perm((0, 2, 1))], (1, 0): [Perm((1, 0))]}, + [(Perm((0, 1)), ((0, 0), (1, 0)))], + [], + ) + + +@pytest.fixture +def threshold(): + """Av(2143) with its topmost point placed, a 21 inserted into the cell left of + the point and the 2 of the 21 placed bottommost: that is the lowest top of an + inversion of the left cell. Its points below it are in two increasing cells, + left and right of it, every point of the left one below those of the right + one, in the row of the cell right of the topmost point, which sees them only + through the 2143 with one point in them.""" + with_point = Tiling.from_string("2143").add_single_cell_requirement( + Perm((0,)), (0, 0) + ) + placed = RequirementPlacementStrategy( + [GriddedPerm(Perm((0,)), ((0, 0),))], [0], DIR_NORTH + )(with_point).children[0] + with_inversion = placed.add_single_cell_requirement(Perm((1, 0)), (0, 0)) + return RequirementPlacementStrategy( + [GriddedPerm(Perm((1, 0)), ((0, 0), (0, 0)))], [0], DIR_SOUTH + )(with_inversion).children[0] + + +THRESHOLD_CELLS = [(0, 0), (0, 2), (1, 1), (2, 0), (2, 2)] + + +def beside_a_row_cell(both_above): + """Two cells of the set in a row, A = (0, 0) before B = (1, 0), every point of A + below those of B, beside a cell C = (2, 0) of the rest in the same row. Every + point of A lies above those of C; so does every point of B if both_above, and + then C sees A and B alike.""" + crossing = [ + (Perm((1, 0)), ((0, 0), (1, 0))), + (Perm((0, 1)), ((0, 0), (2, 0))), + ] + if both_above: + crossing.append((Perm((0, 1)), ((1, 0), (2, 0)))) + return tiling_from( + { + (0, 0): [Perm((0, 2, 1))], + (1, 0): [Perm((0, 2, 1))], + (2, 0): [Perm((1, 0))], + }, + crossing, + [], + ) + + +@pytest.fixture +def alike_row(): + return beside_a_row_cell(True) + + +@pytest.fixture +def unlike_row(): + return beside_a_row_cell(False) + + +@pytest.fixture +def unlike_row_decreasing(): + """The complement of unlike_row, reducible decreasing only: every point of A + lies above those of B and below those of C.""" + return beside_a_row_cell(False).complement() + + +@pytest.fixture +def unlike_column(): + """The inverse of unlike_row: A = (0, 0) below B = (0, 1) in a column with a + cell C = (0, 2) of the rest, which sees them unlike, so that the reduction + keeps both cells of the column; reducible increasing only.""" + return beside_a_row_cell(False).inverse() + + +@pytest.fixture +def unlike_column_decreasing(): + """The inverse of unlike_row_decreasing: the same three cells, reducible + decreasing only.""" + return beside_a_row_cell(False).complement().inverse() + + +@pytest.fixture +def with_requirement_list(): + """alike_row with a point required in A or in C.""" + tiling = beside_a_row_cell(True) + return Tiling( + tiling.obstructions, + [[GriddedPerm.point_perm((0, 0)), GriddedPerm.point_perm((2, 0))]], + ) + + +@pytest.fixture +def touched_only(): + """A cell A = (0, 0) of the set below a cell B = (0, 1) of it, in a column and + rows with no cell outside the set, and a cell C = (1, 2) of the rest: the rest + sees A only through the 132s with a point in A and two in C, and B not at + all.""" + return tiling_from( + { + (0, 0): [Perm((0, 2, 1))], + (0, 1): [Perm((1, 0))], + (1, 2): [Perm((0, 1, 2))], + }, + [(Perm((0, 2, 1)), ((0, 0), (1, 2), (1, 2)))], + [(0, 2), (1, 0), (1, 1)], + ) + + +def row_above_a_cell(requirements=()): + """A row of an increasing cell A = (0, 1), a cell B = (1, 1) of Av(312) and a + decreasing cell C = (2, 1), above a cell D = (2, 0) of Av(321), with no + obstruction between two cells.""" + tiling = tiling_from( + { + (0, 1): [Perm((1, 0))], + (1, 1): [Perm((2, 0, 1))], + (2, 1): [Perm((0, 1))], + (2, 0): [Perm((2, 1, 0))], + }, + [], + [], + ) + return Tiling(tiling.obstructions, requirements) + + +@pytest.fixture +def split_by_column(): + return row_above_a_cell() + + +@pytest.fixture +def split_by_first_column(): + """The reverse of split_by_column: C = (0, 1), above D = (0, 0), comes before + B = (1, 1) in the row, and A = (2, 1) after them.""" + return row_above_a_cell().reverse() + + +@pytest.fixture +def seen_through_requirement(): + """row_above_a_cell with a point required in B or in D.""" + return row_above_a_cell( + [[GriddedPerm.point_perm((1, 1)), GriddedPerm.point_perm((2, 0))]] + ) + + +@pytest.fixture +def seen_through_a_21(): + """An increasing cell A = (0, 1) beside a cell B = (1, 1) of Av(321), above two + decreasing cells C = (1, 0) and D = (2, 0), with no obstruction between two + cells, and a point required in D or a 21 from A down to C.""" + tiling = tiling_from( + { + (0, 1): [Perm((1, 0))], + (1, 1): [Perm((2, 1, 0))], + (1, 0): [Perm((0, 1))], + (2, 0): [Perm((0, 1))], + }, + [], + [], + ) + return Tiling( + tiling.obstructions, + [[GriddedPerm.point_perm((2, 0)), GriddedPerm(Perm((1, 0)), ((0, 1), (1, 0)))]], + ) + + +@pytest.fixture +def left_out_beside_a_21(): + """row_above_a_cell with a 21 from A down to D, or a 12 in D, required.""" + return row_above_a_cell( + [ + [ + GriddedPerm(Perm((1, 0)), ((0, 1), (2, 0))), + GriddedPerm(Perm((0, 1)), ((2, 0), (2, 0))), + ] + ] + ) + + +def offered_sets(tiling, region): + return sorted( + {strategy.cells for strategy in ReductionFactory(True, region)(tiling)} + ) + + +def test_reducible(two_rows, apart): + assert is_reducible(two_rows, [(1, 0), (2, 0), (3, 1)]) + assert is_reducible(two_rows, [(0, 1), (3, 1)]) + # a 123 with two points in (1, 0) and one in (3, 1) crosses the set + assert not is_reducible(two_rows, [(1, 0), (2, 0)]) + assert not is_reducible(two_rows, [(1, 0)]) + assert is_reducible(apart, [(1, 0), (2, 2)]) + assert not is_reducible(apart, [(1, 0)]) + + +def test_crossing_in_a_column(): + """A 123 with two points in A = (0, 0), a cell of Av(132), and one in the + decreasing cell B = (0, 1) above it has its point of the set {B} in B, and + touches A, in B's column. The set has no other cell in that column, so the + column is in order, and the set is reducible in both directions. B's column, 0, + differs from its row, 1, so the test tells the set's columns from its rows.""" + tiling = tiling_from( + {(0, 0): [Perm((0, 2, 1))], (0, 1): [Perm((0, 1))]}, + [(Perm((0, 1, 2)), ((0, 0), (0, 0), (0, 1)))], + [], + ) + assert is_reducible(tiling, [(0, 1)], True) + assert is_reducible(tiling, [(0, 1)], False) + + +def test_reducible_with_no_direction(alike_row): + """With no direction given, a set is reducible if it is in one of the two: + alike_row's two cells are reducible increasing only, and those of its + complement decreasing only. The cells are read once, so an iterator serves for + both directions.""" + cells = [(0, 0), (1, 0)] + for tiling, increasing in ((alike_row, True), (alike_row.complement(), False)): + assert is_reducible(tiling, cells, increasing) + assert not is_reducible(tiling, cells, not increasing) + assert is_reducible(tiling, cells) + assert is_reducible(tiling, iter(cells)) + + +def test_degenerate_sets(two_rows): + """The empty set is not reducible, nor is a set with an empty cell or a cell + off the tiling; all the active cells of a tiling are, when the condition holds, + though the factory does not offer them.""" + assert not is_reducible(two_rows, []) + width, height = two_rows.dimensions + empty_cells = [ + (column, row) + for column in range(width) + for row in range(height) + if (column, row) not in two_rows.active_cells + ] + assert empty_cells + for cell in empty_cells + [(width, height)]: + assert not is_reducible(two_rows, [cell]) + assert not is_reducible(two_rows, [cell, (0, 1)]) + assert is_reducible(tiling_from({(0, 0): [Perm((2, 1, 0))]}, [], []), [(0, 0)]) + + +def test_rigid(two_rows): + assert is_rigid(two_rows, [(3, 1)]) + assert not is_rigid(two_rows, [(0, 1), (3, 1)]) + row = tiling_from({(0, 0): [Perm((1, 0))], (1, 0): [Perm((1, 0))]}, [], []) + assert not is_rigid(row, [(0, 0), (1, 0)]) + increasing_row = tiling_from( + {(0, 0): [Perm((1, 0))], (1, 0): [Perm((1, 0))]}, + [(Perm((1, 0)), ((0, 0), (1, 0)))], + [], + ) + assert is_rigid(increasing_row, [(0, 0), (1, 0)]) + # the points of the two cells in a fixed order, but the second cell not monotone + second_not_monotone = tiling_from( + {(0, 0): [Perm((1, 0))], (1, 0): [Perm((0, 1, 2))]}, + [(Perm((0, 1)), ((0, 0), (1, 0)))], + [], + ) + assert not is_rigid(second_not_monotone, [(0, 0), (1, 0)]) + # two increasing cells of a column, in no fixed order, the lower one's points + # all left of the upper one's, and all right of them; in rows 1 and 2 of column + # 0, beside a cell (1, 0), so that neither cell's row index is the column index + increasing_cells = {cell: [Perm((1, 0))] for cell in [(0, 1), (0, 2), (1, 0)]} + column = [(0, 1), (0, 2)] + assert not is_rigid(tiling_from(increasing_cells, [], []), column) + for crossing in ( + (Perm((1, 0)), ((0, 2), (0, 1))), + (Perm((0, 1)), ((0, 1), (0, 2))), + ): + assert is_rigid(tiling_from(increasing_cells, [crossing], []), column) + + +def test_regions(two_rows, apart, row_and_column, one_cell): + assert offered_sets(two_rows, "cell") == [] + assert offered_sets(two_rows, "row") == [((0, 1), (3, 1))] + assert offered_sets(two_rows, "column") == [] + assert offered_sets(two_rows, "line") == [((0, 1), (3, 1))] + assert offered_sets(two_rows, "any") == [ + ((0, 1), (3, 1)), + ((1, 0), (2, 0), (3, 1)), + ] + assert offered_sets(apart, "line") == [] + assert offered_sets(apart, "any") == [((1, 0), (2, 2))] + assert offered_sets(row_and_column, "row") == [((0, 0), (1, 0), (2, 0))] + assert offered_sets(row_and_column, "column") == [((0, 0), (0, 1))] + assert offered_sets(row_and_column, "line") == [ + ((0, 0), (0, 1)), + ((0, 0), (1, 0), (2, 0)), + ] + assert offered_sets(one_cell, "cell") == [((0, 0),)] + assert offered_sets(one_cell, "any") == [((0, 0),)] + assert offered_sets(two_rows, "any") == sorted( + { + strategy.cells + for strategy in ReductionFactory(True, "any", max_cells=3)(two_rows) + } + ) + assert offered_sets(two_rows, "any")[:1] == sorted( + { + strategy.cells + for strategy in ReductionFactory(True, "any", max_cells=2)(two_rows) + } + ) + with pytest.raises(ValueError): + ReductionFactory(True, "diagonal") + # the factories' names for the regions that test_cut_region and + # test_cell_reduction do not check + assert str(ReductionFactory(True, "row")) == "tracked row reduction factory" + assert str(ReductionFactory(False, "column")) == "column reduction factory" + assert str(ReductionFactory(True, "line")) == "tracked line reduction factory" + assert str(ReductionFactory(True, "any")) == ( + "tracked reduction factory of any set of cells" + ) + assert str(ReductionFactory(False, "any", 3)) == ( + "reduction factory of sets of at most 3 cells" + ) + + +def test_factory_defaults(): + """Unless told otherwise, a factory is tracked and reduces single cells, with no + bound on the number of cells of a set.""" + factory = ReductionFactory() + assert (factory.tracked, factory.region, factory.max_cells) == (True, "cell", None) + + +def test_candidate_sets(): + """The sets each region tries, each once, on an L of three cells: (0, 0) below + (0, 1) and left of (1, 0).""" + tiling = tiling_from( + {cell: [Perm((0, 2, 1))] for cell in [(0, 0), (0, 1), (1, 0)]}, [], [(1, 1)] + ) + single_cells = [((0, 0),), ((0, 1),), ((1, 0),)] + bottom_row, left_column = ((0, 0), (1, 0)), ((0, 0), (0, 1)) + two_cell_sets = [left_column, bottom_row, ((0, 1), (1, 0))] + expected = [ + ("cell", None, single_cells), + ("row", None, [bottom_row, ((0, 1),)]), + ("column", None, [left_column, ((1, 0),)]), + ("line", None, single_cells + [bottom_row, left_column]), + ("cut", None, [left_column, ((1, 0),), bottom_row, ((0, 1),)]), + ("cut", 1, [((1, 0),), ((0, 1),)]), + ("any", None, single_cells + two_cell_sets + [((0, 0), (0, 1), (1, 0))]), + ("any", 2, single_cells + two_cell_sets), + ] + for region, max_cells, sets in expected: + factory = ReductionFactory(True, region, max_cells) + assert sorted(factory.candidate_sets(tiling)) == sorted(sets), region + + +def cut_sets(tiling): + """The active cells on each side of each line between adjacent columns or rows.""" + width, height = tiling.dimensions + cells = tiling.active_cells + sides = [] + for column in range(1, width): + sides.append({cell for cell in cells if cell[0] < column}) + sides.append({cell for cell in cells if cell[0] >= column}) + for row in range(1, height): + sides.append({cell for cell in cells if cell[1] < row}) + sides.append({cell for cell in cells if cell[1] >= row}) + return sides + + +def test_cut_region(two_rows, apart, row_and_column, one_cell, threshold): + # the threshold reduction's set, the cells left of the topmost point, is a cut + assert tuple(THRESHOLD_CELLS) in offered_sets(threshold, "cut") + for tiling in (two_rows, apart, row_and_column, one_cell, threshold): + offered = offered_sets(tiling, "cut") + # each set offered is one side of a line, and "any" offers it too + assert all(set(cells) in cut_sets(tiling) for cells in offered) + assert set(offered) <= set(offered_sets(tiling, "any")) + # both of two_rows' reductions are cuts: above its middle line, and right of + # its first column + assert offered_sets(two_rows, "cut") == [ + ((0, 1), (3, 1)), + ((1, 0), (2, 0), (3, 1)), + ] + assert offered_sets(one_cell, "cut") == [((0, 0),)] + # max_cells bounds the sets of a cut as it bounds those of "any" + assert tuple(THRESHOLD_CELLS) not in { + strategy.cells for strategy in ReductionFactory(True, "cut", 4)(threshold) + } + assert str(ReductionFactory(True, "cut")) == ( + "tracked reduction factory of the cells on one side of a line" + ) + assert str(ReductionFactory(False, "cut", 4)) == ( + "reduction factory of the cells on one side of a line of at most 4 cells" + ) + for strategy in ReductionFactory(True, "cut")(threshold): + rule = strategy(threshold) + assert all(rule.sanity_check(n) for n in range(5)) + + +def test_children(two_rows): + cells = [(1, 0), (2, 0), (3, 1)] + # no cell outside the set shares a row or a column with (1, 0) or (2, 0), and + # no obstruction with points outside the set has a point in them + assert reduction_plan(two_rows, cells) == ((((3, 1),),), ((1, 0), (2, 0))) + reduced_tiling, own_tiling = reduction_children(two_rows, cells, False) + # the reduced tiling keeps (3, 1) beside (0, 1), decreasing and tracked + assert reduced_tiling.dimensions == (2, 1) + assert GriddedPerm(Perm((0, 1)), ((1, 0), (1, 0))) in reduced_tiling.obstructions + assert reduced_tiling.assumptions == (TrackingAssumption.from_cells([(1, 0)]),) + # the own tiling keeps the 123s with the decreasing cell, in three columns and + # two rows, and tracks (3, 1) only + assert own_tiling.dimensions == (3, 2) + assert GriddedPerm(Perm((0, 1, 2)), ((0, 0), (0, 0), (2, 1))) in ( + own_tiling.obstructions + ) + assert own_tiling.assumptions == (TrackingAssumption.from_cells([(2, 1)]),) + # with two children, the rule is never an equivalence + assert not ReductionStrategy(cells, False).can_be_equivalent() + + +@pytest.mark.parametrize( + "fixture, cells", + [ + ("two_rows", [(1, 0), (2, 0), (3, 1)]), + ("two_rows", [(0, 1), (3, 1)]), + ("apart", [(1, 0), (2, 2)]), + ("row_and_column", [(0, 0), (0, 1)]), + ("row_and_column", [(0, 0), (1, 0), (2, 0)]), + ("one_cell", [(0, 0)]), + ("seen_through_a_21", [(0, 1), (1, 1)]), + ("left_out_beside_a_21", [(0, 1), (1, 1), (2, 1)]), + ("seen_through_requirement", [(0, 1), (1, 1)]), + ("split_by_first_column", [(0, 1), (1, 1)]), + ], +) +@pytest.mark.parametrize("increasing", [True, False]) +def test_sanity_check(request, fixture, cells, increasing): + """The counts, objects and random samples of the rule against its tilings.""" + tiling = request.getfixturevalue(fixture) + rule = ReductionStrategy(cells, increasing)(tiling) + for n in range(6): + assert rule.sanity_check(n) + + +@pytest.mark.parametrize( + "fixture, cells, increasing", + [ + (fixture, cells, increasing) + for fixture, cells in ( + ("two_rows", [(1, 0), (2, 0), (3, 1)]), + ("apart", [(1, 0), (2, 2)]), + ("row_and_column", [(0, 0), (1, 0), (2, 0)]), + ("touched_only", [(0, 0), (0, 1)]), + ) + for increasing in (True, False) + ] + + [ + ("alike_row", [(0, 0), (1, 0)], True), + ("unlike_row", [(0, 0), (1, 0)], True), + ("unlike_row_decreasing", [(0, 0), (1, 0)], False), + ("unlike_column", [(0, 0), (0, 1)], True), + ("unlike_column_decreasing", [(0, 0), (0, 1)], False), + ("with_requirement_list", [(0, 0), (1, 0)], True), + ("split_by_column", [(1, 1), (2, 1)], True), + ("seen_through_requirement", [(0, 1), (1, 1), (2, 1)], True), + ("seen_through_a_21", [(0, 1), (1, 1)], True), + ("left_out_beside_a_21", [(0, 1), (1, 1), (2, 1)], True), + ("threshold", THRESHOLD_CELLS, True), + ], +) +def test_maps(request, fixture, cells, increasing): + """The forward map is a bijection onto the pairs with as many points in each + group, whose sizes add up to the parent's with the groups' points counted once, + and the backward map is its inverse.""" + tiling = request.getfixturevalue(fixture) + strategy = ReductionStrategy(cells, increasing) + reduced_tiling, own_tiling = strategy.decomposition_function(tiling) + groups, _ = strategy.plan(tiling) + + def reduced_group_sizes(reduced_gridded_perm): + parent_gridded_perm = reduced_tiling.backward_map.map_gp(reduced_gridded_perm) + return tuple( + sum(1 for other in parent_gridded_perm.pos if other == group[0]) + for group in groups + ) + + def own_group_sizes(own_gridded_perm): + own_cells = own_tiling.backward_map.map_gp(own_gridded_perm).pos + return tuple( + sum(1 for other in own_cells if other in group) for group in groups + ) + + own_by_sizes = defaultdict(list) + for n in range(6): + for own_gridded_perm in own_tiling.objects_of_size(n): + own_by_sizes[own_group_sizes(own_gridded_perm)].append(own_gridded_perm) + for n in range(6): + images = set() + for gridded_perm in tiling.objects_of_size(n): + reduced_gridded_perm, own_gridded_perm = strategy.forward_map( + tiling, gridded_perm + ) + assert ( + reduced_gridded_perm in reduced_tiling + and own_gridded_perm in own_tiling + ) + assert ( + next( + strategy.backward_map( + tiling, (reduced_gridded_perm, own_gridded_perm) + ) + ) + == gridded_perm + ) + images.add((reduced_gridded_perm, own_gridded_perm)) + pairs = { + (reduced_gridded_perm, own_gridded_perm) + for reduced_size in range(n + 1) + for reduced_gridded_perm in reduced_tiling.objects_of_size(reduced_size) + for own_gridded_perm in own_by_sizes[ + reduced_group_sizes(reduced_gridded_perm) + ] + if reduced_size + + len(own_gridded_perm) + - sum(reduced_group_sizes(reduced_gridded_perm)) + == n + } + assert images == pairs + + +def test_monotone_obstructions(): + """In each cell, and between the two cells of each row and of each column, in + the order of the direction: the lower (left) cell's points left of (below) the + other's when increasing, right of (above) them when decreasing.""" + cells = [(0, 0), (1, 0), (0, 1)] + increasing_obstructions = [ + GriddedPerm(Perm((1, 0)), positions) + for positions in ( + ((0, 0), (0, 0)), + ((0, 0), (1, 0)), + ((0, 1), (0, 0)), + ((0, 1), (0, 1)), + ((1, 0), (1, 0)), + ) + ] + decreasing_obstructions = [ + GriddedPerm(Perm((0, 1)), positions) + for positions in ( + ((0, 0), (0, 0)), + ((0, 0), (0, 1)), + ((0, 0), (1, 0)), + ((0, 1), (0, 1)), + ((1, 0), (1, 0)), + ) + ] + assert sorted(monotone_obstructions(cells, True)) == sorted(increasing_obstructions) + assert sorted(monotone_obstructions(cells, False)) == sorted( + decreasing_obstructions + ) + + +def test_groups_along_a_row(alike_row, unlike_row): + """Two cells of a row that the rest sees alike are one group, kept in the + first; two cells it does not see alike are two groups.""" + cells = [(0, 0), (1, 0)] + assert reduction_plan(alike_row, cells) == ((((0, 0), (1, 0)),), ()) + assert reduction_plan(unlike_row, cells) == ((((0, 0),), ((1, 0),)), ()) + for tiling in (alike_row, unlike_row): + assert is_reducible(tiling, cells, True) + assert not is_reducible(tiling, cells, False) + rule = ReductionStrategy(cells, True)(tiling) + assert all(rule.sanity_check(n) for n in range(6)) + reduced_tiling, own_tiling = reduction_children(alike_row, cells, True) + assert reduced_tiling.dimensions == (2, 1) + assert own_tiling.dimensions == (2, 1) + + +def test_groups_along_a_column(unlike_column, unlike_column_decreasing): + """The two cells of the column are two groups, both kept, so that test_maps + orders the points of two kept cells of a column: increasing in unlike_column, + decreasing in unlike_column_decreasing.""" + cells = [(0, 0), (0, 1)] + for tiling, increasing in ( + (unlike_column, True), + (unlike_column_decreasing, False), + ): + assert reduction_plan(tiling, cells) == ((((0, 0),), ((0, 1),)), ()) + assert is_reducible(tiling, cells, increasing) + assert not is_reducible(tiling, cells, not increasing) + + +def test_left_out_beside_kept(touched_only): + """A cell the rest sees only through an obstruction with a point in it is + kept, and the cell beside it, which the rest does not see, is left out.""" + cells = [(0, 0), (0, 1)] + assert reduction_plan(touched_only, cells) == ((((0, 0),),), ((0, 1),)) + for increasing in (True, False): + rule = ReductionStrategy(cells, increasing)(touched_only) + assert all(rule.sanity_check(n) for n in range(6)) + + +def test_requirement_list(with_requirement_list, alike_row): + """A requirement list with a point in the set and a point outside it. With a + point required in A or in C, A and B are two groups, as a point in B does not + meet the requirement; with a point in A, in B or in C, they are one group.""" + cells = [(0, 0), (1, 0)] + assert is_reducible(with_requirement_list, cells, True) + assert reduction_plan(with_requirement_list, cells) == ( + (((0, 0),), ((1, 0),)), + (), + ) + rule = ReductionStrategy(cells, True)(with_requirement_list) + assert all(rule.sanity_check(n) for n in range(6)) + in_any_cell = Tiling( + alike_row.obstructions, + [[GriddedPerm.point_perm(cell) for cell in [(0, 0), (1, 0), (2, 0)]]], + ) + assert reduction_plan(in_any_cell, cells) == ((((0, 0), (1, 0)),), ()) + + +def test_requirement_with_two_points_in_the_set(alike_row): + """A gridded permutation with two points in the set, in a requirement list + with points outside it, makes the set not reducible: a 12 from A to B, in a + list with a point in C, and a lone 231 across A, B and C, which has a point + outside the set itself. The direction is given, as alike_row is not reducible + decreasing anyway.""" + cells = [(0, 0), (1, 0)] + assert is_reducible(alike_row, cells, True) + for requirement in ( + [GriddedPerm(Perm((0, 1)), ((0, 0), (1, 0))), GriddedPerm.point_perm((2, 0))], + [GriddedPerm(Perm((1, 2, 0)), ((0, 0), (1, 0), (2, 0)))], + ): + tiling = Tiling(alike_row.obstructions, [requirement]) + assert sorted(tiling.requirements[0]) == sorted(requirement) + assert not is_reducible(tiling, cells, True) + with pytest.raises(StrategyDoesNotApply): + ReductionStrategy(cells, True)(tiling) + + +def test_seen_through_requirement(seen_through_requirement): + """Of the top row, B is kept, though its row and column have no cell outside + the set, since a requirement list with a point outside the set has a point in + it; A, which nothing outside the set sees, is left out.""" + cells = [(0, 1), (1, 1), (2, 1)] + assert reduction_plan(seen_through_requirement, cells) == ( + (((1, 1),), ((2, 1),)), + ((0, 1),), + ) + rule = ReductionStrategy(cells, True)(seen_through_requirement) + assert all(rule.sanity_check(n) for n in range(5)) + # the set of A and B alone is two groups, as the rest sees them alike one way + # only: moved from B to A, the requirement list's point leaves the list, while + # nothing the rest sees has a point in A to move to B + assert reduction_plan(seen_through_requirement, [(0, 1), (1, 1)]) == ( + (((0, 1),), ((1, 1),)), + (), + ) + + +def test_split_by_column(split_by_column, split_by_first_column): + """B and C, in a row with A outside the set, are two groups: C's column has D + outside the set, which sees C's points but not B's. So C joins no group, also + in the reverse, where it comes before B.""" + cells = [(1, 1), (2, 1)] + assert reduction_plan(split_by_column, cells) == ((((1, 1),), ((2, 1),)), ()) + rule = ReductionStrategy(cells, True)(split_by_column) + assert all(rule.sanity_check(n) for n in range(5)) + assert reduction_plan(split_by_first_column, [(0, 1), (1, 1)]) == ( + (((0, 1),), ((1, 1),)), + (), + ) + + +def test_seen_through_a_21(seen_through_a_21): + """A, in a column and a row with no cell outside the set, is kept: the rest + sees it through the 21 of the requirement list, whose other point is in C. The + 21 is in a list with a point, as Tiling splits a requirement of a lone 21 + between two cells that share no row or column into a point in each.""" + cells = [(0, 1), (1, 1)] + assert reduction_plan(seen_through_a_21, cells) == ((((0, 1),), ((1, 1),)), ()) + assert (tuple(cells), True) in { + (strategy.cells, strategy.increasing) + for strategy in ReductionFactory(True, "row")(seen_through_a_21) + } + + +def test_left_out_beside_a_21(left_out_beside_a_21): + """Of the top row, A is kept, though its row and column have no cell outside + the set, since the rest sees it through the 21 of the requirement list, whose + other point is in D; B, which nothing outside the set sees, is left out: the + case of test_left_out_beside_kept, with a requirement for the obstruction.""" + cells = [(0, 1), (1, 1), (2, 1)] + assert reduction_plan(left_out_beside_a_21, cells) == ( + (((0, 1),), ((2, 1),)), + ((1, 1),), + ) + + +def test_direction_not_reducible(threshold): + """A strategy applied in a direction its set is not reducible in.""" + with pytest.raises(StrategyDoesNotApply): + ReductionStrategy(THRESHOLD_CELLS, False)(threshold) + + +def random_gridded_perm(random_generator, cells, size): + """A gridded permutation with points in random cells, read left to right by + column and bottom to top by row.""" + positions = sorted(random_generator.choice(cells) for _ in range(size)) + by_value = sorted( + range(size), key=lambda index: (positions[index][1], random_generator.random()) + ) + values = [0] * size + for value, index in enumerate(by_value): + values[index] = value + return GriddedPerm(Perm(values), tuple(positions)) + + +def random_tiling(random_generator): + width, height = random_generator.randint(1, 3), random_generator.randint(1, 3) + cells = [(column, row) for column in range(width) for row in range(height)] + active = random_generator.sample( + cells, random_generator.randint(1, min(len(cells), 4)) + ) + patterns = [ + Perm(pattern) + for pattern in ((0, 1), (1, 0), (0, 2, 1), (1, 0, 2), (1, 2, 0), (0, 1, 2)) + ] + obstructions = [ + GriddedPerm.point_perm(cell) for cell in cells if cell not in active + ] + for cell in active: + for pattern in random_generator.sample( + patterns, random_generator.randint(1, 2) + ): + obstructions.append(GriddedPerm.single_cell(pattern, cell)) + for _ in range(random_generator.randint(1, 4)): + obstructions.append( + random_gridded_perm( + random_generator, active, random_generator.randint(2, 3) + ) + ) + requirements = ( + [[GriddedPerm.point_perm(random_generator.choice(active))]] + if random_generator.random() < 0.3 + else [] + ) + return Tiling(obstructions, requirements) + + +@pytest.mark.timeout(300) +def test_random_tilings(): + """Every reduction the factory offers on 30 random tilings, seeded, passes its + sanity check (counts, objects and random samples) to size 4.""" + random_generator = random.Random(2026) + tilings = [] + while len(tilings) < 30: + tiling = random_tiling(random_generator) + if not tiling.is_empty() and len(tiling.active_cells) >= 2: + tilings.append(tiling) + reductions = 0 + for tiling in tilings: + for strategy in ReductionFactory(True, "any", max_cells=3)(tiling): + rule = strategy(tiling) + assert all(rule.sanity_check(n) for n in range(5)), rule + reductions += 1 + assert reductions >= 30 + + +def test_ordered_line(threshold): + """An obstruction with one point in the set may see it along a row the set has + other cells in, when the set's own obstructions put those cells in the order of + the reduction: here the two low cells, the left one below the right one, so + increasing only.""" + assert is_reducible(threshold, THRESHOLD_CELLS, True) + assert not is_reducible(threshold, THRESHOLD_CELLS, False) + assert [ + strategy.increasing + for strategy in ReductionFactory(True, "any")(threshold) + if strategy.cells == tuple(THRESHOLD_CELLS) + ] == [True] + + +@pytest.mark.timeout(120) +def test_threshold(threshold): + """The reduction of the left cell's five cells: the two low cells are seen as + one, the point and the high cells are left out, and each child tracks one + group.""" + strategy = ReductionStrategy(THRESHOLD_CELLS, True) + assert strategy.plan(threshold) == ( + (((0, 0), (2, 0)),), + ((0, 2), (1, 1), (2, 2)), + ) + reduced_tiling, own_tiling = strategy.decomposition_function(threshold) + assert reduced_tiling.dimensions == (3, 2) + assert len(reduced_tiling.assumptions) == 1 + assert len(own_tiling.assumptions) == 1 + assert [len(list(threshold.objects_of_size(n))) for n in range(7)] == [ + 0, + 0, + 0, + 1, + 7, + 40, + 225, + ] + rule = strategy(threshold) + for n in range(7): + assert rule.sanity_check(n) + + +@pytest.mark.timeout(120) +def test_threshold_inverse(threshold): + """The inverse of the threshold tiling: the two low cells are in one column, + which the cell below the topmost point's cell shares, and are seen as one.""" + tiling = threshold.inverse() + cells = [(row, column) for column, row in THRESHOLD_CELLS] + assert is_reducible(tiling, cells, True) + assert not is_reducible(tiling, cells, False) + strategy = ReductionStrategy(cells, True) + groups, left_out = strategy.plan(tiling) + assert groups == (((0, 0), (0, 2)),) + assert len(left_out) == 3 + rule = strategy(tiling) + for n in range(7): + assert rule.sanity_check(n) + + +def test_parent_assumptions(apart): + """The parent's tracked cells keep their counts. Given no children, + extra_parameters computes them.""" + strategy = ReductionStrategy([(1, 0), (2, 2)], True) + rule = strategy(apart) + for n in range(6): + assert rule.sanity_check(n) + assert len(rule.children[0].extra_parameters) == 3 + children = strategy.decomposition_function(apart) + parameters = strategy.extra_parameters(apart) + assert parameters == strategy.extra_parameters(apart, children) + assert len(parameters[0]) == 2 + + +def test_unrestricted_cell(): + """A cell with no obstruction of its own is not reduced: its own tiling would be + Tiling(), with only the empty permutation.""" + tiling = tiling_from( + {(1, 0): [Perm((1, 0))]}, [(Perm((0, 1)), ((0, 0), (1, 0)))], [] + ) + assert not is_reducible(tiling, [(0, 0)]) + assert list(ReductionFactory(True, "any")(tiling)) == [] + assert list(CellReductionFactory(True)(tiling)) == [] + + +def test_factory_skips(): + """The factory does not offer a single finite cell, one whose basis has an + increasing and a decreasing pattern, though it is reducible and not rigid, nor a + set the rest of the tiling sees nothing of: a factor. A cell whose basis has + only one of the two patterns is offered.""" + for basis in ( + [Perm((0, 1, 2)), Perm((2, 1, 0))], + [Perm((0, 1, 2))], + [Perm((2, 1, 0))], + ): + cell_tiling = tiling_from( + {(0, 0): basis, (1, 0): [Perm((1, 0))]}, + [(Perm((0, 1)), ((0, 0), (1, 0)))], + [], + ) + finite = len(basis) == 2 + assert is_reducible(cell_tiling, [(0, 0)]) + assert not is_rigid(cell_tiling, [(0, 0)]) + assert ReductionFactory.is_finite_cell(cell_tiling, (0, 0)) == finite + assert offered_sets(cell_tiling, "cell") == ([] if finite else [((0, 0),)]) + factor = tiling_from( + {(0, 0): [Perm((2, 1, 0))], (1, 1): [Perm((2, 1, 0))]}, [], [(0, 1), (1, 0)] + ) + assert is_reducible(factor, [(0, 0)], True) + assert reduction_plan(factor, [(0, 0)]) == ((), ((0, 0),)) + assert list(ReductionFactory(True, "any")(factor)) == [] + + +def test_tracked_pattern(): + """A tracking assumption on a gridded permutation of two or more points counts + occurrences of a pattern, which the sorting does not keep, as it keeps only + the number of points in each cell: a set with a cell of one is not reduced, + and the reductions still offered pass their sanity check.""" + tiling = Tiling( + [ + GriddedPerm(Perm((0, 1)), ((0, 1),) * 2), + GriddedPerm(Perm((1, 0)), ((0, 1),) * 2), + GriddedPerm(Perm((0, 2, 1)), ((1, 1),) * 3), + GriddedPerm(Perm((1, 0, 2)), ((1, 0),) * 3), + ], + assumptions=[TrackingAssumption([GriddedPerm(Perm((0, 1)), ((1, 0),) * 2)])], + ) + assert not is_reducible(tiling, [(1, 0)]) + for increasing in (True, False): + with pytest.raises(StrategyDoesNotApply): + ReductionStrategy([(1, 0)], increasing)(tiling) + offered = offered_sets(tiling, "any") + assert offered and all((1, 0) not in cells for cells in offered) + for strategy in ReductionFactory(True, "any")(tiling): + rule = strategy(tiling) + assert all(rule.sanity_check(n) for n in range(5)) + + +def test_equiv( + alike_row, + unlike_row, + split_by_column, + seen_through_requirement, + touched_only, + one_cell, +): + """Two reductions' constructors are equivalent when they pair their children + the same way: cells left out or not, and the groups' and the parent's + parameters at the same places of the children's parameters.""" + + def constructor(tiling, cells, increasing=True): + return ReductionStrategy(cells, increasing).constructor(tiling) + + def tracking(tiling, cell): + return tiling.add_assumption(TrackingAssumption.from_cells([cell])) + + alike = constructor(alike_row, [(0, 0), (1, 0)]) + unlike = constructor(unlike_row, [(0, 0), (1, 0)]) + tracking_the_set = constructor(tracking(one_cell, (0, 0)), [(0, 0)]) + pairs = [ + # one group each, nothing left out + (alike, constructor(alike_row.complement(), [(0, 0), (1, 0)], False), True), + # two groups each, nothing left out + (unlike, constructor(split_by_column, [(1, 1), (2, 1)]), True), + # one group and two + (alike, unlike, False), + # two groups each, a cell left out in one only + ( + unlike, + constructor(seen_through_requirement, [(0, 1), (1, 1), (2, 1)]), + False, + ), + # one group each, a cell left out in one only, and so its own tiling + # tracked: two differences + (alike, constructor(touched_only, [(0, 0), (0, 1)]), False), + # the parent tracking the set's cell, or the cell after it: the parent's + # parameter at the group's place, or after it + (tracking_the_set, constructor(tracking(one_cell, (1, 0)), [(0, 0)]), False), + # the parent tracking the set's cell, or the cell before it: the group's + # parameter at the parent's place, or after it + ( + tracking_the_set, + constructor(tracking(one_cell.reverse(), (0, 0)), [(1, 0)]), + False, + ), + ] + for first, second, equivalent in pairs: + assert first.equiv(second)[0] == equivalent + assert second.equiv(first)[0] == equivalent + assert not alike.equiv(DisjointUnion(alike_row, (alike_row,), ({},)))[0] + + +def test_bounded_compositions(): + """The tuples of parts numbers at least 0 whose sum is at most bound, each + once: C(bound + parts, parts) of them.""" + assert list(_bounded_compositions(5, 0)) == [()] + assert list(_bounded_compositions(0, 3)) == [(0, 0, 0)] + compositions = list(_bounded_compositions(4, 3)) + assert len(set(compositions)) == len(compositions) == math.comb(7, 3) + assert all( + len(parts) == 3 and min(parts) >= 0 and sum(parts) <= 4 + for parts in compositions + ) + + +def test_not_tracked(two_rows, one_cell): + """An untracked strategy tracks nothing and has no constructor; a factory's + strategies are tracked as the factory is.""" + strategy = ReductionStrategy([(1, 0), (2, 0), (3, 1)], True, tracked=False) + reduced_tiling, _ = strategy.decomposition_function(two_rows) + assert not reduced_tiling.assumptions + with pytest.raises(NotImplementedError): + strategy.constructor(two_rows) + for tracked in (True, False): + for factory, tiling in ( + (ReductionFactory(tracked, "row"), two_rows), + (CellReductionFactory(tracked), one_cell), + ): + strategies = list(factory(tiling)) + assert strategies + assert all(strategy.tracked == tracked for strategy in strategies) + + +def test_cell_reduction(one_cell, two_rows): + """The cell reduction is the reduction of single cells.""" + for tiling in (one_cell, two_rows): + cell_strategies = list(CellReductionFactory(True)(tiling)) + assert all( + isinstance(strategy, CellReductionStrategy) for strategy in cell_strategies + ) + assert sorted( + (strategy.cells, strategy.increasing) for strategy in cell_strategies + ) == sorted( + (strategy.cells, strategy.increasing) + for strategy in ReductionFactory(True, "cell")(tiling) + ) + rule = CellReductionStrategy((0, 0), True)(one_cell) + for n in range(6): + assert rule.sanity_check(n) + assert str(CellReductionFactory(True)) == "tracked cell reduction factory" + + +def test_strategy_cells(): + """A strategy's cells are sorted and without repeats, so that strategies of + the same set compare equal.""" + assert ReductionStrategy([(1, 0), (0, 0), (1, 0)], True).cells == ((0, 0), (1, 0)) + assert ReductionStrategy([(1, 0), (0, 0)], True) == ReductionStrategy( + [(0, 0), (1, 0)], True + ) + + +def test_encoding(): + """The strategies, in each direction, tracked or not, and the factories decode + to what they encode.""" + strategies = [ + strategy + for increasing in (True, False) + for tracked in (True, False) + for strategy in ( + ReductionStrategy([(0, 0), (1, 0)], increasing, tracked), + CellReductionStrategy((2, 1), increasing, tracked), + ) + ] + for strategy in strategies + [ + ReductionFactory(True, "line"), + ReductionFactory(True, "cut", 6), + ReductionFactory(False, "any", 3), + CellReductionFactory(True), + ]: + decoded = Strategy.from_dict(json.loads(json.dumps(strategy.to_jsonable()))) + assert decoded.__class__ == strategy.__class__ + assert decoded.__dict__ == strategy.__dict__ + assert CellReductionStrategy((2, 1), False).to_jsonable()["cell"] == (2, 1) + assert "region" not in CellReductionFactory(True).to_jsonable() + + +@pytest.mark.timeout(120) +def test_specification(): + """A specification of Av(1243, 2314, 2413) that reduces a set of three cells, + with row and column placements, tracked fusion and the reduction of any set of + at most three cells. The search goes level by level: after the fourth, the + first with a specification, every specification of the class reduces a set of + three cells (fusion has none before the fifth), so whichever is extracted does. + auto_search would stop as its timing allows and extract a specification at + random, which need not reduce three cells where there are other kinds.""" + pack = TileScopePack.row_and_col_placements(row_only=False) + counting_verification = ( + strat.BasicVerificationStrategy, + strat.OneByOneVerificationStrategy, + ) + pack = TileScopePack( + initial_strats=pack.initial_strats, + ver_strats=[ + strategy + for strategy in pack.ver_strats + if isinstance(strategy, counting_verification) + ], + inferral_strats=pack.inferral_strats, + expansion_strats=pack.expansion_strats, + name=pack.name, + ) + pack = pack.make_fusion(tracked=True).add_initial( + ReductionFactory(True, "any", max_cells=3) + ) + searcher = TileScope("1243_2314_2413", pack) + for _ in range(4): + searcher.do_level() + spec = searcher.get_specification(minimization_time_limit=0) + assert isinstance(spec, CombinatorialSpecification) + assert any( + isinstance(rule.strategy, ReductionStrategy) and len(rule.strategy.cells) == 3 + for rule in spec.rules_dict.values() + ) + basis = [Perm((0, 1, 3, 2)), Perm((1, 2, 0, 3)), Perm((1, 3, 0, 2))] + assert [spec.count_objects_of_size(n) for n in range(9)] == [ + len(list(Av(basis).of_length(n))) for n in range(9) + ] diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py new file mode 100644 index 00000000..cc91292f --- /dev/null +++ b/tilings/algorithms/reduction.py @@ -0,0 +1,731 @@ +"""The reduction of a set of cells of a tiling: the condition under which a set is +reducible, the plan of what the reduced tiling keeps of it, the two children, and +the words that the maps between the tiling and the children are built from. + +A set of active cells of a tiling is reducible, in a direction (increasing or +decreasing), if every obstruction with points both in the set and outside it has +exactly one point in the set, in the cell c say, and + +- it does not have points both in another cell of the row of c and in another + cell of the column of c; +- if it has a point in another cell of the column (row) of c, then the cells of + the set in that column (row) are already in the order of the direction: c is + the only one, or the tiling's obstructions put the points of the lower (left) + of any two of them all to the left of (below) those of the other, the other + way round when decreasing; + +every requirement list lies in the set, or each of its gridded permutations has +no point in the set or one point in it as an obstruction may have; each cell of +the set lies in an obstruction that lies in the set; and no assumption with a +point in the set is a component assumption or tracks a gridded permutation of +two or more points. + +A gridded permutation is the same as, for each column, the word of the cells of +its points from left to right, for each row, the word of the cells of its points +from bottom to top, and the permutation in each cell, chosen independently. The +share of a set of cells in these is the order of its points among themselves in +each column and in each row, and the permutation in each of its cells; the rest +is the places of the set's points among the other points of each column and +row, and the permutations in the other cells. An obstruction with one point in a +reducible set sees that point only through its cell and its place among the +other points of its column or of its row, and which of the set's places there +are the cell's is fixed by the rest: the cell is the only one of the set there, +or the set's own obstructions fix the order of its cells there. Sorting the +share into the canonical one, in which every cell of the set is increasing (or +decreasing), and so are the points of the cells of the set in each row and in +each column, keeps both. The sorting keeps the number of points in each cell, +but not the occurrences of a pattern, which is why a tracked pattern of two or +more points may not touch the set. + +So the gridded permutations of the tiling are the pairs of a gridded +permutation of the reduced tiling, whose share is canonical, and one of the own +tiling, with as many points in the cells of the set. The own tiling is the +tiling of the set's own obstructions and requirements, those that lie in the +set, with every other cell empty. As `Tiling` takes a cell in no obstruction and +no requirement to be empty, each cell of the set must lie in one of the set's +own obstructions or requirements; the condition asks for an obstruction, which +is stricter. + +The reduced tiling keeps what the rest of the tiling sees of the set and no +more. A cell of the set whose row and column have no cell outside the set, and +in which no obstruction or requirement with points outside the set has a point, +is seen only through its number of points, which the own tiling counts: the +reduced tiling leaves it out. Cells of the set in one row are seen as one if +their columns, and the columns between them, have no cell outside the set, no +other cell of the set that the rest sees lies between them, and the +obstructions and requirements with points outside the set treat a point in any +of them alike; likewise in a column. The reduced tiling keeps the first of such +a group, with all the group's points. + +`tilings.strategies.reduction` has the strategy, its constructor, which counts +the pairs, and its factory. +""" + +from collections import Counter, defaultdict +from functools import lru_cache +from itertools import product +from operator import itemgetter +from typing import ( + TYPE_CHECKING, + DefaultDict, + Dict, + FrozenSet, + Iterable, + Iterator, + List, + NamedTuple, + Optional, + Set, + Tuple, +) + +from permuta import Perm +from tilings.assumptions import ComponentAssumption, TrackingAssumption +from tilings.griddedperm import GriddedPerm + +if TYPE_CHECKING: + from tilings import Tiling + +__all__ = ( + "combine_gridded_perms", + "is_reducible", + "is_rigid", + "monotone_obstructions", + "own_is_tracked", + "reduce_gridded_perm", + "ReductionPlan", + "reduction_children", + "reduction_plan", +) + +Cell = Tuple[int, int] +Groups = Tuple[Tuple[Cell, ...], ...] +Words = Dict[int, List[Cell]] + + +class ReductionPlan(NamedTuple): + """The groups of the set's cells that the rest of the tiling sees as one, each + first the cell the reduced tiling keeps for it, and the cells the rest does not + see, which the reduced tiling leaves out.""" + + groups: Groups + left_out: Tuple[Cell, ...] + + +class _Layout(NamedTuple): + """The plan, and the columns and the rows with cells outside the set.""" + + plan: ReductionPlan + outside_columns: FrozenSet[int] + outside_rows: FrozenSet[int] + + +def _lies_in(gridded_perms: Iterable[GriddedPerm], cells: FrozenSet[Cell]) -> bool: + """Return True if every point of the gridded permutations is in the cells.""" + return all( + cell in cells for gridded_perm in gridded_perms for cell in gridded_perm.pos + ) + + +def _crosses(gridded_perm: GriddedPerm, cells: FrozenSet[Cell]) -> bool: + """Return True if the gridded permutation has points in the cells and outside + them.""" + points_inside = sum(1 for cell in gridded_perm.pos if cell in cells) + return 0 < points_inside < len(gridded_perm) + + +def monotone_obstructions(cells: Iterable[Cell], increasing: bool) -> List[GriddedPerm]: + """Return the gridded 21s (increasing) or 12s (decreasing) with both points in + cells of the set that share a row or a column, which make the share of the set + canonical.""" + cell_list = sorted(set(cells)) + pattern = Perm((1, 0)) if increasing else Perm((0, 1)) + obstructions = [] + for left_cell, right_cell in product(cell_list, repeat=2): + if left_cell[0] != right_cell[0] and left_cell[1] != right_cell[1]: + continue + if left_cell[0] > right_cell[0]: + continue + if increasing and left_cell[1] < right_cell[1]: + continue + if not increasing and left_cell[1] > right_cell[1]: + continue + obstructions.append(GriddedPerm(pattern, (left_cell, right_cell))) + return obstructions + + +def _in_order(tiling: "Tiling", line_cells: Iterable[Cell], increasing: bool) -> bool: + """Return True if the tiling's obstructions put the points of the cells, which + share a row or a column, in the order of the direction: each monotone + obstruction between two of them is implied.""" + return all( + obstruction.contains(*tiling.obstructions) + for obstruction in monotone_obstructions(line_cells, increasing) + if obstruction.pos[0] != obstruction.pos[1] + ) + + +def _ordered_lines( + tiling: "Tiling", cells: FrozenSet[Cell], increasing: bool +) -> Tuple[Set[int], Set[int]]: + """The columns, and the rows, whose cells of the set are in the order of the + direction, those with one cell of the set among them.""" + ordered_columns = { + column + for column in {cell[0] for cell in cells} + if _in_order(tiling, [cell for cell in cells if cell[0] == column], increasing) + } + ordered_rows = { + row + for row in {cell[1] for cell in cells} + if _in_order(tiling, [cell for cell in cells if cell[1] == row], increasing) + } + return ordered_columns, ordered_rows + + +def _crossing_is_allowed( + gridded_perm: GriddedPerm, + cells: FrozenSet[Cell], + ordered_columns: Set[int], + ordered_rows: Set[int], +) -> bool: + """Return True if the gridded permutation, which has points in the cells and + outside them, has exactly one point in them, and touches either no other cell + of that point's row or no other cell of its column, and touches the other cells + of the column (row) only if the cells' points there are in the order of the + direction.""" + cells_inside = [cell for cell in gridded_perm.pos if cell in cells] + if len(cells_inside) != 1: + return False + column, row = cells_inside[0] + cells_outside = [cell for cell in gridded_perm.pos if cell not in cells] + touches_column = any(cell[0] == column for cell in cells_outside) + touches_row = any(cell[1] == row for cell in cells_outside) + if touches_column and touches_row: + return False + if touches_column and column not in ordered_columns: + return False + if touches_row and row not in ordered_rows: + return False + return True + + +def _qualifies(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> bool: + """Return True if the set is of active cells, the assumptions track points in + its cells only one by one (no component assumption on them, and no tracked + gridded permutation of two or more points with a point in them), and each of + its cells lies in an obstruction that lies in the set, which keeps it active in + the own tiling (an unrestricted cell lies in none; a requirement that lies in + the set would keep it active too, but is not taken).""" + if not cell_set or not cell_set <= tiling.active_cells: + return False + if any( + (isinstance(assumption, ComponentAssumption) or len(gridded_perm) > 1) + and any(cell in cell_set for cell in gridded_perm.pos) + for assumption in tiling.assumptions + for gridded_perm in assumption.gps + ): + return False + cells_with_own_obstructions = { + cell + for obstruction in tiling.obstructions + if all(cell in cell_set for cell in obstruction.pos) + for cell in obstruction.pos + } + return cell_set <= cells_with_own_obstructions + + +def _crossing_obstructions( + tiling: "Tiling", cell_set: FrozenSet[Cell] +) -> Iterator[GriddedPerm]: + """The obstructions with points in the set and outside it.""" + return ( + obstruction + for obstruction in tiling.obstructions + if _crosses(obstruction, cell_set) + ) + + +def _crossing_requirements( + tiling: "Tiling", cell_set: FrozenSet[Cell] +) -> Iterator[Tuple[GriddedPerm, ...]]: + """The requirement lists that do not lie in the set.""" + return ( + requirement + for requirement in tiling.requirements + if not _lies_in(requirement, cell_set) + ) + + +def _seen_gridded_perms( + tiling: "Tiling", cell_set: FrozenSet[Cell] +) -> Iterator[GriddedPerm]: + """The gridded permutations the rest of the tiling sees the set through, one + at a time: the obstructions with points in the set and outside it, and the + gridded permutations with points in the set of the requirement lists that do + not lie in it.""" + yield from _crossing_obstructions(tiling, cell_set) + for requirement in _crossing_requirements(tiling, cell_set): + for gridded_perm in requirement: + if any(cell in cell_set for cell in gridded_perm.pos): + yield gridded_perm + + +def is_reducible( + tiling: "Tiling", cells: Iterable[Cell], increasing: Optional[bool] = None +) -> bool: + """Return True if the set of cells is reducible on the tiling in the direction, + or in one of the two directions if none is given.""" + cell_set = frozenset(cells) + if increasing is None: + return is_reducible(tiling, cell_set, True) or is_reducible( + tiling, cell_set, False + ) + if not _qualifies(tiling, cell_set): + return False + ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) + # one at a time, to stop at the first gridded permutation not allowed + return all( + _crossing_is_allowed(gridded_perm, cell_set, ordered_columns, ordered_rows) + for gridded_perm in _seen_gridded_perms(tiling, cell_set) + ) + + +class _View(NamedTuple): + """What the rest of the tiling has to do with a set of cells: the set, the + columns and the rows with cells outside it, the obstructions and the + requirement lists with points outside it, the cells of the set with a point of + a gridded permutation the rest sees it through, and the tracked cells.""" + + cell_set: FrozenSet[Cell] + outside_columns: FrozenSet[int] + outside_rows: FrozenSet[int] + crossing_obstructions: Tuple[GriddedPerm, ...] + crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] + touched: FrozenSet[Cell] + tracked: FrozenSet[Cell] + + +def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: + outside = tiling.active_cells - cell_set + return _View( + cell_set, + frozenset(cell[0] for cell in outside), + frozenset(cell[1] for cell in outside), + tuple(_crossing_obstructions(tiling, cell_set)), + tuple(_crossing_requirements(tiling, cell_set)), + frozenset( + cell + for gridded_perm in _seen_gridded_perms(tiling, cell_set) + for cell in gridded_perm.pos + if cell in cell_set + ), + frozenset( + cell + for assumption in tiling.assumptions + for gridded_perm in assumption.gps + for cell in gridded_perm.pos + ), + ) + + +def is_rigid(tiling: "Tiling", cells: Iterable[Cell]) -> bool: + """Return True if the share of the cells is the same in every gridded + permutation with the same number of points in each cell: every cell is + monotone, and the points of any two of the cells in a row or a column are in + a fixed order. Reducing such cells changes nothing, or reverses them.""" + cell_list = sorted(set(cells)) + obstructions = tiling.obstructions + + def is_forbidden(gridded_perm: GriddedPerm) -> bool: + return gridded_perm.contains(*obstructions) + + for first_cell, second_cell in product(cell_list, repeat=2): + if first_cell > second_cell: + continue + if first_cell == second_cell or first_cell[1] == second_cell[1]: + # a cell, or two cells of a row, read from left to right + if not ( + is_forbidden(GriddedPerm(Perm((0, 1)), (first_cell, second_cell))) + or is_forbidden(GriddedPerm(Perm((1, 0)), (first_cell, second_cell))) + ): + return False + elif first_cell[0] == second_cell[0]: + # two cells of a column, the lower one first + if not ( + is_forbidden(GriddedPerm(Perm((0, 1)), (first_cell, second_cell))) + or is_forbidden(GriddedPerm(Perm((1, 0)), (second_cell, first_cell))) + ): + return False + return True + + +def _moved(gridded_perm: GriddedPerm, source: Cell, target: Cell) -> GriddedPerm: + """The gridded permutation with its point in the source cell in the target + cell instead.""" + return GriddedPerm( + gridded_perm.patt, + tuple(target if cell == source else cell for cell in gridded_perm.pos), + ) + + +def _seen_alike(tiling: "Tiling", source: Cell, target: Cell, view: _View) -> bool: + """Return True if every obstruction with points outside the set that has its + point of the set in the source cell is still an obstruction with that point in + the target cell, and every such gridded permutation of a requirement list is + still in the list.""" + for obstruction in view.crossing_obstructions: + if source in obstruction.pos and not _moved( + obstruction, source, target + ).contains(*tiling.obstructions): + return False + for requirement in view.crossing_requirements: + for gridded_perm in requirement: + if ( + source in gridded_perm.pos + and _moved(gridded_perm, source, target) not in requirement + ): + return False + return True + + +def _left_out(view: _View) -> Tuple[Cell, ...]: + """The cells of the set the rest does not see: no cell outside the set in + their row or column, no point of an obstruction or requirement with points + outside the set, and not tracked.""" + return tuple( + cell + for cell in sorted(view.cell_set) + if cell[0] not in view.outside_columns + and cell[1] not in view.outside_rows + and cell not in view.touched + and cell not in view.tracked + ) + + +def _runs( + tiling: "Tiling", seen: List[Cell], view: _View, along_row: bool +) -> List[Tuple[Cell, ...]]: + """The groups of two or more cells that the rest sees as one, in the rows (or + the columns) with cells outside the set: consecutive cells of such a line whose + columns (rows), and those between them, have no cell outside the set, and that + the obstructions and requirements with points outside the set treat alike.""" + line_index, place_index = (1, 0) if along_row else (0, 1) + outside_lines = view.outside_rows if along_row else view.outside_columns + outside_places = view.outside_columns if along_row else view.outside_rows + lines: DefaultDict[int, List[Cell]] = defaultdict(list) + for cell in seen: + if cell[line_index] in outside_lines: + lines[cell[line_index]].append(cell) + runs: List[Tuple[Cell, ...]] = [] + for line in sorted(lines): + run: List[Cell] = [] + for cell in sorted(lines[line], key=itemgetter(place_index)): + joins = cell not in view.tracked and cell[place_index] not in outside_places + if ( + joins + and run + and not any( + run[-1][place_index] < place < cell[place_index] + for place in outside_places + ) + and _seen_alike(tiling, run[-1], cell, view) + and _seen_alike(tiling, cell, run[-1], view) + ): + run.append(cell) + continue + if len(run) > 1: + runs.append(tuple(run)) + run = [cell] if joins else [] + if len(run) > 1: + runs.append(tuple(run)) + return runs + + +# The maps call _layout once for every gridded permutation they map, each time for the +# same tiling and set: the cache computes the plan and the lines with cells outside +# the set once for each. +@lru_cache(maxsize=1024) +def _layout(tiling: "Tiling", cells: Tuple[Cell, ...]) -> _Layout: + """The plan of the reduction (see reduction_plan), and the columns and the + rows with cells outside the set.""" + view = _view(tiling, frozenset(cells)) + left_out = _left_out(view) + seen = [cell for cell in cells if cell not in left_out] + # a cell of a group along a row has a column with no cell outside the set, so + # it is in no column with one, and the two kinds of group do not meet + runs = _runs(tiling, seen, view, along_row=True) + _runs( + tiling, seen, view, along_row=False + ) + grouped = {cell for run in runs for cell in run} + groups = runs + [(cell,) for cell in seen if cell not in grouped] + return _Layout( + ReductionPlan(tuple(sorted(groups)), left_out), + view.outside_columns, + view.outside_rows, + ) + + +def reduction_plan(tiling: "Tiling", cells: Iterable[Cell]) -> ReductionPlan: + """Return the groups of the set's cells that the rest of the tiling sees as + one, each first the cell the reduced tiling keeps for it, and the cells the + rest does not see, which the reduced tiling leaves out.""" + return _layout(tiling, tuple(sorted(set(cells)))).plan + + +def own_is_tracked(groups: Groups, left_out: Tuple[Cell, ...]) -> bool: + """The own tiling tracks the groups unless its size alone is their number of + points: one group, and no cell left out.""" + return len(groups) != 1 or bool(left_out) + + +def _reduced_tiling( + tiling: "Tiling", + cell_set: FrozenSet[Cell], + plan: ReductionPlan, + increasing: bool, + tracked: bool, +) -> "Tiling": + groups, left_out = plan + kept = [group[0] for group in groups] + emptied = [cell for group in groups for cell in group[1:]] + list(left_out) + return tiling.__class__( + [ + obstruction + for obstruction in tiling.obstructions + if not _lies_in([obstruction], cell_set) + ] + + monotone_obstructions(kept, increasing) + + [GriddedPerm.point_perm(cell) for cell in emptied], + [ + requirement + for requirement in tiling.requirements + if not _lies_in(requirement, cell_set) + ], + list(tiling.assumptions) + + ([TrackingAssumption.from_cells([cell]) for cell in kept] if tracked else []), + ) + + +def _own_tiling( + tiling: "Tiling", cell_set: FrozenSet[Cell], plan: ReductionPlan +) -> "Tiling": + # Not Tiling.sub_tiling: it keeps the parts in the set of the parent's + # assumptions, which the reduced tiling counts, and it does not simplify the + # tiling, while searches compare simplified tilings. + groups, left_out = plan + width, height = tiling.dimensions + other_cells_emptied = [ + GriddedPerm.point_perm((column, row)) + for column in range(width) + for row in range(height) + if (column, row) not in cell_set + ] + return tiling.__class__( + [ + obstruction + for obstruction in tiling.obstructions + if _lies_in([obstruction], cell_set) + ] + + other_cells_emptied, + [ + requirement + for requirement in tiling.requirements + if _lies_in(requirement, cell_set) + ], + ( + [TrackingAssumption.from_cells(list(group)) for group in groups] + if own_is_tracked(groups, left_out) + else [] + ), + ) + + +def reduction_children( + tiling: "Tiling", + cells: Iterable[Cell], + increasing: bool, + tracked: bool = True, +) -> Tuple["Tiling", "Tiling"]: + """Return the reduced tiling, with a cell for each group of the set that the + rest sees as one and none for the cells it does not see, those cells increasing + (or decreasing) and in that order in their rows and columns, each tracked if + tracked; and the own tiling, each group tracked if there are two or more, or + cells left out.""" + cell_set = frozenset(cells) + plan = reduction_plan(tiling, cell_set) + return ( + _reduced_tiling(tiling, cell_set, plan, increasing, tracked), + _own_tiling(tiling, cell_set, plan), + ) + + +def _words(gridded_perm: GriddedPerm) -> Tuple[Words, Words, Dict[Cell, List[int]]]: + """The words of the gridded permutation: for each column the cells of its + points from left to right, for each row from bottom to top, and for each cell + the ranks of the values of its points from left to right.""" + column_words: DefaultDict[int, List[Cell]] = defaultdict(list) + for cell in gridded_perm.pos: + column_words[cell[0]].append(cell) + row_words: DefaultDict[int, List[Cell]] = defaultdict(list) + for index in sorted( + range(len(gridded_perm)), key=lambda index: gridded_perm.patt[index] + ): + row_words[gridded_perm.pos[index][1]].append(gridded_perm.pos[index]) + values_in_cell: DefaultDict[Cell, List[int]] = defaultdict(list) + for value, cell in zip(gridded_perm.patt, gridded_perm.pos): + values_in_cell[cell].append(value) + ranks = { + cell: list(Perm.to_standard(values)) for cell, values in values_in_cell.items() + } + return column_words, row_words, ranks + + +def _slots(words: Words) -> Tuple[DefaultDict[Cell, List[int]], int]: + """For each cell, the places of its letters in the words read one line after + the other, and the number of letters.""" + slots: DefaultDict[Cell, List[int]] = defaultdict(list) + index = 0 + for line in sorted(words): + for cell in words[line]: + slots[cell].append(index) + index += 1 + return slots, index + + +def _from_words( + column_words: Words, row_words: Words, ranks: Dict[Cell, List[int]] +) -> GriddedPerm: + """The gridded permutation with the words.""" + positions, size = _slots(column_words) + values, number_of_values = _slots(row_words) + if number_of_values != size: + raise ValueError("the column words and the row words have different lengths") + pattern = [0] * size + cells_of_points: List[Cell] = [(0, 0)] * size + for cell, cell_positions in positions.items(): + if not len(values[cell]) == len(cell_positions) == len(ranks[cell]): + raise ValueError(f"the words have different numbers of points in {cell}") + for position, rank in zip(cell_positions, ranks[cell]): + pattern[position] = values[cell][rank] + cells_of_points[position] = cell + return GriddedPerm(Perm(pattern), tuple(cells_of_points)) + + +def _monotone_ranks(size: int, increasing: bool) -> List[int]: + return list(Perm.identity(size) if increasing else Perm.monotone_decreasing(size)) + + +def _canonical_words( + kept: List[Cell], points_in_cell: Counter, *, along_row: bool, increasing: bool +) -> Words: + """The canonical word of each row (from bottom to top) or each column (from + left to right) of the kept cells, each with its number of points: the cells in + the order of their columns (rows), the other way round when decreasing.""" + line_index, place_index = (1, 0) if along_row else (0, 1) + lines: DefaultDict[int, List[Cell]] = defaultdict(list) + for cell in kept: + lines[cell[line_index]].append(cell) + return { + line: [ + cell + for cell in sorted( + cells, key=itemgetter(place_index), reverse=not increasing + ) + for _ in range(points_in_cell[cell]) + ] + for line, cells in lines.items() + } + + +def _fill( + word: List[Cell], cells: FrozenSet[Cell], filler: List[Cell], line: int +) -> List[Cell]: + """The word with its letters of the cells, in order, replaced by the filler's.""" + if sum(1 for cell in word if cell in cells) != len(filler): + raise ValueError(f"the line {line} has a different number of the set's points") + letters = iter(filler) + return [next(letters) if cell in cells else cell for cell in word] + + +def _combined_words( + base: Words, filler: Words, cells: FrozenSet[Cell], outside_lines: FrozenSet[int] +) -> Words: + """For each line with cells outside the set, the base's word with its letters + of the set replaced, in order, by the filler's; for every other line, the + filler's word.""" + return { + line: ( + _fill(base.get(line, []), cells, filler.get(line, []), line) + if line in outside_lines + else list(filler.get(line, [])) + ) + for line in set(base) | set(filler) + } + + +def reduce_gridded_perm( + tiling: "Tiling", + cells: Iterable[Cell], + gridded_perm: GriddedPerm, + increasing: bool, +) -> GriddedPerm: + """Return the gridded permutation of the reduced tiling for one of the tiling, in + the tiling's cells: the share of the set canonical, each group's points in its + first cell, and the points of the cells left out removed.""" + cell_tuple = tuple(sorted(set(cells))) + layout = _layout(tiling, cell_tuple) + cell_set = frozenset(cell_tuple) + keeper = {cell: group[0] for group in layout.plan.groups for cell in group} + kept = [group[0] for group in layout.plan.groups] + points_in_cell = Counter( + keeper[cell] for cell in gridded_perm.pos if cell in keeper + ) + column_words, row_words, ranks = _words(gridded_perm) + new_ranks = {cell: values for cell, values in ranks.items() if cell not in cell_set} + for cell in kept: + new_ranks[cell] = _monotone_ranks(points_in_cell[cell], increasing) + return _from_words( + _combined_words( + column_words, + _canonical_words( + kept, points_in_cell, along_row=False, increasing=increasing + ), + cell_set, + layout.outside_columns, + ), + _combined_words( + row_words, + _canonical_words( + kept, points_in_cell, along_row=True, increasing=increasing + ), + cell_set, + layout.outside_rows, + ), + new_ranks, + ) + + +def combine_gridded_perms( + tiling: "Tiling", + cells: Iterable[Cell], + reduced_gridded_perm: GriddedPerm, + own_gridded_perm: GriddedPerm, +) -> GriddedPerm: + """Return the gridded permutation of the tiling for one of the reduced tiling and + one of the own tiling, both in the tiling's cells: the rest from the first, the + share of the set from the second.""" + cell_tuple = tuple(sorted(set(cells))) + layout = _layout(tiling, cell_tuple) + cell_set = frozenset(cell_tuple) + reduced_columns, reduced_rows, reduced_ranks = _words(reduced_gridded_perm) + own_columns, own_rows, own_ranks = _words(own_gridded_perm) + ranks = { + cell: values for cell, values in reduced_ranks.items() if cell not in cell_set + } + ranks.update(own_ranks) + return _from_words( + _combined_words(reduced_columns, own_columns, cell_set, layout.outside_columns), + _combined_words(reduced_rows, own_rows, cell_set, layout.outside_rows), + ranks, + ) diff --git a/tilings/strategies/__init__.py b/tilings/strategies/__init__.py index 15011b20..6224a316 100644 --- a/tilings/strategies/__init__.py +++ b/tilings/strategies/__init__.py @@ -25,6 +25,7 @@ RequirementPointingFactory, ) from .rearrange_assumption import RearrangeAssumptionFactory +from .reduction import ReductionFactory from .relax_assumption import RelaxAssumptionFactory from .requirement_insertion import ( BasisPatternInsertionFactory, @@ -115,6 +116,7 @@ "SubobstructionInferralFactory", # Reduction "CellReductionFactory", + "ReductionFactory", # Symmetry "SymmetriesFactory", # Verification diff --git a/tilings/strategies/cell_reduction.py b/tilings/strategies/cell_reduction.py index 07fce85e..610ec344 100644 --- a/tilings/strategies/cell_reduction.py +++ b/tilings/strategies/cell_reduction.py @@ -1,66 +1,22 @@ -"""The cell reduction strategy.""" +"""The cell reduction strategy, the reduction of a single cell. -from typing import Callable, Dict, Iterator, List, Optional, Set, Tuple, cast +`ReductionStrategy` and `ReductionFactory` (`tilings.strategies.reduction`) reduce +any set of cells. These are the reductions of single cells, kept with their names +and encodings for the packs and specifications that use them. +""" -import sympy +from typing import Tuple, cast -from comb_spec_searcher import Constructor, Strategy, StrategyFactory -from comb_spec_searcher.exception import StrategyDoesNotApply -from comb_spec_searcher.typing import ( - Parameters, - RelianceProfile, - SubObjects, - SubRecs, - SubSamplers, - SubTerms, - Terms, -) -from permuta import Perm -from tilings import GriddedPerm, Tiling -from tilings.assumptions import ComponentAssumption, TrackingAssumption +from .reduction import ReductionConstructor, ReductionFactory, ReductionStrategy -Cell = Tuple[int, int] - - -class CellReductionConstructor(Constructor): - def __init__(self, parameter: str): - self.parameter = parameter - - def get_equation( - self, lhs_func: sympy.Function, rhs_funcs: Tuple[sympy.Function, ...] - ) -> sympy.Eq: - raise NotImplementedError - - def reliance_profile(self, n: int, **parameters: int) -> RelianceProfile: - raise NotImplementedError - - def get_terms( - self, parent_terms: Callable[[int], Terms], subterms: SubTerms, n: int - ) -> Terms: - raise NotImplementedError +__all__ = ("CellReductionConstructor", "CellReductionStrategy", "CellReductionFactory") - def get_sub_objects( - self, subobjs: SubObjects, n: int - ) -> Iterator[Tuple[Parameters, Tuple[List[Optional[GriddedPerm]]]]]: - raise NotImplementedError - - def random_sample_sub_objects( - self, - parent_count: int, - subsamplers: SubSamplers, - subrecs: SubRecs, - n: int, - **parameters: int, - ) -> Tuple[GriddedPerm]: - raise NotImplementedError +Cell = Tuple[int, int] - def equiv( - self, other: "Constructor", data: Optional[object] = None - ) -> Tuple[bool, Optional[object]]: - raise NotImplementedError +CellReductionConstructor = ReductionConstructor -class CellReductionStrategy(Strategy[Tiling, GriddedPerm]): +class CellReductionStrategy(ReductionStrategy): """A strategy that replaces the cell with an increasing or decreasing cell.""" def __init__( @@ -69,240 +25,51 @@ def __init__( increasing: bool, tracked: bool = True, ): - self.cell = cell - self.tracked = tracked - self.increasing = increasing - super().__init__() - - def can_be_equivalent(self) -> bool: - return False - - def is_two_way(self, comb_class: Tiling) -> bool: - return False - - def is_reversible(self, comb_class: Tiling) -> bool: - return False - - def shifts( - self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None - ) -> Tuple[int, ...]: - return (0, 0) - - def decomposition_function(self, comb_class: Tiling) -> Tuple[Tiling, Tiling]: - if self.increasing: - extra = Perm((1, 0)) - else: - extra = Perm((0, 1)) - reduced_obs = sorted( - [ - ob - for ob in comb_class.obstructions - if not ob.pos[0] == self.cell or not ob.is_single_cell() - ] - + [GriddedPerm.single_cell(extra, self.cell)] - ) - reduced_reqs = sorted( - req - for req in comb_class.requirements - if not all(gp.pos[0] == self.cell and gp.is_single_cell() for gp in req) - ) - reduced_ass = sorted( - ass - for ass in comb_class.assumptions - if not isinstance(ass, ComponentAssumption) - or GriddedPerm.point_perm(self.cell) not in ass.gps - ) - reduced_tiling = Tiling( - reduced_obs, - reduced_reqs, - reduced_ass, - remove_empty_rows_and_cols=False, - derive_empty=False, - simplify=False, - sorted_input=True, - ) - local_basis = comb_class.sub_tiling([self.cell]) - if self.tracked: - return ( - reduced_tiling.add_assumption( - TrackingAssumption.from_cells([self.cell]) - ), - local_basis, - ) - return reduced_tiling, local_basis - - def constructor( - self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None - ) -> CellReductionConstructor: - if not self.tracked: - raise NotImplementedError("The reduction strategy was not tracked") - if children is None: - children = self.decomposition_function(comb_class) - if children is None: - raise StrategyDoesNotApply("Can't reduce the cell") - ass = TrackingAssumption.from_cells([self.cell]) - child_param = children[0].get_assumption_parameter(ass) - gp = GriddedPerm.point_perm(self.cell) - if any(gp in assumption.gps for assumption in comb_class.assumptions): - raise NotImplementedError - return CellReductionConstructor(child_param) - - def reverse_constructor( - self, - idx: int, - comb_class: Tiling, - children: Optional[Tuple[Tiling, ...]] = None, - ) -> Constructor: - if children is None: - children = self.decomposition_function(comb_class) - if children is None: - raise StrategyDoesNotApply("Can't reduce the cell") - raise NotImplementedError - - def extra_parameters( - self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None - ) -> Tuple[Dict[str, str]]: - if children is None: - children = self.decomposition_function(comb_class) - if children is None: - raise StrategyDoesNotApply("Strategy does not apply") - raise NotImplementedError - - def formal_step(self) -> str: - return f"reducing cell {self.cell}" - - def backward_map( - self, - comb_class: Tiling, - objs: Tuple[Optional[GriddedPerm], ...], - children: Optional[Tuple[Tiling, ...]] = None, - ) -> Iterator[GriddedPerm]: - if children is None: - children = self.decomposition_function(comb_class) - raise NotImplementedError - - def forward_map( - self, - comb_class: Tiling, - obj: GriddedPerm, - children: Optional[Tuple[Tiling, ...]] = None, - ) -> Tuple[Optional[GriddedPerm], ...]: - if children is None: - children = self.decomposition_function(comb_class) - raise NotImplementedError + self.cell = cast(Cell, tuple(cell)) + super().__init__((self.cell,), increasing, tracked) def to_jsonable(self) -> dict: d: dict = super().to_jsonable() - d.pop("ignore_parent") - d.pop("inferrable") - d.pop("possibly_empty") - d.pop("workable") + d.pop("cells") d["cell"] = self.cell - d["tracked"] = self.tracked - d["increasing"] = self.increasing return d - def __repr__(self) -> str: - args = ", ".join( - [ - f"cell={self.cell!r}", - f"increasing={self.increasing!r}", - f"tracked={self.tracked!r}", - ] - ) - return f"{self.__class__.__name__}({args})" - @classmethod def from_dict(cls, d: dict) -> "CellReductionStrategy": - cell = cast(Tuple[int, int], tuple(d.pop("cell"))) + cell = cast(Cell, tuple(d.pop("cell"))) tracked = d.pop("tracked") increasing = d.pop("increasing") assert not d return cls(cell, increasing, tracked) - @staticmethod - def get_eq_symbol() -> str: - return "↣" - - -class CellReductionFactory(StrategyFactory[Tiling]): - def __init__(self, tracked: bool): - self.tracked = tracked - super().__init__() + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(cell={self.cell!r}, " + f"increasing={self.increasing!r}, tracked={self.tracked!r})" + ) - def __call__(self, comb_class: Tiling) -> Iterator[CellReductionStrategy]: - if comb_class.dimensions == (1, 1): - return - cell_bases = comb_class.cell_basis() - for cell in self.reducible_cells(comb_class): - if not ( # a finite cell - any(patt.is_increasing() for patt in cell_bases[cell][0]) - and any(patt.is_decreasing() for patt in cell_bases[cell][0]) - ) and self.can_reduce_cell_with_requirements_and_assumptions( - comb_class, cell - ): - yield CellReductionStrategy(cell, True, self.tracked) - yield CellReductionStrategy(cell, False, self.tracked) - def can_reduce_cell_with_requirements_and_assumptions( - self, tiling: Tiling, cell: Cell - ) -> bool: - return all( # local - all(gp.pos[0] == cell and gp.is_single_cell() for gp in req) - or all( - # at most one point in cell - sum(1 for _ in gp.points_in_cell(cell)) < 2 - # no gp in row and col - and not self.gp_in_row_and_col(gp, cell) - for gp in req - ) - for req in tiling.requirements - ) and all( - not isinstance(ass, ComponentAssumption) - or GriddedPerm.point_perm(cell) not in ass.gps - or len(ass.gps) == 1 - for ass in tiling.assumptions - ) +class CellReductionFactory(ReductionFactory): + """The reductions of single cells: `ReductionFactory` with the region "cell".""" - @staticmethod - def gp_in_row_and_col(gp: GriddedPerm, cell: Cell) -> bool: - """Return True if there are points touching a cell in the row and col of - cell that isn't cell itself.""" - x, y = cell - return ( - len(set(gp.pos[idx][1] for idx, _ in gp.get_points_col(x))) > 1 - and len(set(gp.pos[idx][0] for idx, _ in gp.get_points_row(y))) > 1 - ) + def __init__(self, tracked: bool): + super().__init__(tracked, "cell") - def reducible_cells(self, tiling: Tiling) -> Set[Cell]: - """Return the set of non-monotone cells with at most one point in a crossing - obstrution touching them, and no obstruction touching a cell in the row and - a cell in the column.""" - cells = set(tiling.active_cells) - set(tiling.point_cells) - for gp in tiling.obstructions: - if not cells: - break - if not gp.is_localized(): - seen = set() - for cell in gp.pos: - if cell in seen or self.gp_in_row_and_col(gp, cell): - cells.discard(cell) - seen.add(cell) - elif len(gp) == 2: - cells.discard(gp.pos[0]) - return cells + def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: + (cell,) = cells + return CellReductionStrategy(cell, increasing, self.tracked) def __repr__(self) -> str: return self.__class__.__name__ + f"({self.tracked})" - def __str__(self) -> str: - return ("tracked " if self.tracked else "") + "cell reduction factory" - def to_jsonable(self) -> dict: d: dict = super().to_jsonable() - d["tracked"] = self.tracked + d.pop("region") + d.pop("max_cells") return d @classmethod def from_dict(cls, d: dict) -> "CellReductionFactory": - return cls(d["tracked"]) + tracked = d.pop("tracked") + assert not d + return cls(tracked) diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py new file mode 100644 index 00000000..1b77108b --- /dev/null +++ b/tilings/strategies/reduction.py @@ -0,0 +1,696 @@ +"""The reduction strategy, which makes a set of cells monotone, its constructor and +its factory. + +`tilings.algorithms.reduction` says when a set of cells is reducible and why the +reduction holds: the gridded permutations of the tiling are the pairs of one of +the reduced tiling and one of the own tiling with as many points in the cells of +the set, where the reduced tiling keeps a cell for each group of the set's cells +that the rest of the tiling sees as one, and none for the cells it does not see. +The reduced tiling tracks each group in its kept cell; the own tiling tracks each +group in its cells, unless there is one group and no cell left out, when its +size is the group's number of points. The count is the Hadamard product of the +children in the numbers of points of the groups, at 1, and the product in the +number of points of the cells left out: + + parent(n) = sum over w and m of reduced(n - m; w) * own(|w| + m; w), + +w the number of points in each group and m in the cells left out. A single cell +is the cell reduction (`tilings.strategies.cell_reduction`). +""" + +import random +from collections import Counter, defaultdict +from itertools import combinations +from typing import ( + Any, + Callable, + DefaultDict, + Dict, + Iterable, + Iterator, + List, + NamedTuple, + Optional, + Tuple, + cast, +) + +import sympy + +from comb_spec_searcher import Constructor, Strategy, StrategyFactory +from comb_spec_searcher.exception import StrategyDoesNotApply +from comb_spec_searcher.typing import ( + Objects, + Parameters, + RelianceProfile, + SubObjects, + SubRecs, + SubSamplers, + SubTerms, + Terms, +) +from tilings import GriddedPerm, Tiling +from tilings.algorithms.reduction import ( + Cell, + ReductionPlan, + combine_gridded_perms, + is_reducible, + is_rigid, + own_is_tracked, + reduce_gridded_perm, + reduction_children, + reduction_plan, +) +from tilings.assumptions import TrackingAssumption + +__all__ = ("ReductionConstructor", "ReductionStrategy", "ReductionFactory") + +REGIONS = ("cell", "row", "column", "line", "cut", "any") + + +class SampleChoice(NamedTuple): + """The sizes and the parameters of the reduced tiling and of the own tiling for + one choice of the numbers of points in the groups and the cells left out.""" + + reduced_size: int + reduced_parameters: Dict[str, int] + own_size: int + own_parameters: Dict[str, int] + + +def _bounded_compositions(bound: int, parts: int) -> Iterator[Tuple[int, ...]]: + """The tuples of length parts of numbers at least 0 whose sum is at most + bound.""" + if parts == 0: + yield () + return + for first in range(bound + 1): + for rest in _bounded_compositions(bound - first, parts - 1): + yield (first,) + rest + + +class ReductionConstructor(Constructor[Tiling, GriddedPerm]): + """The constructor of a reduction: the Hadamard product of the reduced tiling + and the own tiling in the variables that count the points in the groups of + cells the rest of the tiling sees as one, at 1, and the product in the size of + the cells the reduced tiling leaves out. + + parent(n; p) = sum over w and m of reduced(n - m; p, w) * own(|w| + m; w) + + parent_to_reduced is the strategy's map of the parent's parameters to the + reduced tiling's (its extra parameters for the reduced tiling). + """ + + def __init__( + self, + parent: Tiling, + children: Tuple[Tiling, ...], + plan: ReductionPlan, + parent_to_reduced: Dict[str, str], + ): + reduced_tiling, own_tiling = children + groups, left_out = plan + self.leaves_out = bool(left_out) + self.parent_to_reduced = parent_to_reduced + self.reduced_group_parameters = tuple( + self._parameter_of( + reduced_tiling, TrackingAssumption.from_cells([group[0]]) + ) + for group in groups + ) + self.reduced_group_indices = self._indices( + reduced_tiling, self.reduced_group_parameters + ) + self.parent_indices = self._indices( + reduced_tiling, + [self.parent_to_reduced[name] for name in parent.extra_parameters], + ) + self.own_group_parameters: Optional[Tuple[str, ...]] = None + self.own_group_indices: Tuple[int, ...] = () + if own_is_tracked(groups, left_out): + self.own_group_parameters = tuple( + self._parameter_of(own_tiling, TrackingAssumption.from_cells(group)) + for group in groups + ) + self.own_group_indices = self._indices( + own_tiling, self.own_group_parameters + ) + + @staticmethod + def _parameter_of(child: Tiling, assumption: TrackingAssumption) -> str: + """The child's parameter for an assumption in the parent's cells.""" + return child.get_assumption_parameter( + child.forward_map.map_assumption(assumption) + ) + + @staticmethod + def _indices(child: Tiling, parameters: Iterable[str]) -> Tuple[int, ...]: + """The places of the parameters in the child's extra parameters.""" + return tuple(child.extra_parameters.index(name) for name in parameters) + + def _left_out_sizes(self, parent_size: int) -> range: + """The numbers of points the cells left out may have.""" + return range(parent_size + 1) if self.leaves_out else range(1) + + def _group_sizes(self, own_parameters: Parameters, size: int) -> Tuple[int, ...]: + """The number of points in each group, from a parameter of the own tiling.""" + if self.own_group_parameters is None: + return (size,) + return tuple(own_parameters[index] for index in self.own_group_indices) + + def _own_parameters(self, group_sizes: Tuple[int, ...]) -> Dict[str, int]: + if self.own_group_parameters is None: + return {} + return dict(zip(self.own_group_parameters, group_sizes)) + + def get_equation( + self, lhs_func: sympy.Function, rhs_funcs: Tuple[sympy.Function, ...] + ) -> sympy.Eq: + raise NotImplementedError( + "A Hadamard product has no equation in the generating functions" + ) + + def reliance_profile(self, n: int, **parameters: int) -> RelianceProfile: + raise NotImplementedError + + def _split(self, reduced_parameters: Parameters) -> Tuple[Parameters, Parameters]: + """The number of points in each group, and the parent's parameters, from a + parameter of the reduced tiling.""" + return ( + tuple(reduced_parameters[index] for index in self.reduced_group_indices), + tuple(reduced_parameters[index] for index in self.parent_indices), + ) + + def _pairs( + self, + parent_size: int, + reduced_by_parameters: Callable[[int], Dict[Parameters, Any]], + own_by_group_sizes: Callable[[int], Dict[Tuple[int, ...], Any]], + ) -> Iterator[Tuple[Parameters, Any, Any]]: + """For each number of points in the cells left out, and each parameter of + the reduced tiling at the size that leaves, the parent's parameters, the + reduced tiling's value and the own tiling's value with as many points in + each group (counts, or lists of objects).""" + own_by_size: Dict[int, Dict[Tuple[int, ...], Any]] = {} + for left_out_size in self._left_out_sizes(parent_size): + reduced_values = reduced_by_parameters(parent_size - left_out_size) + for reduced_parameters, reduced_value in reduced_values.items(): + group_sizes, parent_parameters = self._split(reduced_parameters) + own_size = sum(group_sizes) + left_out_size + if own_size not in own_by_size: + own_by_size[own_size] = own_by_group_sizes(own_size) + own_value = own_by_size[own_size].get(group_sizes) + if reduced_value and own_value: + yield parent_parameters, reduced_value, own_value + + def _own_counts(self, own_terms: Callable[[int], Terms], size: int) -> Counter: + """The number of objects of the own tiling of the size with each number of + points in the groups.""" + own_counts: Counter = Counter() + for own_parameters, own_count in own_terms(size).items(): + own_counts[self._group_sizes(own_parameters, size)] += own_count + return own_counts + + def get_terms( + self, parent_terms: Callable[[int], Terms], subterms: SubTerms, n: int + ) -> Terms: + reduced_terms, own_terms = subterms + terms: Terms = Counter() + for parent_parameters, reduced_count, own_count in self._pairs( + n, reduced_terms, lambda size: self._own_counts(own_terms, size) + ): + terms[parent_parameters] += reduced_count * own_count + return terms + + def _own_objects( + self, own_objects: Callable[[int], Objects], size: int + ) -> DefaultDict[Parameters, List[Optional[GriddedPerm]]]: + """The objects of the own tiling of the size, by the number of points in + each group.""" + own_lists: DefaultDict[Parameters, List[Optional[GriddedPerm]]] = defaultdict( + list + ) + for own_parameters, own_list in own_objects(size).items(): + own_lists[self._group_sizes(own_parameters, size)].extend(own_list) + return own_lists + + def get_sub_objects( + self, subobjs: SubObjects, n: int + ) -> Iterator[Tuple[Parameters, Tuple[List[Optional[GriddedPerm]], ...]]]: + reduced_objects, own_objects = subobjs + for parent_parameters, reduced_list, own_list in self._pairs( + n, reduced_objects, lambda size: self._own_objects(own_objects, size) + ): + yield parent_parameters, (reduced_list, own_list) + + def _sample_choices( + self, parent_size: int, parent_parameters: Dict[str, int] + ) -> Iterator[SampleChoice]: + """For each number of points in the cells left out and in the groups that + the parent's parameters allow, the sizes and the parameters of the two + children.""" + fixed_parameters = { + self.parent_to_reduced[name]: value + for name, value in parent_parameters.items() + } + fixed_sizes = [ + fixed_parameters.get(name) for name in self.reduced_group_parameters + ] + free = [index for index, size in enumerate(fixed_sizes) if size is None] + fixed_total = sum(size for size in fixed_sizes if size is not None) + for left_out_size in self._left_out_sizes(parent_size): + reduced_size = parent_size - left_out_size + if fixed_total > reduced_size: + continue + for free_sizes in _bounded_compositions( + reduced_size - fixed_total, len(free) + ): + group_sizes = list(fixed_sizes) + for index, size in zip(free, free_sizes): + group_sizes[index] = size + reduced_parameters = dict(fixed_parameters) + reduced_parameters.update( + zip(self.reduced_group_parameters, cast(List[int], group_sizes)) + ) + yield SampleChoice( + reduced_size, + reduced_parameters, + sum(cast(List[int], group_sizes)) + left_out_size, + self._own_parameters(tuple(cast(List[int], group_sizes))), + ) + + def random_sample_sub_objects( + self, + parent_count: int, + subsamplers: SubSamplers, + subrecs: SubRecs, + n: int, + **parameters: int, + ) -> Tuple[Optional[GriddedPerm], ...]: + random_choice = random.randint(1, parent_count) + total = 0 + for choice in self._sample_choices(n, parameters): + total += self._choice_count(subrecs, choice) + if random_choice <= total: + return ( + subsamplers[0](n=choice.reduced_size, **choice.reduced_parameters), + subsamplers[1](n=choice.own_size, **choice.own_parameters), + ) + raise RuntimeError("Function did not return") + + @staticmethod + def _choice_count(subrecs: SubRecs, choice: SampleChoice) -> int: + """The number of pairs of objects of the two children for the choice.""" + reduced_count = subrecs[0](n=choice.reduced_size, **choice.reduced_parameters) + if not reduced_count: + return 0 + return reduced_count * subrecs[1](n=choice.own_size, **choice.own_parameters) + + def equiv( + self, other: "Constructor", data: Optional[object] = None + ) -> Tuple[bool, Optional[object]]: + """Two reductions' constructors are taken as equivalent only when they pair + their children the same way: cells left out or not, the own tiling + tracked or not, and the groups' and the parent's parameters at the same + places of the children's parameters, so as many groups. This errs on the + side of not equivalent.""" + # Whether the own tiling is tracked, and the places of its groups' + # parameters, follow from the number of groups and leaves_out; they are + # compared all the same, as what _group_sizes reads. + return ( + isinstance(other, ReductionConstructor) + and self.leaves_out == other.leaves_out + and (self.own_group_parameters is None) + == (other.own_group_parameters is None) + and self.reduced_group_indices == other.reduced_group_indices + and self.parent_indices == other.parent_indices + and self.own_group_indices == other.own_group_indices, + None, + ) + + @staticmethod + def get_eq_symbol() -> str: + """The strategy's symbol between the parent and its children, ↣.""" + return ReductionStrategy.get_eq_symbol() + + @staticmethod + def get_op_symbol() -> str: + """The strategy's symbol between the two children, ⊙.""" + return ReductionStrategy.get_op_symbol() + + def __str__(self) -> str: + return "Hadamard product in the reduced cells" + + +class ReductionStrategy(Strategy[Tiling, GriddedPerm]): + """A strategy that makes a set of cells increasing (or decreasing), the points + of its cells in each row and each column in that order, and counts the set's + own tiling beside it. The reduced tiling keeps only the cells of the set that + the rest of the tiling sees, one for each group it sees as one.""" + + def __init__( + self, + cells: Iterable[Cell], + increasing: bool, + tracked: bool = True, + ): + self.cells: Tuple[Cell, ...] = tuple( + sorted(set(cast(Cell, tuple(cell)) for cell in cells)) + ) + self.increasing = increasing + self.tracked = tracked + super().__init__() + + def can_be_equivalent(self) -> bool: + return False + + def is_two_way(self, comb_class: Tiling) -> bool: + return False + + def is_reversible(self, comb_class: Tiling) -> bool: + return False + + def shifts( + self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None + ) -> Tuple[int, ...]: + return (0, 0) + + def plan(self, comb_class: Tiling) -> ReductionPlan: + """The groups of the set the reduced tiling keeps a cell for, and the cells + it leaves out.""" + return reduction_plan(comb_class, self.cells) + + def decomposition_function(self, comb_class: Tiling) -> Tuple[Tiling, Tiling]: + if not is_reducible(comb_class, self.cells, self.increasing): + direction = "increasing" if self.increasing else "decreasing" + raise StrategyDoesNotApply( + f"The cells {self.cells} are not reducible to {direction}" + ) + return reduction_children(comb_class, self.cells, self.increasing, self.tracked) + + def constructor( + self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None + ) -> ReductionConstructor: + if not self.tracked: + raise NotImplementedError("The reduction strategy was not tracked") + if children is None: + children = self.decomposition_function(comb_class) + return ReductionConstructor( + comb_class, + children, + self.plan(comb_class), + self.extra_parameters(comb_class, children)[0], + ) + + def reverse_constructor( + self, + idx: int, + comb_class: Tiling, + children: Optional[Tuple[Tiling, ...]] = None, + ) -> Constructor: + raise NotImplementedError + + def extra_parameters( + self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None + ) -> Tuple[Dict[str, str], ...]: + if children is None: + children = self.decomposition_function(comb_class) + reduced_tiling = children[0] + return ( + { + parent_parameter: reduced_tiling.get_assumption_parameter( + reduced_tiling.forward_map.map_assumption(assumption) + ) + for parent_parameter, assumption in zip( + comb_class.extra_parameters, comb_class.assumptions + ) + }, + {}, + ) + + def formal_step(self) -> str: + cells = ", ".join(str(cell) for cell in self.cells) + direction = "increasing" if self.increasing else "decreasing" + noun = "cell" if len(self.cells) == 1 else "cells" + return f"reducing {noun} {cells} to {direction}" + + def reduce_gridded_perm( + self, comb_class: Tiling, gridded_perm: GriddedPerm + ) -> GriddedPerm: + """The gridded permutation of the reduced tiling for one of the tiling, in + the tiling's cells (see tilings.algorithms.reduction).""" + return reduce_gridded_perm( + comb_class, self.cells, gridded_perm, self.increasing + ) + + def combine_gridded_perms( + self, + comb_class: Tiling, + reduced_gridded_perm: GriddedPerm, + own_gridded_perm: GriddedPerm, + ) -> GriddedPerm: + """The gridded permutation of the tiling for one of the reduced tiling and + one of the own tiling, both in the tiling's cells (see + tilings.algorithms.reduction).""" + return combine_gridded_perms( + comb_class, self.cells, reduced_gridded_perm, own_gridded_perm + ) + + def backward_map( + self, + comb_class: Tiling, + objs: Tuple[Optional[GriddedPerm], ...], + children: Optional[Tuple[Tiling, ...]] = None, + ) -> Iterator[GriddedPerm]: + if children is None: + children = self.decomposition_function(comb_class) + reduced_gridded_perm, own_gridded_perm = objs + assert reduced_gridded_perm is not None and own_gridded_perm is not None + yield self.combine_gridded_perms( + comb_class, + children[0].backward_map.map_gp(reduced_gridded_perm), + children[1].backward_map.map_gp(own_gridded_perm), + ) + + def forward_map( + self, + comb_class: Tiling, + obj: GriddedPerm, + children: Optional[Tuple[Tiling, ...]] = None, + ) -> Tuple[Optional[GriddedPerm], ...]: + if children is None: + children = self.decomposition_function(comb_class) + return ( + children[0].forward_map.map_gp(self.reduce_gridded_perm(comb_class, obj)), + children[1].forward_map.map_gp(obj.get_gridded_perm_in_cells(self.cells)), + ) + + def to_jsonable(self) -> dict: + d: dict = super().to_jsonable() + d.pop("ignore_parent") + d.pop("inferrable") + d.pop("possibly_empty") + d.pop("workable") + d["cells"] = self.cells + d["increasing"] = self.increasing + d["tracked"] = self.tracked + return d + + @classmethod + def from_dict(cls, d: dict) -> "ReductionStrategy": + cells = [cast(Cell, tuple(cell)) for cell in d.pop("cells")] + increasing = d.pop("increasing") + tracked = d.pop("tracked") + assert not d + return cls(cells, increasing, tracked) + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(cells={self.cells!r}, " + f"increasing={self.increasing!r}, tracked={self.tracked!r})" + ) + + @staticmethod + def get_eq_symbol() -> str: + return "↣" + + @staticmethod + def get_op_symbol() -> str: + return "⊙" + + +class ReductionFactory(StrategyFactory[Tiling]): + """The reductions of the sets of cells of a region, in each direction the set + is reducible in. + + The region says which sets are tried: + + - "cell": every single cell (the cell reduction); + - "row": the active cells of every row; + - "column": the active cells of every column; + - "line": every set of cells in one row or in one column, so single cells and + whole rows and columns among them; + - "cut": the active cells on one side of a line between two adjacent columns + or two adjacent rows: left of it, right of it, below it or above it, as the + cells left of a placed point, which a threshold reduction reduces; at most + twice the number of columns and rows; + - "any": every set of cells. + + With max_cells, the sets of "cut" and "any" have at most that many cells. The + region "any" tries every set of active cells, whose number grows exponentially + with the number of active cells; give it a max_cells, or use "cut" or "line". + + A set is reduced if it is reducible, it is not all of the active cells, it is + not rigid (every cell monotone, and the points of any two of its cells in a + row or a column in a fixed order: reducing it would change nothing, or reverse + it), it is not a single finite cell, and the rest of the tiling sees one of its + cells (if it sees none, the set is a factor). + + With tracked=False the strategies track nothing and have no constructor: for + an untracked search, whose specification a tracked search then counts. + """ + + def __init__( + self, + tracked: bool = True, + region: str = "cell", + max_cells: Optional[int] = None, + ): + if region not in REGIONS: + raise ValueError(f"The region must be one of {REGIONS}, not {region!r}") + self.tracked = tracked + self.region = region + self.max_cells = max_cells + super().__init__() + + def candidate_sets(self, tiling: Tiling) -> Iterator[Tuple[Cell, ...]]: + """The sets of active cells of the region, each once.""" + active_cells = sorted(tiling.active_cells) + seen = set() + for cells in self._region_sets(tiling, active_cells): + if cells and cells not in seen: + seen.add(cells) + yield cells + + def _region_sets( + self, tiling: Tiling, active_cells: List[Cell] + ) -> Iterator[Tuple[Cell, ...]]: + if self.region == "cell": + return ((cell,) for cell in active_cells) + if self.region in ("row", "column"): + return (tuple(line) for line in self._lines(tiling)) + if self.region == "line": + return ( + subset + for line in self._lines(tiling) + for size in range(1, len(line) + 1) + for subset in combinations(line, size) + ) + if self.region == "cut": + return ( + side + for side in self.cuts(tiling, active_cells) + if self.max_cells is None or len(side) <= self.max_cells + ) + largest = len(active_cells) + if self.max_cells is not None: + largest = min(largest, self.max_cells) + return ( + subset + for size in range(1, largest + 1) + for subset in combinations(active_cells, size) + ) + + def _lines(self, tiling: Tiling) -> List[List[Cell]]: + """The active cells of each row (for "row" and "line") and of each column + (for "column" and "line").""" + width, height = tiling.dimensions + lines: List[List[Cell]] = [] + if self.region in ("row", "line"): + lines.extend(sorted(tiling.cells_in_row(row)) for row in range(height)) + if self.region in ("column", "line"): + lines.extend(sorted(tiling.cells_in_col(column)) for column in range(width)) + return lines + + @staticmethod + def cuts(tiling: Tiling, active_cells: List[Cell]) -> Iterator[Tuple[Cell, ...]]: + """For each line between two adjacent columns, the active cells left of it + and those right of it; likewise below and above each line between two + adjacent rows.""" + width, height = tiling.dimensions + for column in range(1, width): + yield tuple(cell for cell in active_cells if cell[0] < column) + yield tuple(cell for cell in active_cells if cell[0] >= column) + for row in range(1, height): + yield tuple(cell for cell in active_cells if cell[1] < row) + yield tuple(cell for cell in active_cells if cell[1] >= row) + + @staticmethod + def is_finite_cell(tiling: Tiling, cell: Cell) -> bool: + """Return True if the cell's basis has an increasing and a decreasing + pattern, so that the cell has finitely many permutations: a single such + cell is not reduced.""" + cell_basis = tiling.cell_basis()[cell][0] + return any(pattern.is_increasing() for pattern in cell_basis) and any( + pattern.is_decreasing() for pattern in cell_basis + ) + + def __call__(self, comb_class: Tiling) -> Iterator[ReductionStrategy]: + active_cells = comb_class.active_cells + for cells in self.candidate_sets(comb_class): + if len(cells) == len(active_cells): + continue + if len(cells) == 1 and self.is_finite_cell(comb_class, cells[0]): + continue + directions = [ + increasing + for increasing in (True, False) + if is_reducible(comb_class, cells, increasing) + ] + if not directions or is_rigid(comb_class, cells): + continue + if not reduction_plan(comb_class, cells).groups: + continue + for increasing in directions: + yield self.strategy(cells, increasing) + + def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: + """The strategy the factory yields for the set and the direction, which + CellReductionFactory makes a CellReductionStrategy.""" + return ReductionStrategy(cells, increasing, self.tracked) + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(tracked={self.tracked!r}, " + f"region={self.region!r}, max_cells={self.max_cells!r})" + ) + + def __str__(self) -> str: + tracked = "tracked " if self.tracked else "" + if self.region == "cut": + bound = ( + "" if self.max_cells is None else f" of at most {self.max_cells} cells" + ) + return ( + f"{tracked}reduction factory of the cells on one side of a line{bound}" + ) + if self.region != "any": + return f"{tracked}{self.region} reduction factory" + if self.max_cells is None: + return f"{tracked}reduction factory of any set of cells" + return f"{tracked}reduction factory of sets of at most {self.max_cells} cells" + + def to_jsonable(self) -> dict: + d: dict = super().to_jsonable() + d["tracked"] = self.tracked + d["region"] = self.region + d["max_cells"] = self.max_cells + return d + + @classmethod + def from_dict(cls, d: dict) -> "ReductionFactory": + tracked = d.pop("tracked") + region = d.pop("region") + max_cells = d.pop("max_cells") + assert not d + return cls(tracked, region, max_cells) diff --git a/tilings/strategy_pack.py b/tilings/strategy_pack.py index 7208a0f9..f1a58812 100644 --- a/tilings/strategy_pack.py +++ b/tilings/strategy_pack.py @@ -358,7 +358,7 @@ def kitchen_sinkify( # pylint: disable=R0912 strat.DeflationFactory(tracked), strat.AssumptionAndPointJumpingFactory(), strat.MonotoneSlidingFactory(), - strat.CellReductionFactory(tracked), + strat.ReductionFactory(tracked), strat.RequirementCorroborationFactory(), strat.RelaxAssumptionFactory(), ]