From f5e7b03dda1f53da81e68e38901e89bfda23814f Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 22 Oct 2026 10:41:54 +0000 Subject: [PATCH 01/21] Reduce any set of cells: the cell reduction generalized, with counting ReductionStrategy reduces a set of cells whose crossing obstructions each have one point in it, seen only along its row or its column: the cells become increasing (or decreasing), their points in each row and column in that order, and the tiling of the set's own obstructions and requirements goes beside the reduced tiling. ReductionConstructor counts the rule as the Hadamard product of the two children in the variables counting the points in the cells, at 1, and generates and samples objects through the sorting. ReductionFactory tries the sets of a region: single cells (the default, the cell reduction), rows, columns, every set in one row or column, or any set, of at most max_cells cells. CellReductionFactory and CellReductionStrategy are the single-cell case, with their names and encodings; the kitchen sink pack uses ReductionFactory. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 + tests/strategies/test_encoding.py | 13 + tests/strategies/test_reduction.py | 353 +++++++++++ tilings/strategies/__init__.py | 2 + tilings/strategies/cell_reduction.py | 294 +--------- tilings/strategies/reduction.py | 844 +++++++++++++++++++++++++++ tilings/strategy_pack.py | 2 +- 7 files changed, 1259 insertions(+), 265 deletions(-) create mode 100644 tests/strategies/test_reduction.py create mode 100644 tilings/strategies/reduction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c317fe0..b0cc4888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ 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 the tiling of their own + obstructions and requirements, with the points in each of them counted, goes + beside the reduced tiling. The count is the Hadamard product of the two in the + variables counting the points in the cells, at 1, and the rules generate and + sample their objects. The factory's `region` says which sets it tries: `"cell"` + (single cells, the cell reduction), `"row"`, `"column"`, `"line"` (every set of + cells in one row or one column) and `"any"` (every set, of at most `max_cells` + cells if given). + +### Changed +- `CellReductionFactory` and `CellReductionStrategy` are now the reduction of + single cells, so they count, generate and sample objects, and the kitchen sink + pack uses `ReductionFactory` with the region `"cell"`. A cell with no + obstruction of its own, or in a component assumption, is no longer reduced. ## [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..863da868 --- /dev/null +++ b/tests/strategies/test_reduction.py @@ -0,0 +1,353 @@ +import json +from collections import defaultdict + +import pytest + +from comb_spec_searcher import CombinatorialSpecification, Strategy +from permuta import Av, Perm +from tilings import GriddedPerm, Tiling +from tilings import strategies as strat +from tilings.assumptions import TrackingAssumption +from tilings.strategies.cell_reduction import ( + CellReductionFactory, + CellReductionStrategy, +) +from tilings.strategies.reduction import ( + ReductionFactory, + ReductionStrategy, + compression, + is_reducible, + is_rigid, + reduction_children, +) +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)))], + [], + ) + + +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_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)]) + + +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") + + +def test_children(two_rows): + cells = [(1, 0), (2, 0), (3, 1)] + reduced_tiling, own_tiling = reduction_children(two_rows, cells, False) + # the bottom row is one decreasing sequence, the cells tracked + assert GriddedPerm(Perm((0, 1)), ((1, 0), (2, 0))) in reduced_tiling.obstructions + for cell in cells: + assert GriddedPerm(Perm((0, 1)), (cell, cell)) in reduced_tiling.obstructions + assert TrackingAssumption.from_cells([cell]) in reduced_tiling.assumptions + assert reduced_tiling.dimensions == two_rows.dimensions + # the own tiling keeps the 123s with the decreasing cell, in three columns and + # two rows + assert own_tiling.dimensions == (3, 2) + assert GriddedPerm(Perm((0, 1, 2)), ((0, 0), (0, 0), (2, 1))) in ( + own_tiling.obstructions + ) + assert len(own_tiling.assumptions) == 3 + + +@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)]), + ], +) +@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", + [ + ("two_rows", [(1, 0), (2, 0), (3, 1)]), + ("apart", [(1, 0), (2, 2)]), + ("row_and_column", [(0, 0), (1, 0), (2, 0)]), + ], +) +@pytest.mark.parametrize("increasing", [True, False]) +def test_maps(request, fixture, cells, increasing): + """The forward map is a bijection onto the pairs with as many points in each + cell, and the backward map is its inverse.""" + tiling = request.getfixturevalue(fixture) + strategy = ReductionStrategy(cells, increasing) + reduced_tiling, own_tiling = strategy.decomposition_function(tiling) + _, own_to_parent = compression(strategy.cells) + + def reduced_cell_sizes(reduced_gp): + parent_gp = reduced_tiling.backward_map.map_gp(reduced_gp) + return tuple( + sum(1 for other in parent_gp.pos if other == cell) + for cell in strategy.cells + ) + + def own_cell_sizes(own_gp): + own_cells = [own_to_parent[cell] for cell in own_gp.pos] + return tuple( + sum(1 for other in own_cells if other == cell) for cell in strategy.cells + ) + + own_by_sizes = defaultdict(list) + for n in range(6): + for own_gp in own_tiling.objects_of_size(n): + own_by_sizes[own_cell_sizes(own_gp)].append(own_gp) + for n in range(6): + images = set() + for gp in tiling.objects_of_size(n): + reduced_gp, own_gp = strategy.forward_map(tiling, gp) + assert reduced_gp in reduced_tiling and own_gp in own_tiling + assert next(strategy.backward_map(tiling, (reduced_gp, own_gp))) == gp + images.add((reduced_gp, own_gp)) + pairs = { + (reduced_gp, own_gp) + for reduced_gp in reduced_tiling.objects_of_size(n) + for own_gp in own_by_sizes[reduced_cell_sizes(reduced_gp)] + } + assert images == pairs + + +def test_parent_assumptions(apart): + """The parent's tracked cells keep their counts.""" + rule = ReductionStrategy([(1, 0), (2, 2)], True)(apart) + for n in range(6): + assert rule.sanity_check(n) + assert len(rule.children[0].extra_parameters) == 3 + + +def test_not_tracked(two_rows): + 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) + + +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(s, CellReductionStrategy) for s in cell_strategies) + assert sorted((s.cells, s.increasing) for s in cell_strategies) == sorted( + (s.cells, s.increasing) for s 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_encoding(): + for strategy in ( + ReductionStrategy([(0, 0), (1, 0)], True, True), + CellReductionStrategy((2, 1), False, False), + ReductionFactory(True, "line"), + 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(1234, 1342, 2413) that reduces a set of three cells.""" + 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) + ) + spec = TileScope("1234_1342_2413", pack).auto_search() + assert isinstance(spec, CombinatorialSpecification) + assert any( + isinstance(rule.strategy, ReductionStrategy) and len(rule.strategy.cells) > 1 + for rule in spec.rules_dict.values() + ) + basis = [Perm((0, 1, 2, 3)), Perm((0, 2, 3, 1)), 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/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..fe38b46a 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 Iterator, 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,238 +25,48 @@ 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 strategies(self, cells: Tuple[Cell, ...]) -> Iterator[ReductionStrategy]: + (cell,) = cells + yield CellReductionStrategy(cell, True, self.tracked) + yield CellReductionStrategy(cell, False, 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 diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py new file mode 100644 index 00000000..05a3c43a --- /dev/null +++ b/tilings/strategies/reduction.py @@ -0,0 +1,844 @@ +"""The reduction strategy, which makes a set of cells monotone. + +A set of active cells of a tiling is reducible 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 of c, then c is the only cell + of the set in its column, and if it has a point in another cell of the row of + c, then c is the only cell of the set in its row; + +and every requirement list lies in the set, or each of its gridded permutations +has no point in the set or one point in it that the obstructions may have. + +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. 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 +sorting the share of the set into a canonical one keeps both. So sorting is a +bijection from the gridded permutations of the tiling to the pairs of a gridded +permutation of the reduced tiling, in which the share of the set is canonical, +and one of the tiling of the set's own obstructions and requirements, with the +same number of points in each cell of the set. In the canonical share 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. + +The count is the Hadamard product of the two children in the variables that +count the points in the cells of the set, at 1: + + parent(n) = sum over v of reduced(n; v) * own(v), + +the sum over the number of points v in each cell of the set. A single cell is the +cell reduction (`tilings.strategies.cell_reduction`). +""" + +import random +from collections import Counter, defaultdict +from itertools import combinations, product +from typing import ( + Callable, + DefaultDict, + Dict, + FrozenSet, + Iterable, + Iterator, + List, + 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 permuta import Perm +from tilings import GriddedPerm, Tiling +from tilings.assumptions import ComponentAssumption, TrackingAssumption + +__all__ = ("ReductionConstructor", "ReductionStrategy", "ReductionFactory") + +Cell = Tuple[int, int] +REGIONS = ("cell", "row", "column", "line", "any") + + +def _crossing_is_allowed(gp: GriddedPerm, cells: FrozenSet[Cell]) -> 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 that point's cell is the only one of the + cells in its column (row).""" + cells_inside = [cell for cell in gp.pos if cell in cells] + if len(cells_inside) != 1: + return False + column, row = cells_inside[0] + cells_outside = [cell for cell in gp.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 any(cell[0] == column and cell[1] != row for cell in cells): + return False + if touches_row and any(cell[1] == row and cell[0] != column for cell in cells): + return False + return True + + +def is_reducible(tiling: Tiling, cells: Iterable[Cell]) -> bool: + """Return True if the set of cells is reducible on the tiling.""" + cell_set = frozenset(cells) + if not cell_set or not cell_set <= tiling.active_cells: + return False + if any( + isinstance(assumption, ComponentAssumption) + and any(gp.pos[0] in cell_set for gp in assumption.gps) + for assumption in tiling.assumptions + ): + return False + # the tiling of the set's own obstructions must have every cell of the set, so + # each cell needs an obstruction of its own (an unrestricted cell has none) + 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 + } + if not cell_set <= cells_with_own_obstructions: + return False + for obstruction in tiling.obstructions: + points_inside = sum(1 for cell in obstruction.pos if cell in cell_set) + if 0 < points_inside < len(obstruction) and not _crossing_is_allowed( + obstruction, cell_set + ): + return False + for requirement in tiling.requirements: + if all(cell in cell_set for gp in requirement for cell in gp.pos): + continue + for gp in requirement: + if any(cell in cell_set for cell in gp.pos) and not _crossing_is_allowed( + gp, cell_set + ): + return False + return True + + +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(gp: GriddedPerm) -> bool: + return gp.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 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)) + patt = 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(patt, (left_cell, right_cell))) + return obstructions + + +def compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cell]]: + """Return the maps from the cells of the set to the cells of its own tiling, + which keeps the columns and rows of the set only, and back.""" + cell_list = sorted(set(cells)) + column_index = { + column: index + for index, column in enumerate(sorted({cell[0] for cell in cell_list})) + } + row_index = { + row: index for index, row in enumerate(sorted({cell[1] for cell in cell_list})) + } + forward = {cell: (column_index[cell[0]], row_index[cell[1]]) for cell in cell_list} + backward = {own_cell: cell for cell, own_cell in forward.items()} + return forward, backward + + +def reduction_children( + tiling: Tiling, cells: Iterable[Cell], increasing: bool, tracked: bool = True +) -> Tuple[Tiling, Tiling]: + """Return the reduced tiling, with the cells tracked if tracked, and the + tiling of the cells' own obstructions and requirements, with every cell + tracked if there are two or more.""" + cell_set = frozenset(cells) + + def lies_in_cells(gps: Iterable[GriddedPerm]) -> bool: + return all(cell in cell_set for gp in gps for cell in gp.pos) + + cell_assumptions = [ + TrackingAssumption.from_cells([cell]) for cell in sorted(cell_set) + ] + reduced_tiling = Tiling( + [ob for ob in tiling.obstructions if not lies_in_cells([ob])] + + monotone_obstructions(cell_set, increasing), + [req for req in tiling.requirements if not lies_in_cells(req)], + list(tiling.assumptions) + (cell_assumptions if tracked else []), + ) + 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 + ] + own_tiling = Tiling( + [ob for ob in tiling.obstructions if lies_in_cells([ob])] + other_cells_emptied, + [req for req in tiling.requirements if lies_in_cells(req)], + cell_assumptions if len(cell_set) > 1 else [], + ) + return reduced_tiling, own_tiling + + +def _canonical_words( + points_in_cell: Counter, increasing: bool +) -> Tuple[Dict[int, List[Cell]], Dict[int, List[Cell]]]: + """The canonical words of the cells: for each column the cells of its points + from left to right, and for each row from bottom to top.""" + column_words: Dict[int, List[Cell]] = {} + for column in {cell[0] for cell in points_in_cell}: + cells_in_column = sorted( + (cell for cell in points_in_cell if cell[0] == column), + key=lambda cell: cell[1], + reverse=not increasing, + ) + column_words[column] = [ + cell for cell in cells_in_column for _ in range(points_in_cell[cell]) + ] + row_words: Dict[int, List[Cell]] = {} + for row in {cell[1] for cell in points_in_cell}: + cells_in_row = sorted( + (cell for cell in points_in_cell if cell[1] == row), + key=lambda cell: cell[0], + reverse=not increasing, + ) + row_words[row] = [ + cell for cell in cells_in_row for _ in range(points_in_cell[cell]) + ] + return column_words, row_words + + +def _pattern_words( + pattern: GriddedPerm, +) -> Tuple[Dict[int, List[Cell]], Dict[int, List[Cell]]]: + """The words of the pattern: for each column the cells of its points from + left to right, and for each row from bottom to top.""" + column_words: DefaultDict[int, List[Cell]] = defaultdict(list) + for cell in pattern.pos: + column_words[cell[0]].append(cell) + row_words: DefaultDict[int, List[Cell]] = defaultdict(list) + for index in sorted(range(len(pattern)), key=lambda index: pattern.patt[index]): + row_words[pattern.pos[index][1]].append(pattern.pos[index]) + return column_words, row_words + + +def _cell_ranks( + points_in_cell: Counter, increasing: bool, pattern: Optional[GriddedPerm] +) -> Dict[Cell, List[int]]: + """For each cell, the ranks of the values of its points, from left to right.""" + if pattern is None: + return { + cell: list(range(size)) if increasing else list(range(size - 1, -1, -1)) + for cell, size in points_in_cell.items() + } + ranks = {} + for cell in points_in_cell: + pattern_values = [ + value for value, other in zip(pattern.patt, pattern.pos) if other == cell + ] + sorted_values = sorted(pattern_values) + ranks[cell] = [sorted_values.index(value) for value in pattern_values] + return ranks + + +def _deal_slots( + gp: GriddedPerm, + indices_in_cells: List[int], + column_words: Dict[int, List[Cell]], + row_words: Dict[int, List[Cell]], +) -> Tuple[DefaultDict[Cell, List[int]], DefaultDict[Cell, List[int]]]: + """The positions and values that the points of each cell get: the positions + of the cells' points in each column dealt out in the order of its word, and + the values of their points in each row.""" + positions: DefaultDict[Cell, List[int]] = defaultdict(list) + for column, word in column_words.items(): + slots = [index for index in indices_in_cells if gp.pos[index][0] == column] + for slot, cell in zip(slots, word): + positions[cell].append(slot) + values: DefaultDict[Cell, List[int]] = defaultdict(list) + for row, word in row_words.items(): + slots = sorted( + gp.patt[index] for index in indices_in_cells if gp.pos[index][1] == row + ) + for slot, cell in zip(slots, word): + values[cell].append(slot) + return positions, values + + +def _assemble( + gp: GriddedPerm, + positions: DefaultDict[Cell, List[int]], + values: DefaultDict[Cell, List[int]], + ranks: Dict[Cell, List[int]], +) -> GriddedPerm: + """The gridded permutation with each cell's points at its positions, with its + values in the order of its ranks.""" + new_values, new_cells = list(gp.patt), list(gp.pos) + for cell, cell_ranks in ranks.items(): + cell_values = sorted(values[cell]) + for position, rank in zip(sorted(positions[cell]), cell_ranks): + new_values[position] = cell_values[rank] + new_cells[position] = cell + return GriddedPerm(Perm(new_values), tuple(new_cells)) + + +def resort( + gp: GriddedPerm, + cells: Iterable[Cell], + increasing: bool, + pattern: Optional[GriddedPerm] = None, +) -> GriddedPerm: + """Return the gridded permutation with the share of the cells made canonical, + or, if pattern is given, made that of pattern, a gridded permutation in the + cells with as many points in each of them. Every column keeps the positions + of the points of the cells in it, and every row their values.""" + cell_set = frozenset(cells) + indices_in_cells = [index for index, cell in enumerate(gp.pos) if cell in cell_set] + points_in_cell = Counter(gp.pos[index] for index in indices_in_cells) + if pattern is None: + column_words, row_words = _canonical_words(points_in_cell, increasing) + else: + if Counter(pattern.pos) != points_in_cell: + raise ValueError("the pattern has a different number of points in a cell") + column_words, row_words = _pattern_words(pattern) + positions, values = _deal_slots(gp, indices_in_cells, column_words, row_words) + return _assemble( + gp, positions, values, _cell_ranks(points_in_cell, increasing, pattern) + ) + + +class ReductionConstructor(Constructor[Tiling, GriddedPerm]): + """The constructor of a reduction: the Hadamard product of the reduced tiling + and the tiling of the cells' own obstructions in the variables that count the + points in the cells, at 1. + + parent(n; p) = sum over v of reduced(n; p, v) * own(|v|; v) + """ + + def __init__( + self, + parent: Tiling, + children: Tuple[Tiling, ...], + cells: Iterable[Cell], + ): + reduced_tiling, own_tiling = children + self.cells = tuple(sorted(set(cells))) + reduced_parameters = reduced_tiling.extra_parameters + + def reduced_parameter(assumption: TrackingAssumption) -> str: + return reduced_tiling.get_assumption_parameter( + reduced_tiling.forward_map.map_assumption(assumption) + ) + + self.reduced_cell_parameters = tuple( + reduced_parameter(TrackingAssumption.from_cells([cell])) + for cell in self.cells + ) + self.reduced_cell_indices = tuple( + reduced_parameters.index(name) for name in self.reduced_cell_parameters + ) + self.parent_to_reduced = { + parent_parameter: reduced_parameter(assumption) + for parent_parameter, assumption in zip( + parent.extra_parameters, parent.assumptions + ) + } + self.parent_indices = tuple( + reduced_parameters.index(self.parent_to_reduced[parent_parameter]) + for parent_parameter in parent.extra_parameters + ) + if len(self.cells) == 1: + self.own_cell_parameters: Optional[Tuple[str, ...]] = None + self.own_cell_indices: Tuple[int, ...] = () + else: + forward, _ = compression(self.cells) + self.own_cell_parameters = tuple( + own_tiling.get_assumption_parameter( + TrackingAssumption.from_cells([forward[cell]]) + ) + for cell in self.cells + ) + self.own_cell_indices = tuple( + own_tiling.extra_parameters.index(name) + for name in self.own_cell_parameters + ) + + def _cell_sizes(self, own_parameters: Parameters, size: int) -> Tuple[int, ...]: + """The number of points in each cell, from a parameter of the own tiling.""" + if self.own_cell_parameters is None: + return (size,) + return tuple(own_parameters[index] for index in self.own_cell_indices) + + def _own_parameters(self, cell_sizes: Tuple[int, ...]) -> Dict[str, int]: + if self.own_cell_parameters is None: + return {} + return dict(zip(self.own_cell_parameters, cell_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 cell, and the parent's parameters, from a + parameter of the reduced tiling.""" + return ( + tuple(reduced_parameters[index] for index in self.reduced_cell_indices), + tuple(reduced_parameters[index] for index in self.parent_indices), + ) + + def _own_counts(self, own_terms: Callable[[int], Terms], size: int) -> Counter: + """The number of objects of the own tiling with each number of points in + the cells, for objects of the size.""" + own_counts: Counter = Counter() + for own_parameters, own_count in own_terms(size).items(): + own_counts[self._cell_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 + own_counts_by_size: Dict[int, Counter] = {} + terms: Terms = Counter() + for reduced_parameters, reduced_count in reduced_terms(n).items(): + cell_sizes, parent_parameters = self._split(reduced_parameters) + size = sum(cell_sizes) + if size not in own_counts_by_size: + own_counts_by_size[size] = self._own_counts(own_terms, size) + own_count = own_counts_by_size[size][cell_sizes] + if own_count: + 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 cell.""" + own_lists: DefaultDict[Parameters, List[Optional[GriddedPerm]]] = defaultdict( + list + ) + for own_parameters, own_list in own_objects(size).items(): + own_lists[self._cell_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 + own_by_size: Dict[int, DefaultDict[Parameters, List[Optional[GriddedPerm]]]] + own_by_size = {} + for reduced_parameters, reduced_list in reduced_objects(n).items(): + cell_sizes, parent_parameters = self._split(reduced_parameters) + size = sum(cell_sizes) + if size not in own_by_size: + own_by_size[size] = self._own_objects(own_objects, size) + own_list = own_by_size[size].get(cell_sizes, []) + if reduced_list and own_list: + yield parent_parameters, (reduced_list, own_list) + + def _sample_choices( + self, n: int, parent_parameters: Dict[str, int] + ) -> Iterator[Tuple[Dict[str, int], int, Dict[str, int]]]: + """For each number of points in the cells that the parent's parameters + allow, the parameters of the reduced tiling, the size and the parameters + of the own tiling.""" + fixed_parameters = { + self.parent_to_reduced[name]: value + for name, value in parent_parameters.items() + } + for cell_sizes in product(range(n + 1), repeat=len(self.cells)): + if sum(cell_sizes) > n: + continue + reduced_parameters = dict(fixed_parameters) + if all( + reduced_parameters.setdefault(name, cell_size) == cell_size + for name, cell_size in zip(self.reduced_cell_parameters, cell_sizes) + ): + yield reduced_parameters, sum(cell_sizes), self._own_parameters( + cell_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, n, choice) + if random_choice <= total: + reduced_parameters, size, own_parameters = choice + return ( + subsamplers[0](n=n, **reduced_parameters), + subsamplers[1](n=size, **own_parameters), + ) + raise RuntimeError("Function did not return") + + @staticmethod + def _choice_count( + subrecs: SubRecs, n: int, choice: Tuple[Dict[str, int], int, Dict[str, int]] + ) -> int: + """The number of pairs of objects for the choice of the reduced tiling's + parameters, the size and the own tiling's parameters.""" + reduced_parameters, size, own_parameters = choice + reduced_count = subrecs[0](n=n, **reduced_parameters) + if not reduced_count: + return 0 + return reduced_count * subrecs[1](n=size, **own_parameters) + + def equiv( + self, other: "Constructor", data: Optional[object] = None + ) -> Tuple[bool, Optional[object]]: + return ( + isinstance(other, ReductionConstructor) + and len(self.cells) == len(other.cells) + and len(self.parent_indices) == len(other.parent_indices), + None, + ) + + @staticmethod + def get_eq_symbol() -> str: + return "↣" + + @staticmethod + def get_op_symbol() -> str: + return "⊙" + + 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 cells' + own tiling beside it.""" + + 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 decomposition_function(self, comb_class: Tiling) -> Tuple[Tiling, Tiling]: + if not is_reducible(comb_class, self.cells): + raise StrategyDoesNotApply(f"The cells {self.cells} are not reducible") + 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.cells) + + 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 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_gp, own_gp = objs + assert reduced_gp is not None and own_gp is not None + _, backward = compression(self.cells) + pattern = GriddedPerm(own_gp.patt, tuple(backward[cell] for cell in own_gp.pos)) + yield resort( + children[0].backward_map.map_gp(reduced_gp), + self.cells, + self.increasing, + pattern, + ) + + 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) + forward, _ = compression(self.cells) + own_gp = obj.get_gridded_perm_in_cells(self.cells) + return ( + children[0].forward_map.map_gp(resort(obj, self.cells, self.increasing)), + GriddedPerm(own_gp.patt, tuple(forward[cell] for cell in own_gp.pos)), + ) + + 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 both directions. + + 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; + - "any": every set of cells, of at most max_cells cells if that is given. + + 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) and it is not a single finite cell. So the reduced tiling is never larger + than the tiling, and smaller at some size. + """ + + 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) + lines: List[List[Cell]] = [] + if self.region in ("row", "line"): + lines.extend( + [cell for cell in active_cells if cell[1] == row] + for row in range(tiling.dimensions[1]) + ) + if self.region in ("column", "line"): + lines.extend( + [cell for cell in active_cells if cell[0] == column] + for column in range(tiling.dimensions[0]) + ) + seen = set() + if self.region == "cell": + candidates: Iterable[Tuple[Cell, ...]] = ((cell,) for cell in active_cells) + elif self.region in ("row", "column"): + candidates = (tuple(line) for line in lines) + elif self.region == "line": + candidates = ( + subset + for line in lines + for size in range(1, len(line) + 1) + for subset in combinations(line, size) + ) + else: + largest = len(active_cells) + if self.max_cells is not None: + largest = min(largest, self.max_cells) + candidates = ( + subset + for size in range(1, largest + 1) + for subset in combinations(active_cells, size) + ) + for cells in candidates: + if cells and cells not in seen: + seen.add(cells) + yield cells + + @staticmethod + def is_finite_cell(tiling: Tiling, cell: Cell) -> bool: + cell_basis = tiling.cell_basis()[cell][0] + return any(patt.is_increasing() for patt in cell_basis) and any( + patt.is_decreasing() for patt 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 + if not is_reducible(comb_class, cells) or is_rigid(comb_class, cells): + continue + yield from self.strategies(cells) + + def strategies(self, cells: Tuple[Cell, ...]) -> Iterator[ReductionStrategy]: + yield ReductionStrategy(cells, True, self.tracked) + yield ReductionStrategy(cells, False, 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 != "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": + return cls(d.get("tracked", True), d.get("region", "cell"), d.get("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(), ] From fdb4852efc830625e0e1a71f79aeb2b97693a4a4 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 22 Oct 2026 11:01:26 +0000 Subject: [PATCH 02/21] Test that a cell with no obstruction of its own is not reduced Its own tiling would be Tiling(), with only the empty permutation; develop's cell reduction factory offered such a cell. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 863da868..3c806dfb 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -282,6 +282,17 @@ def test_parent_assumptions(apart): assert len(rule.children[0].extra_parameters) == 3 +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_not_tracked(two_rows): strategy = ReductionStrategy([(1, 0), (2, 0), (3, 1)], True, tracked=False) reduced_tiling, _ = strategy.decomposition_function(two_rows) From 39ca3ceb2a16dbffd92b1c5a72a37d2b23ae658e Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 22 Oct 2026 14:35:29 +0000 Subject: [PATCH 03/21] The reduction: rows and columns already in order, cells the rest does not see left out, and cells it sees as one tracked once A crossing obstruction may now see its point of the set along a row or column the set has other cells in, when the set's own obstructions already put those cells in the order of the reduction: the sorting then leaves that row's order as it is, and the own tiling has the same obstructions. This makes the threshold reduction of Av(2143) (the cell left of the maximum, after its lowest inversion top is placed) a reduction of five cells. The reduced tiling now keeps only what the rest of the tiling sees of the set. It leaves out the cells whose row and column have no cell outside the set and that no crossing obstruction or requirement touches, which the own tiling counts, and keeps one cell for each group of cells in a row (or column) the rest cannot tell apart, tracked once in both children. The count is the Hadamard product in the groups' sizes and the product in the size of the cells left out. The maps are built from the column words, row words and cell permutations. minimal=False keeps every cell, each tracked, as before; a strategy encoded without the key decodes so, and keeps the children it had. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +- tests/strategies/test_reduction.py | 157 ++++- tilings/strategies/cell_reduction.py | 23 +- tilings/strategies/reduction.py | 953 ++++++++++++++++++--------- 4 files changed, 817 insertions(+), 336 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cc4888..08d1ca6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - `ReductionFactory` and `ReductionStrategy`, the reduction of a set of cells: the cells are made increasing or decreasing, and the tiling of their own - obstructions and requirements, with the points in each of them counted, goes - beside the reduced tiling. The count is the Hadamard product of the two in the - variables counting the points in the cells, at 1, and the rules generate and - sample their objects. The factory's `region` says which sets it tries: `"cell"` - (single cells, the cell reduction), `"row"`, `"column"`, `"line"` (every set of - cells in one row or one column) and `"any"` (every set, of at most `max_cells` - cells if given). + obstructions and requirements goes beside the reduced tiling. An obstruction + with one point in the set may see that point along a row or a column the set + has other cells in, when the set's own obstructions already put those cells in + the order of the reduction. The reduced tiling keeps only what the rest of the + tiling sees of the set: it leaves out the cells the rest does not see, and keeps + one cell for each group of cells it sees as one, tracked once in both children. + The count is the Hadamard product of the two children in the variables counting + the points in those groups, at 1, and the product in the size of the cells left + out, and the rules generate and sample their objects. The factory's `region` + says which sets it tries: `"cell"` (single cells, the cell reduction), `"row"`, + `"column"`, `"line"` (every set of cells in one row or one column) and `"any"` + (every set, of at most `max_cells` cells if given). A strategy with + `minimal=False` keeps and tracks every cell of the set. ### Changed - `CellReductionFactory` and `CellReductionStrategy` are now the reduction of diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 3c806dfb..85116a5c 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -5,6 +5,7 @@ from comb_spec_searcher import CombinatorialSpecification, Strategy 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.assumptions import TrackingAssumption @@ -19,7 +20,9 @@ is_reducible, is_rigid, reduction_children, + reduction_plan, ) +from tilings.strategies.requirement_placement import RequirementPlacementStrategy from tilings.strategy_pack import TileScopePack from tilings.tilescope import TileScope @@ -123,6 +126,29 @@ def one_cell(): ) +@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 offered_sets(tiling, region): return sorted( {strategy.cells for strategy in ReductionFactory(True, region)(tiling)} @@ -189,19 +215,34 @@ def test_regions(two_rows, apart, row_and_column, one_cell): 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 bottom row is one decreasing sequence, the cells tracked - assert GriddedPerm(Perm((0, 1)), ((1, 0), (2, 0))) in reduced_tiling.obstructions - for cell in cells: - assert GriddedPerm(Perm((0, 1)), (cell, cell)) in reduced_tiling.obstructions - assert TrackingAssumption.from_cells([cell]) in reduced_tiling.assumptions - assert reduced_tiling.dimensions == two_rows.dimensions + # 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 + # 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)]),) + # not minimal, the reduced tiling keeps every cell of the set, each tracked + assert reduction_plan(two_rows, cells, minimal=False) == ( + (((1, 0),), ((2, 0),), ((3, 1),)), + (), + ) + reduced_tiling, own_tiling = reduction_children( + two_rows, cells, False, minimal=False + ) + assert GriddedPerm(Perm((0, 1)), ((1, 0), (2, 0))) in reduced_tiling.obstructions + for cell in cells: + assert GriddedPerm(Perm((0, 1)), (cell, cell)) in reduced_tiling.obstructions + assert TrackingAssumption.from_cells([cell]) in reduced_tiling.assumptions + assert reduced_tiling.dimensions == two_rows.dimensions assert len(own_tiling.assumptions) == 3 @@ -217,10 +258,11 @@ def test_children(two_rows): ], ) @pytest.mark.parametrize("increasing", [True, False]) -def test_sanity_check(request, fixture, cells, increasing): +@pytest.mark.parametrize("minimal", [True, False]) +def test_sanity_check(request, fixture, cells, increasing, minimal): """The counts, objects and random samples of the rule against its tilings.""" tiling = request.getfixturevalue(fixture) - rule = ReductionStrategy(cells, increasing)(tiling) + rule = ReductionStrategy(cells, increasing, minimal=minimal)(tiling) for n in range(6): assert rule.sanity_check(n) @@ -234,31 +276,33 @@ def test_sanity_check(request, fixture, cells, increasing): ], ) @pytest.mark.parametrize("increasing", [True, False]) -def test_maps(request, fixture, cells, increasing): +@pytest.mark.parametrize("minimal", [True, False]) +def test_maps(request, fixture, cells, increasing, minimal): """The forward map is a bijection onto the pairs with as many points in each - cell, and the backward map is its inverse.""" + 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) + strategy = ReductionStrategy(cells, increasing, minimal=minimal) reduced_tiling, own_tiling = strategy.decomposition_function(tiling) + groups, _ = strategy.plan(tiling) _, own_to_parent = compression(strategy.cells) - def reduced_cell_sizes(reduced_gp): + def reduced_group_sizes(reduced_gp): parent_gp = reduced_tiling.backward_map.map_gp(reduced_gp) return tuple( - sum(1 for other in parent_gp.pos if other == cell) - for cell in strategy.cells + sum(1 for other in parent_gp.pos if other == group[0]) for group in groups ) - def own_cell_sizes(own_gp): + def own_group_sizes(own_gp): own_cells = [own_to_parent[cell] for cell in own_gp.pos] return tuple( - sum(1 for other in own_cells if other == cell) for cell in strategy.cells + 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_gp in own_tiling.objects_of_size(n): - own_by_sizes[own_cell_sizes(own_gp)].append(own_gp) + own_by_sizes[own_group_sizes(own_gp)].append(own_gp) for n in range(6): images = set() for gp in tiling.objects_of_size(n): @@ -268,12 +312,73 @@ def own_cell_sizes(own_gp): images.add((reduced_gp, own_gp)) pairs = { (reduced_gp, own_gp) - for reduced_gp in reduced_tiling.objects_of_size(n) - for own_gp in own_by_sizes[reduced_cell_sizes(reduced_gp)] + for reduced_size in range(n + 1) + for reduced_gp in reduced_tiling.objects_of_size(reduced_size) + for own_gp in own_by_sizes[reduced_group_sizes(reduced_gp)] + if reduced_size + len(own_gp) - sum(reduced_group_sizes(reduced_gp)) == n } assert images == pairs +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.""" rule = ReductionStrategy([(1, 0), (2, 2)], True)(apart) @@ -330,6 +435,18 @@ def test_encoding(): assert "region" not in CellReductionFactory(True).to_jsonable() +def test_encoded_before(): + """A strategy encoded before the choice keeps every cell, each tracked.""" + for strategy in ( + ReductionStrategy([(1, 0), (2, 0), (3, 1)], False), + CellReductionStrategy((0, 0), True), + ): + assert strategy.minimal + encoded = strategy.to_jsonable() + encoded.pop("minimal") + assert not Strategy.from_dict(encoded).minimal + + @pytest.mark.timeout(120) def test_specification(): """A specification of Av(1234, 1342, 2413) that reduces a set of three cells.""" diff --git a/tilings/strategies/cell_reduction.py b/tilings/strategies/cell_reduction.py index fe38b46a..60cc9561 100644 --- a/tilings/strategies/cell_reduction.py +++ b/tilings/strategies/cell_reduction.py @@ -5,7 +5,7 @@ and encodings for the packs and specifications that use them. """ -from typing import Iterator, Tuple, cast +from typing import Tuple, cast from .reduction import ReductionConstructor, ReductionFactory, ReductionStrategy @@ -24,9 +24,10 @@ def __init__( cell: Cell, increasing: bool, tracked: bool = True, + minimal: bool = True, ): self.cell = cast(Cell, tuple(cell)) - super().__init__((self.cell,), increasing, tracked) + super().__init__((self.cell,), increasing, tracked, minimal) def to_jsonable(self) -> dict: d: dict = super().to_jsonable() @@ -39,26 +40,28 @@ def from_dict(cls, d: dict) -> "CellReductionStrategy": cell = cast(Cell, tuple(d.pop("cell"))) tracked = d.pop("tracked") increasing = d.pop("increasing") + # encoded before the choice existed: the cell kept and tracked as it was + minimal = d.pop("minimal", False) assert not d - return cls(cell, increasing, tracked) + return cls(cell, increasing, tracked, minimal) def __repr__(self) -> str: return ( f"{self.__class__.__name__}(cell={self.cell!r}, " - f"increasing={self.increasing!r}, tracked={self.tracked!r})" + f"increasing={self.increasing!r}, tracked={self.tracked!r}, " + f"minimal={self.minimal!r})" ) class CellReductionFactory(ReductionFactory): """The reductions of single cells: `ReductionFactory` with the region "cell".""" - def __init__(self, tracked: bool): - super().__init__(tracked, "cell") + def __init__(self, tracked: bool, minimal: bool = True): + super().__init__(tracked, "cell", None, minimal) - def strategies(self, cells: Tuple[Cell, ...]) -> Iterator[ReductionStrategy]: + def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: (cell,) = cells - yield CellReductionStrategy(cell, True, self.tracked) - yield CellReductionStrategy(cell, False, self.tracked) + return CellReductionStrategy(cell, increasing, self.tracked, self.minimal) def __repr__(self) -> str: return self.__class__.__name__ + f"({self.tracked})" @@ -71,4 +74,4 @@ def to_jsonable(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "CellReductionFactory": - return cls(d["tracked"]) + return cls(d["tracked"], d.get("minimal", True)) diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index 05a3c43a..828b584e 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -1,14 +1,17 @@ +# pylint: disable=too-many-lines """The reduction strategy, which makes a set of cells monotone. -A set of active cells of a tiling is reducible 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 +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 of c, then c is the only cell - of the set in its column, and if it has a point in another cell of the row of - c, then c is the only cell of the set in its row; +- 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; and every requirement list lies in the set, or each of its gridded permutations has no point in the set or one point in it that the obstructions may have. @@ -17,29 +20,47 @@ 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. 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 -sorting the share of the set into a canonical one keeps both. So sorting is a -bijection from the gridded permutations of the tiling to the pairs of a gridded -permutation of the reduced tiling, in which the share of the set is canonical, -and one of the tiling of the set's own obstructions and requirements, with the -same number of points in each cell of the set. In the canonical share 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. - -The count is the Hadamard product of the two children in the variables that -count the points in the cells of the set, at 1: - - parent(n) = sum over v of reduced(n; v) * own(v), - -the sum over the number of points v in each cell of the set. A single cell is the -cell reduction (`tilings.strategies.cell_reduction`). +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. 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 tiling of the set's own obstructions and requirements, with as many +points in the cells of the set. + +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. Each group is tracked once, in both +children, and 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`). A strategy that is +not minimal, as are those of specifications encoded before the choice existed, +keeps every cell of the set in the reduced tiling, each tracked. """ import random from collections import Counter, defaultdict +from functools import lru_cache from itertools import combinations, product +from operator import itemgetter from typing import ( Callable, DefaultDict, @@ -48,7 +69,9 @@ Iterable, Iterator, List, + NamedTuple, Optional, + Set, Tuple, cast, ) @@ -74,15 +97,84 @@ __all__ = ("ReductionConstructor", "ReductionStrategy", "ReductionFactory") Cell = Tuple[int, int] +Groups = Tuple[Tuple[Cell, ...], ...] +Plan = Tuple[Groups, Tuple[Cell, ...]] +Words = Dict[int, List[Cell]] REGIONS = ("cell", "row", "column", "line", "any") -def _crossing_is_allowed(gp: GriddedPerm, cells: FrozenSet[Cell]) -> bool: +def _lies_in(gps: 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 gp in gps for cell in gp.pos) + + +def _crosses(gp: 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 gp.pos if cell in cells) + return 0 < points_inside < len(gp) + + +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)) + patt = 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(patt, (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( + gp: 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 that point's cell is the only one of the - cells in its column (row).""" + 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 gp.pos if cell in cells] if len(cells_inside) != 1: return False @@ -92,16 +184,18 @@ def _crossing_is_allowed(gp: GriddedPerm, cells: FrozenSet[Cell]) -> bool: touches_row = any(cell[1] == row for cell in cells_outside) if touches_column and touches_row: return False - if touches_column and any(cell[0] == column and cell[1] != row for cell in cells): + if touches_column and column not in ordered_columns: return False - if touches_row and any(cell[1] == row and cell[0] != column for cell in cells): + if touches_row and row not in ordered_rows: return False return True -def is_reducible(tiling: Tiling, cells: Iterable[Cell]) -> bool: - """Return True if the set of cells is reducible on the tiling.""" - cell_set = frozenset(cells) +def _qualifies(tiling: Tiling, cell_set: FrozenSet[Cell]) -> bool: + """Return True if the set is of active cells, with no component assumption + on them, and each of its cells has an obstruction of its own: the tiling of the + set's own obstructions must have every cell of the set (an unrestricted cell + has none).""" if not cell_set or not cell_set <= tiling.active_cells: return False if any( @@ -110,31 +204,44 @@ def is_reducible(tiling: Tiling, cells: Iterable[Cell]) -> bool: for assumption in tiling.assumptions ): return False - # the tiling of the set's own obstructions must have every cell of the set, so - # each cell needs an obstruction of its own (an unrestricted cell has none) 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 } - if not cell_set <= cells_with_own_obstructions: - return False + return cell_set <= cells_with_own_obstructions + + +def _seen_gps(tiling: Tiling, cell_set: FrozenSet[Cell]) -> Iterator[GriddedPerm]: + """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.""" for obstruction in tiling.obstructions: - points_inside = sum(1 for cell in obstruction.pos if cell in cell_set) - if 0 < points_inside < len(obstruction) and not _crossing_is_allowed( - obstruction, cell_set - ): - return False + if _crosses(obstruction, cell_set): + yield obstruction for requirement in tiling.requirements: - if all(cell in cell_set for gp in requirement for cell in gp.pos): - continue - for gp in requirement: - if any(cell in cell_set for cell in gp.pos) and not _crossing_is_allowed( - gp, cell_set - ): - return False - return True + if not _lies_in(requirement, cell_set): + for gp in requirement: + if any(cell in cell_set for cell in gp.pos): + yield gp + + +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.""" + if increasing is None: + return is_reducible(tiling, cells, True) or is_reducible(tiling, cells, False) + cell_set = frozenset(cells) + if not _qualifies(tiling, cell_set): + return False + ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) + return all( + _crossing_is_allowed(gp, cell_set, ordered_columns, ordered_rows) + for gp in _seen_gps(tiling, cell_set) + ) def is_rigid(tiling: Tiling, cells: Iterable[Cell]) -> bool: @@ -168,26 +275,6 @@ def is_forbidden(gp: GriddedPerm) -> bool: return True -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)) - patt = 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(patt, (left_cell, right_cell))) - return obstructions - - def compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cell]]: """Return the maps from the cells of the set to the cells of its own tiling, which keeps the columns and rows of the set only, and back.""" @@ -204,26 +291,178 @@ def compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cel return forward, backward -def reduction_children( - tiling: Tiling, cells: Iterable[Cell], increasing: bool, tracked: bool = True -) -> Tuple[Tiling, Tiling]: - """Return the reduced tiling, with the cells tracked if tracked, and the - tiling of the cells' own obstructions and requirements, with every cell - tracked if there are two or more.""" - cell_set = frozenset(cells) +class _View(NamedTuple): + """What the rest of the tiling has to do with a set of cells: the columns and + the rows with cells outside the set, the obstructions and the requirement lists + with points outside it, and the tracked cells.""" + + outside_columns: FrozenSet[int] + outside_rows: FrozenSet[int] + crossing_obstructions: Tuple[GriddedPerm, ...] + crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] + tracked: FrozenSet[Cell] + + +def _view(tiling: Tiling, cell_set: FrozenSet[Cell]) -> _View: + outside = tiling.active_cells - cell_set + return _View( + frozenset(cell[0] for cell in outside), + frozenset(cell[1] for cell in outside), + tuple(ob for ob in tiling.obstructions if _crosses(ob, cell_set)), + tuple(req for req in tiling.requirements if not _lies_in(req, cell_set)), + frozenset( + gp.pos[0] for assumption in tiling.assumptions for gp in assumption.gps + ), + ) - def lies_in_cells(gps: Iterable[GriddedPerm]) -> bool: - return all(cell in cell_set for gp in gps for cell in gp.pos) - cell_assumptions = [ - TrackingAssumption.from_cells([cell]) for cell in sorted(cell_set) - ] - reduced_tiling = Tiling( - [ob for ob in tiling.obstructions if not lies_in_cells([ob])] - + monotone_obstructions(cell_set, increasing), - [req for req in tiling.requirements if not lies_in_cells(req)], - list(tiling.assumptions) + (cell_assumptions if tracked else []), +def _moved(gp: GriddedPerm, source: Cell, target: Cell) -> GriddedPerm: + """The gridded permutation with its point in the source cell in the target + cell instead.""" + return GriddedPerm( + gp.patt, tuple(target if cell == source else cell for cell in gp.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 gp in requirement: + if source in gp.pos and _moved(gp, source, target) not in requirement: + return False + return True + + +def _left_out( + cells: Tuple[Cell, ...], cell_set: FrozenSet[Cell], 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.""" + touched = { + cell for gp in view.crossing_obstructions for cell in gp.pos if cell in cell_set + } + touched.update( + cell + for requirement in view.crossing_requirements + for gp in requirement + for cell in gp.pos + if cell in cell_set + ) + return tuple( + cell + for cell in cells + if cell[0] not in view.outside_columns + and cell[1] not in view.outside_rows + and cell not in touched + and cell not in view.tracked + ) + + +def _runs( + tiling: Tiling, seen: List[Cell], along_row: bool, view: _View +) -> 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 + + +@lru_cache(maxsize=1024) +def _layout( + tiling: Tiling, cells: Tuple[Cell, ...], minimal: bool +) -> Tuple[Plan, FrozenSet[int], FrozenSet[int]]: + """The plan of the reduction (see reduction_plan), and the columns and the + rows with cells outside the set.""" + cell_set = frozenset(cells) + view = _view(tiling, cell_set) + if not minimal: + plan: Plan = tuple((cell,) for cell in cells), () + return plan, view.outside_columns, view.outside_rows + left_out = _left_out(cells, cell_set, 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, True, view) + _runs(tiling, seen, False, view) + grouped = {cell for run in runs for cell in run} + groups = runs + [(cell,) for cell in seen if cell not in grouped] + return (tuple(sorted(groups)), left_out), view.outside_columns, view.outside_rows + + +def reduction_plan(tiling: Tiling, cells: Iterable[Cell], minimal: bool = True) -> Plan: + """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. Not minimal: every cell + a group of its own, and none left out.""" + return _layout(tiling, tuple(sorted(set(cells))), minimal)[0] + + +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: Plan, + 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( + [ob for ob in tiling.obstructions if not _lies_in([ob], cell_set)] + + monotone_obstructions(kept, increasing) + + [GriddedPerm.point_perm(cell) for cell in emptied], + [req for req in tiling.requirements if not _lies_in(req, 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: Plan) -> Tiling: + groups, left_out = plan width, height = tiling.dimensions other_cells_emptied = [ GriddedPerm.point_perm((column, row)) @@ -231,147 +470,148 @@ def lies_in_cells(gps: Iterable[GriddedPerm]) -> bool: for row in range(height) if (column, row) not in cell_set ] - own_tiling = Tiling( - [ob for ob in tiling.obstructions if lies_in_cells([ob])] + other_cells_emptied, - [req for req in tiling.requirements if lies_in_cells(req)], - cell_assumptions if len(cell_set) > 1 else [], + return Tiling( + [ob for ob in tiling.obstructions if _lies_in([ob], cell_set)] + + other_cells_emptied, + [req for req in tiling.requirements if _lies_in(req, cell_set)], + ( + [TrackingAssumption.from_cells(list(group)) for group in groups] + if _own_is_tracked(groups, left_out) + else [] + ), ) - return reduced_tiling, own_tiling -def _canonical_words( - points_in_cell: Counter, increasing: bool -) -> Tuple[Dict[int, List[Cell]], Dict[int, List[Cell]]]: - """The canonical words of the cells: for each column the cells of its points - from left to right, and for each row from bottom to top.""" - column_words: Dict[int, List[Cell]] = {} - for column in {cell[0] for cell in points_in_cell}: - cells_in_column = sorted( - (cell for cell in points_in_cell if cell[0] == column), - key=lambda cell: cell[1], - reverse=not increasing, - ) - column_words[column] = [ - cell for cell in cells_in_column for _ in range(points_in_cell[cell]) - ] - row_words: Dict[int, List[Cell]] = {} - for row in {cell[1] for cell in points_in_cell}: - cells_in_row = sorted( - (cell for cell in points_in_cell if cell[1] == row), - key=lambda cell: cell[0], - reverse=not increasing, - ) - row_words[row] = [ - cell for cell in cells_in_row for _ in range(points_in_cell[cell]) - ] - return column_words, row_words +def reduction_children( + tiling: Tiling, + cells: Iterable[Cell], + increasing: bool, + tracked: bool = True, + minimal: 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 tiling of the set's own obstructions and requirements, each + group tracked if there are two or more, or cells left out.""" + cell_set = frozenset(cells) + plan = reduction_plan(tiling, cell_set, minimal) + return ( + _reduced_tiling(tiling, cell_set, plan, increasing, tracked), + _own_tiling(tiling, cell_set, plan), + ) -def _pattern_words( - pattern: GriddedPerm, -) -> Tuple[Dict[int, List[Cell]], Dict[int, List[Cell]]]: - """The words of the pattern: for each column the cells of its points from - left to right, and for each row from bottom to top.""" +def _words(gp: 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 pattern.pos: + for cell in gp.pos: column_words[cell[0]].append(cell) row_words: DefaultDict[int, List[Cell]] = defaultdict(list) - for index in sorted(range(len(pattern)), key=lambda index: pattern.patt[index]): - row_words[pattern.pos[index][1]].append(pattern.pos[index]) - return column_words, row_words - - -def _cell_ranks( - points_in_cell: Counter, increasing: bool, pattern: Optional[GriddedPerm] -) -> Dict[Cell, List[int]]: - """For each cell, the ranks of the values of its points, from left to right.""" - if pattern is None: - return { - cell: list(range(size)) if increasing else list(range(size - 1, -1, -1)) - for cell, size in points_in_cell.items() - } - ranks = {} - for cell in points_in_cell: - pattern_values = [ - value for value, other in zip(pattern.patt, pattern.pos) if other == cell - ] - sorted_values = sorted(pattern_values) - ranks[cell] = [sorted_values.index(value) for value in pattern_values] - return ranks + for index in sorted(range(len(gp)), key=lambda index: gp.patt[index]): + row_words[gp.pos[index][1]].append(gp.pos[index]) + values_in_cell: DefaultDict[Cell, List[int]] = defaultdict(list) + for value, cell in zip(gp.patt, gp.pos): + values_in_cell[cell].append(value) + ranks = { + cell: [sorted(values).index(value) for value in values] + for cell, values in values_in_cell.items() + } + return column_words, row_words, ranks -def _deal_slots( - gp: GriddedPerm, - indices_in_cells: List[int], - column_words: Dict[int, List[Cell]], - row_words: Dict[int, List[Cell]], -) -> Tuple[DefaultDict[Cell, List[int]], DefaultDict[Cell, List[int]]]: - """The positions and values that the points of each cell get: the positions - of the cells' points in each column dealt out in the order of its word, and - the values of their points in each row.""" - positions: DefaultDict[Cell, List[int]] = defaultdict(list) - for column, word in column_words.items(): - slots = [index for index in indices_in_cells if gp.pos[index][0] == column] - for slot, cell in zip(slots, word): - positions[cell].append(slot) - values: DefaultDict[Cell, List[int]] = defaultdict(list) - for row, word in row_words.items(): - slots = sorted( - gp.patt[index] for index in indices_in_cells if gp.pos[index][1] == row - ) - for slot, cell in zip(slots, word): - values[cell].append(slot) - return positions, values +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 _assemble( - gp: GriddedPerm, - positions: DefaultDict[Cell, List[int]], - values: DefaultDict[Cell, List[int]], - ranks: Dict[Cell, List[int]], +def _from_words( + column_words: Words, row_words: Words, ranks: Dict[Cell, List[int]] ) -> GriddedPerm: - """The gridded permutation with each cell's points at its positions, with its - values in the order of its ranks.""" - new_values, new_cells = list(gp.patt), list(gp.pos) - for cell, cell_ranks in ranks.items(): - cell_values = sorted(values[cell]) - for position, rank in zip(sorted(positions[cell]), cell_ranks): - new_values[position] = cell_values[rank] - new_cells[position] = cell - return GriddedPerm(Perm(new_values), tuple(new_cells)) - - -def resort( - gp: GriddedPerm, - cells: Iterable[Cell], - increasing: bool, - pattern: Optional[GriddedPerm] = None, -) -> GriddedPerm: - """Return the gridded permutation with the share of the cells made canonical, - or, if pattern is given, made that of pattern, a gridded permutation in the - cells with as many points in each of them. Every column keeps the positions - of the points of the cells in it, and every row their values.""" - cell_set = frozenset(cells) - indices_in_cells = [index for index, cell in enumerate(gp.pos) if cell in cell_set] - points_in_cell = Counter(gp.pos[index] for index in indices_in_cells) - if pattern is None: - column_words, row_words = _canonical_words(points_in_cell, increasing) - else: - if Counter(pattern.pos) != points_in_cell: - raise ValueError("the pattern has a different number of points in a cell") - column_words, row_words = _pattern_words(pattern) - positions, values = _deal_slots(gp, indices_in_cells, column_words, row_words) - return _assemble( - gp, positions, values, _cell_ranks(points_in_cell, increasing, pattern) - ) + """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") + patt = [0] * size + pos: 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]): + patt[position] = values[cell][rank] + pos[position] = cell + return GriddedPerm(Perm(patt), tuple(pos)) + + +def _monotone_ranks(size: int, increasing: bool) -> List[int]: + return list(range(size)) if increasing else list(range(size - 1, -1, -1)) + + +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) + } class ReductionConstructor(Constructor[Tiling, GriddedPerm]): """The constructor of a reduction: the Hadamard product of the reduced tiling and the tiling of the cells' own obstructions in the variables that count the - points in the cells, at 1. + 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 v of reduced(n; p, v) * own(|v|; v) + parent(n; p) = sum over w and m of reduced(n - m; p, w) * own(|w| + m; w) """ def __init__( @@ -379,9 +619,14 @@ def __init__( parent: Tiling, children: Tuple[Tiling, ...], cells: Iterable[Cell], + plan: Optional[Plan] = None, ): reduced_tiling, own_tiling = children self.cells = tuple(sorted(set(cells))) + if plan is None: + plan = tuple((cell,) for cell in self.cells), () + self.groups, left_out = plan + self.leaves_out = bool(left_out) reduced_parameters = reduced_tiling.extra_parameters def reduced_parameter(assumption: TrackingAssumption) -> str: @@ -389,12 +634,12 @@ def reduced_parameter(assumption: TrackingAssumption) -> str: reduced_tiling.forward_map.map_assumption(assumption) ) - self.reduced_cell_parameters = tuple( - reduced_parameter(TrackingAssumption.from_cells([cell])) - for cell in self.cells + self.reduced_group_parameters = tuple( + reduced_parameter(TrackingAssumption.from_cells([group[0]])) + for group in self.groups ) - self.reduced_cell_indices = tuple( - reduced_parameters.index(name) for name in self.reduced_cell_parameters + self.reduced_group_indices = tuple( + reduced_parameters.index(name) for name in self.reduced_group_parameters ) self.parent_to_reduced = { parent_parameter: reduced_parameter(assumption) @@ -406,32 +651,36 @@ def reduced_parameter(assumption: TrackingAssumption) -> str: reduced_parameters.index(self.parent_to_reduced[parent_parameter]) for parent_parameter in parent.extra_parameters ) - if len(self.cells) == 1: - self.own_cell_parameters: Optional[Tuple[str, ...]] = None - self.own_cell_indices: Tuple[int, ...] = () + if not _own_is_tracked(self.groups, left_out): + self.own_group_parameters: Optional[Tuple[str, ...]] = None + self.own_group_indices: Tuple[int, ...] = () else: forward, _ = compression(self.cells) - self.own_cell_parameters = tuple( + self.own_group_parameters = tuple( own_tiling.get_assumption_parameter( - TrackingAssumption.from_cells([forward[cell]]) + TrackingAssumption.from_cells([forward[cell] for cell in group]) ) - for cell in self.cells + for group in self.groups ) - self.own_cell_indices = tuple( + self.own_group_indices = tuple( own_tiling.extra_parameters.index(name) - for name in self.own_cell_parameters + for name in self.own_group_parameters ) - def _cell_sizes(self, own_parameters: Parameters, size: int) -> Tuple[int, ...]: - """The number of points in each cell, from a parameter of the own tiling.""" - if self.own_cell_parameters is None: + def _left_out_sizes(self, n: int) -> range: + """The numbers of points the cells left out may have.""" + return range(n + 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_cell_indices) + return tuple(own_parameters[index] for index in self.own_group_indices) - def _own_parameters(self, cell_sizes: Tuple[int, ...]) -> Dict[str, int]: - if self.own_cell_parameters is None: + def _own_parameters(self, group_sizes: Tuple[int, ...]) -> Dict[str, int]: + if self.own_group_parameters is None: return {} - return dict(zip(self.own_cell_parameters, cell_sizes)) + return dict(zip(self.own_group_parameters, group_sizes)) def get_equation( self, lhs_func: sympy.Function, rhs_funcs: Tuple[sympy.Function, ...] @@ -444,19 +693,19 @@ 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 cell, and the parent's parameters, from a + """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_cell_indices), + tuple(reduced_parameters[index] for index in self.reduced_group_indices), tuple(reduced_parameters[index] for index in self.parent_indices), ) def _own_counts(self, own_terms: Callable[[int], Terms], size: int) -> Counter: """The number of objects of the own tiling with each number of points in - the cells, for objects of the size.""" + the groups, for objects of the size.""" own_counts: Counter = Counter() for own_parameters, own_count in own_terms(size).items(): - own_counts[self._cell_sizes(own_parameters, size)] += own_count + own_counts[self._group_sizes(own_parameters, size)] += own_count return own_counts def get_terms( @@ -465,26 +714,29 @@ def get_terms( reduced_terms, own_terms = subterms own_counts_by_size: Dict[int, Counter] = {} terms: Terms = Counter() - for reduced_parameters, reduced_count in reduced_terms(n).items(): - cell_sizes, parent_parameters = self._split(reduced_parameters) - size = sum(cell_sizes) - if size not in own_counts_by_size: - own_counts_by_size[size] = self._own_counts(own_terms, size) - own_count = own_counts_by_size[size][cell_sizes] - if own_count: - terms[parent_parameters] += reduced_count * own_count + for left_out_size in self._left_out_sizes(n): + for reduced_parameters, reduced_count in reduced_terms( + n - left_out_size + ).items(): + group_sizes, parent_parameters = self._split(reduced_parameters) + own_size = sum(group_sizes) + left_out_size + if own_size not in own_counts_by_size: + own_counts_by_size[own_size] = self._own_counts(own_terms, own_size) + own_count = own_counts_by_size[own_size][group_sizes] + if own_count: + 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 cell.""" + each group.""" own_lists: DefaultDict[Parameters, List[Optional[GriddedPerm]]] = defaultdict( list ) for own_parameters, own_list in own_objects(size).items(): - own_lists[self._cell_sizes(own_parameters, size)].extend(own_list) + own_lists[self._group_sizes(own_parameters, size)].extend(own_list) return own_lists def get_sub_objects( @@ -493,36 +745,48 @@ def get_sub_objects( reduced_objects, own_objects = subobjs own_by_size: Dict[int, DefaultDict[Parameters, List[Optional[GriddedPerm]]]] own_by_size = {} - for reduced_parameters, reduced_list in reduced_objects(n).items(): - cell_sizes, parent_parameters = self._split(reduced_parameters) - size = sum(cell_sizes) - if size not in own_by_size: - own_by_size[size] = self._own_objects(own_objects, size) - own_list = own_by_size[size].get(cell_sizes, []) - if reduced_list and own_list: - yield parent_parameters, (reduced_list, own_list) + for left_out_size in self._left_out_sizes(n): + for reduced_parameters, reduced_list in reduced_objects( + n - left_out_size + ).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] = self._own_objects(own_objects, own_size) + own_list = own_by_size[own_size].get(group_sizes, []) + if reduced_list and own_list: + yield parent_parameters, (reduced_list, own_list) def _sample_choices( self, n: int, parent_parameters: Dict[str, int] - ) -> Iterator[Tuple[Dict[str, int], int, Dict[str, int]]]: - """For each number of points in the cells that the parent's parameters - allow, the parameters of the reduced tiling, the size and the parameters - of the own tiling.""" + ) -> Iterator[Tuple[int, Dict[str, int], int, Dict[str, int]]]: + """For each number of points in the cells left out and in the groups that + the parent's parameters allow, the size and the parameters of the reduced + tiling, and the size and the parameters of the own tiling.""" fixed_parameters = { self.parent_to_reduced[name]: value for name, value in parent_parameters.items() } - for cell_sizes in product(range(n + 1), repeat=len(self.cells)): - if sum(cell_sizes) > n: - continue - reduced_parameters = dict(fixed_parameters) - if all( - reduced_parameters.setdefault(name, cell_size) == cell_size - for name, cell_size in zip(self.reduced_cell_parameters, cell_sizes) + for left_out_size in self._left_out_sizes(n): + reduced_size = n - left_out_size + for group_sizes in product( + range(reduced_size + 1), repeat=len(self.groups) ): - yield reduced_parameters, sum(cell_sizes), self._own_parameters( - cell_sizes - ) + if sum(group_sizes) > reduced_size: + continue + reduced_parameters = dict(fixed_parameters) + if all( + reduced_parameters.setdefault(name, group_size) == group_size + for name, group_size in zip( + self.reduced_group_parameters, group_sizes + ) + ): + yield ( + reduced_size, + reduced_parameters, + sum(group_sizes) + left_out_size, + self._own_parameters(group_sizes), + ) def random_sample_sub_objects( self, @@ -535,33 +799,34 @@ def random_sample_sub_objects( random_choice = random.randint(1, parent_count) total = 0 for choice in self._sample_choices(n, parameters): - total += self._choice_count(subrecs, n, choice) + total += self._choice_count(subrecs, choice) if random_choice <= total: - reduced_parameters, size, own_parameters = choice + reduced_size, reduced_parameters, own_size, own_parameters = choice return ( - subsamplers[0](n=n, **reduced_parameters), - subsamplers[1](n=size, **own_parameters), + subsamplers[0](n=reduced_size, **reduced_parameters), + subsamplers[1](n=own_size, **own_parameters), ) raise RuntimeError("Function did not return") @staticmethod def _choice_count( - subrecs: SubRecs, n: int, choice: Tuple[Dict[str, int], int, Dict[str, int]] + subrecs: SubRecs, choice: Tuple[int, Dict[str, int], int, Dict[str, int]] ) -> int: - """The number of pairs of objects for the choice of the reduced tiling's - parameters, the size and the own tiling's parameters.""" - reduced_parameters, size, own_parameters = choice - reduced_count = subrecs[0](n=n, **reduced_parameters) + """The number of pairs of objects for the choice of the sizes and the + parameters of the reduced tiling and of the own tiling.""" + reduced_size, reduced_parameters, own_size, own_parameters = choice + reduced_count = subrecs[0](n=reduced_size, **reduced_parameters) if not reduced_count: return 0 - return reduced_count * subrecs[1](n=size, **own_parameters) + return reduced_count * subrecs[1](n=own_size, **own_parameters) def equiv( self, other: "Constructor", data: Optional[object] = None ) -> Tuple[bool, Optional[object]]: return ( isinstance(other, ReductionConstructor) - and len(self.cells) == len(other.cells) + and len(self.groups) == len(other.groups) + and self.leaves_out == other.leaves_out and len(self.parent_indices) == len(other.parent_indices), None, ) @@ -581,19 +846,22 @@ def __str__(self) -> str: 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 cells' - own tiling beside it.""" + own tiling beside it. Minimal, 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, + minimal: bool = True, ): self.cells: Tuple[Cell, ...] = tuple( sorted(set(cast(Cell, tuple(cell)) for cell in cells)) ) self.increasing = increasing self.tracked = tracked + self.minimal = minimal super().__init__() def can_be_equivalent(self) -> bool: @@ -610,10 +878,20 @@ def shifts( ) -> Tuple[int, ...]: return (0, 0) + def plan(self, comb_class: Tiling) -> Plan: + """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, self.minimal) + def decomposition_function(self, comb_class: Tiling) -> Tuple[Tiling, Tiling]: - if not is_reducible(comb_class, self.cells): - raise StrategyDoesNotApply(f"The cells {self.cells} are not reducible") - return reduction_children(comb_class, self.cells, self.increasing, self.tracked) + 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, self.minimal + ) def constructor( self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None @@ -622,7 +900,9 @@ def constructor( raise NotImplementedError("The reduction strategy was not tracked") if children is None: children = self.decomposition_function(comb_class) - return ReductionConstructor(comb_class, children, self.cells) + return ReductionConstructor( + comb_class, children, self.cells, self.plan(comb_class) + ) def reverse_constructor( self, @@ -656,6 +936,61 @@ def formal_step(self) -> str: noun = "cell" if len(self.cells) == 1 else "cells" return f"reducing {noun} {cells} to {direction}" + def reduced_gp(self, comb_class: Tiling, gp: GriddedPerm) -> GriddedPerm: + """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.""" + (groups, _), outside_columns, outside_rows = _layout( + comb_class, self.cells, self.minimal + ) + cell_set = frozenset(self.cells) + keeper = {cell: group[0] for group in groups for cell in group} + kept = [group[0] for group in groups] + points_in_cell = Counter(keeper[cell] for cell in gp.pos if cell in keeper) + column_words, row_words, ranks = _words(gp) + 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], self.increasing) + return _from_words( + _combined_words( + column_words, + _canonical_words(kept, points_in_cell, False, self.increasing), + cell_set, + outside_columns, + ), + _combined_words( + row_words, + _canonical_words(kept, points_in_cell, True, self.increasing), + cell_set, + outside_rows, + ), + new_ranks, + ) + + def parent_gp( + self, comb_class: Tiling, reduced_gp: GriddedPerm, own_gp: 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: the rest from the first, + the share of the set from the second.""" + _, outside_columns, outside_rows = _layout(comb_class, self.cells, self.minimal) + cell_set = frozenset(self.cells) + reduced_columns, reduced_rows, reduced_ranks = _words(reduced_gp) + own_columns, own_rows, own_ranks = _words(own_gp) + 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, outside_columns), + _combined_words(reduced_rows, own_rows, cell_set, outside_rows), + ranks, + ) + def backward_map( self, comb_class: Tiling, @@ -667,12 +1002,10 @@ def backward_map( reduced_gp, own_gp = objs assert reduced_gp is not None and own_gp is not None _, backward = compression(self.cells) - pattern = GriddedPerm(own_gp.patt, tuple(backward[cell] for cell in own_gp.pos)) - yield resort( + yield self.parent_gp( + comb_class, children[0].backward_map.map_gp(reduced_gp), - self.cells, - self.increasing, - pattern, + GriddedPerm(own_gp.patt, tuple(backward[cell] for cell in own_gp.pos)), ) def forward_map( @@ -686,7 +1019,7 @@ def forward_map( forward, _ = compression(self.cells) own_gp = obj.get_gridded_perm_in_cells(self.cells) return ( - children[0].forward_map.map_gp(resort(obj, self.cells, self.increasing)), + children[0].forward_map.map_gp(self.reduced_gp(comb_class, obj)), GriddedPerm(own_gp.patt, tuple(forward[cell] for cell in own_gp.pos)), ) @@ -699,6 +1032,7 @@ def to_jsonable(self) -> dict: d["cells"] = self.cells d["increasing"] = self.increasing d["tracked"] = self.tracked + d["minimal"] = self.minimal return d @classmethod @@ -706,13 +1040,16 @@ 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") + # encoded before the choice existed: every cell kept and tracked + minimal = d.pop("minimal", False) assert not d - return cls(cells, increasing, tracked) + return cls(cells, increasing, tracked, minimal) def __repr__(self) -> str: return ( f"{self.__class__.__name__}(cells={self.cells!r}, " - f"increasing={self.increasing!r}, tracked={self.tracked!r})" + f"increasing={self.increasing!r}, tracked={self.tracked!r}, " + f"minimal={self.minimal!r})" ) @staticmethod @@ -725,7 +1062,8 @@ def get_op_symbol() -> str: class ReductionFactory(StrategyFactory[Tiling]): - """The reductions of the sets of cells of a region, in both directions. + """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: @@ -739,8 +1077,9 @@ class ReductionFactory(StrategyFactory[Tiling]): 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) and it is not a single finite cell. So the reduced tiling is never larger - than the tiling, and smaller at some size. + it), it is not a single finite cell, and, minimal, the rest of the tiling sees + one of its cells (if it sees none, the set is a factor). So the reduced tiling + is never larger than the tiling, and smaller at some size. """ def __init__( @@ -748,12 +1087,14 @@ def __init__( tracked: bool = True, region: str = "cell", max_cells: Optional[int] = None, + minimal: bool = True, ): 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 + self.minimal = minimal super().__init__() def candidate_sets(self, tiling: Tiling) -> Iterator[Tuple[Cell, ...]]: @@ -810,18 +1151,26 @@ def __call__(self, comb_class: Tiling) -> Iterator[ReductionStrategy]: continue if len(cells) == 1 and self.is_finite_cell(comb_class, cells[0]): continue - if not is_reducible(comb_class, cells) or is_rigid(comb_class, cells): + directions = [ + increasing + for increasing in (True, False) + if is_reducible(comb_class, cells, increasing) + ] + if not directions or is_rigid(comb_class, cells): continue - yield from self.strategies(cells) + if self.minimal and not reduction_plan(comb_class, cells)[0]: + continue + for increasing in directions: + yield self.strategy(cells, increasing) - def strategies(self, cells: Tuple[Cell, ...]) -> Iterator[ReductionStrategy]: - yield ReductionStrategy(cells, True, self.tracked) - yield ReductionStrategy(cells, False, self.tracked) + def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: + return ReductionStrategy(cells, increasing, self.tracked, self.minimal) def __repr__(self) -> str: return ( f"{self.__class__.__name__}(tracked={self.tracked!r}, " - f"region={self.region!r}, max_cells={self.max_cells!r})" + f"region={self.region!r}, max_cells={self.max_cells!r}, " + f"minimal={self.minimal!r})" ) def __str__(self) -> str: @@ -837,8 +1186,14 @@ def to_jsonable(self) -> dict: d["tracked"] = self.tracked d["region"] = self.region d["max_cells"] = self.max_cells + d["minimal"] = self.minimal return d @classmethod def from_dict(cls, d: dict) -> "ReductionFactory": - return cls(d.get("tracked", True), d.get("region", "cell"), d.get("max_cells")) + return cls( + d.get("tracked", True), + d.get("region", "cell"), + d.get("max_cells"), + d.get("minimal", True), + ) From 70459d6beb86a6ddd6ccee107202a9e37d9dd96a Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 22 Oct 2026 15:57:21 +0000 Subject: [PATCH 04/21] The reduction without the old mode: every reduction keeps only what the rest of the tiling sees Henning: "I don't think we really need to be able to read the old specs. They were mostly made for testing purposes. We should rather care about the cleanlyness of the code". So minimal goes from ReductionStrategy, ReductionFactory, the cell reduction's classes, reduction_plan and reduction_children, and the constructor takes the plan. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- tests/strategies/test_reduction.py | 36 ++------------- tilings/strategies/cell_reduction.py | 18 +++----- tilings/strategies/reduction.py | 68 ++++++++-------------------- 4 files changed, 32 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d1ca6a..b10dfb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. out, and the rules generate and sample their objects. The factory's `region` says which sets it tries: `"cell"` (single cells, the cell reduction), `"row"`, `"column"`, `"line"` (every set of cells in one row or one column) and `"any"` - (every set, of at most `max_cells` cells if given). A strategy with - `minimal=False` keeps and tracks every cell of the set. + (every set, of at most `max_cells` cells if given). ### Changed - `CellReductionFactory` and `CellReductionStrategy` are now the reduction of diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 85116a5c..8e020ba5 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -230,20 +230,6 @@ def test_children(two_rows): own_tiling.obstructions ) assert own_tiling.assumptions == (TrackingAssumption.from_cells([(2, 1)]),) - # not minimal, the reduced tiling keeps every cell of the set, each tracked - assert reduction_plan(two_rows, cells, minimal=False) == ( - (((1, 0),), ((2, 0),), ((3, 1),)), - (), - ) - reduced_tiling, own_tiling = reduction_children( - two_rows, cells, False, minimal=False - ) - assert GriddedPerm(Perm((0, 1)), ((1, 0), (2, 0))) in reduced_tiling.obstructions - for cell in cells: - assert GriddedPerm(Perm((0, 1)), (cell, cell)) in reduced_tiling.obstructions - assert TrackingAssumption.from_cells([cell]) in reduced_tiling.assumptions - assert reduced_tiling.dimensions == two_rows.dimensions - assert len(own_tiling.assumptions) == 3 @pytest.mark.parametrize( @@ -258,11 +244,10 @@ def test_children(two_rows): ], ) @pytest.mark.parametrize("increasing", [True, False]) -@pytest.mark.parametrize("minimal", [True, False]) -def test_sanity_check(request, fixture, cells, increasing, minimal): +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, minimal=minimal)(tiling) + rule = ReductionStrategy(cells, increasing)(tiling) for n in range(6): assert rule.sanity_check(n) @@ -276,13 +261,12 @@ def test_sanity_check(request, fixture, cells, increasing, minimal): ], ) @pytest.mark.parametrize("increasing", [True, False]) -@pytest.mark.parametrize("minimal", [True, False]) -def test_maps(request, fixture, cells, increasing, minimal): +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, minimal=minimal) + strategy = ReductionStrategy(cells, increasing) reduced_tiling, own_tiling = strategy.decomposition_function(tiling) groups, _ = strategy.plan(tiling) _, own_to_parent = compression(strategy.cells) @@ -435,18 +419,6 @@ def test_encoding(): assert "region" not in CellReductionFactory(True).to_jsonable() -def test_encoded_before(): - """A strategy encoded before the choice keeps every cell, each tracked.""" - for strategy in ( - ReductionStrategy([(1, 0), (2, 0), (3, 1)], False), - CellReductionStrategy((0, 0), True), - ): - assert strategy.minimal - encoded = strategy.to_jsonable() - encoded.pop("minimal") - assert not Strategy.from_dict(encoded).minimal - - @pytest.mark.timeout(120) def test_specification(): """A specification of Av(1234, 1342, 2413) that reduces a set of three cells.""" diff --git a/tilings/strategies/cell_reduction.py b/tilings/strategies/cell_reduction.py index 60cc9561..574eb04d 100644 --- a/tilings/strategies/cell_reduction.py +++ b/tilings/strategies/cell_reduction.py @@ -24,10 +24,9 @@ def __init__( cell: Cell, increasing: bool, tracked: bool = True, - minimal: bool = True, ): self.cell = cast(Cell, tuple(cell)) - super().__init__((self.cell,), increasing, tracked, minimal) + super().__init__((self.cell,), increasing, tracked) def to_jsonable(self) -> dict: d: dict = super().to_jsonable() @@ -40,28 +39,25 @@ def from_dict(cls, d: dict) -> "CellReductionStrategy": cell = cast(Cell, tuple(d.pop("cell"))) tracked = d.pop("tracked") increasing = d.pop("increasing") - # encoded before the choice existed: the cell kept and tracked as it was - minimal = d.pop("minimal", False) assert not d - return cls(cell, increasing, tracked, minimal) + return cls(cell, increasing, tracked) def __repr__(self) -> str: return ( f"{self.__class__.__name__}(cell={self.cell!r}, " - f"increasing={self.increasing!r}, tracked={self.tracked!r}, " - f"minimal={self.minimal!r})" + f"increasing={self.increasing!r}, tracked={self.tracked!r})" ) class CellReductionFactory(ReductionFactory): """The reductions of single cells: `ReductionFactory` with the region "cell".""" - def __init__(self, tracked: bool, minimal: bool = True): - super().__init__(tracked, "cell", None, minimal) + def __init__(self, tracked: bool): + super().__init__(tracked, "cell") def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: (cell,) = cells - return CellReductionStrategy(cell, increasing, self.tracked, self.minimal) + return CellReductionStrategy(cell, increasing, self.tracked) def __repr__(self) -> str: return self.__class__.__name__ + f"({self.tracked})" @@ -74,4 +70,4 @@ def to_jsonable(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "CellReductionFactory": - return cls(d["tracked"], d.get("minimal", True)) + return cls(d["tracked"]) diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index 828b584e..b01faa9d 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -51,9 +51,7 @@ 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`). A strategy that is -not minimal, as are those of specifications encoded before the choice existed, -keeps every cell of the set in the reduced tiling, each tracked. +is the cell reduction (`tilings.strategies.cell_reduction`). """ import random @@ -408,15 +406,12 @@ def _runs( @lru_cache(maxsize=1024) def _layout( - tiling: Tiling, cells: Tuple[Cell, ...], minimal: bool + tiling: Tiling, cells: Tuple[Cell, ...] ) -> Tuple[Plan, FrozenSet[int], FrozenSet[int]]: """The plan of the reduction (see reduction_plan), and the columns and the rows with cells outside the set.""" cell_set = frozenset(cells) view = _view(tiling, cell_set) - if not minimal: - plan: Plan = tuple((cell,) for cell in cells), () - return plan, view.outside_columns, view.outside_rows left_out = _left_out(cells, cell_set, 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 @@ -427,12 +422,11 @@ def _layout( return (tuple(sorted(groups)), left_out), view.outside_columns, view.outside_rows -def reduction_plan(tiling: Tiling, cells: Iterable[Cell], minimal: bool = True) -> Plan: +def reduction_plan(tiling: Tiling, cells: Iterable[Cell]) -> Plan: """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. Not minimal: every cell - a group of its own, and none left out.""" - return _layout(tiling, tuple(sorted(set(cells))), minimal)[0] + rest does not see, which the reduced tiling leaves out.""" + return _layout(tiling, tuple(sorted(set(cells))))[0] def _own_is_tracked(groups: Groups, left_out: Tuple[Cell, ...]) -> bool: @@ -487,7 +481,6 @@ def reduction_children( cells: Iterable[Cell], increasing: bool, tracked: bool = True, - minimal: 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 @@ -495,7 +488,7 @@ def reduction_children( tracked; and the tiling of the set's own obstructions and requirements, each group tracked if there are two or more, or cells left out.""" cell_set = frozenset(cells) - plan = reduction_plan(tiling, cell_set, minimal) + plan = reduction_plan(tiling, cell_set) return ( _reduced_tiling(tiling, cell_set, plan, increasing, tracked), _own_tiling(tiling, cell_set, plan), @@ -619,12 +612,10 @@ def __init__( parent: Tiling, children: Tuple[Tiling, ...], cells: Iterable[Cell], - plan: Optional[Plan] = None, + plan: Plan, ): reduced_tiling, own_tiling = children self.cells = tuple(sorted(set(cells))) - if plan is None: - plan = tuple((cell,) for cell in self.cells), () self.groups, left_out = plan self.leaves_out = bool(left_out) reduced_parameters = reduced_tiling.extra_parameters @@ -846,22 +837,20 @@ def __str__(self) -> str: 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 cells' - own tiling beside it. Minimal, 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.""" + 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, - minimal: bool = True, ): self.cells: Tuple[Cell, ...] = tuple( sorted(set(cast(Cell, tuple(cell)) for cell in cells)) ) self.increasing = increasing self.tracked = tracked - self.minimal = minimal super().__init__() def can_be_equivalent(self) -> bool: @@ -881,7 +870,7 @@ def shifts( def plan(self, comb_class: Tiling) -> Plan: """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, self.minimal) + 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): @@ -889,9 +878,7 @@ def decomposition_function(self, comb_class: Tiling) -> Tuple[Tiling, Tiling]: raise StrategyDoesNotApply( f"The cells {self.cells} are not reducible to {direction}" ) - return reduction_children( - comb_class, self.cells, self.increasing, self.tracked, self.minimal - ) + return reduction_children(comb_class, self.cells, self.increasing, self.tracked) def constructor( self, comb_class: Tiling, children: Optional[Tuple[Tiling, ...]] = None @@ -940,9 +927,7 @@ def reduced_gp(self, comb_class: Tiling, gp: GriddedPerm) -> GriddedPerm: """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.""" - (groups, _), outside_columns, outside_rows = _layout( - comb_class, self.cells, self.minimal - ) + (groups, _), outside_columns, outside_rows = _layout(comb_class, self.cells) cell_set = frozenset(self.cells) keeper = {cell: group[0] for group in groups for cell in group} kept = [group[0] for group in groups] @@ -975,7 +960,7 @@ def parent_gp( """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.""" - _, outside_columns, outside_rows = _layout(comb_class, self.cells, self.minimal) + _, outside_columns, outside_rows = _layout(comb_class, self.cells) cell_set = frozenset(self.cells) reduced_columns, reduced_rows, reduced_ranks = _words(reduced_gp) own_columns, own_rows, own_ranks = _words(own_gp) @@ -1032,7 +1017,6 @@ def to_jsonable(self) -> dict: d["cells"] = self.cells d["increasing"] = self.increasing d["tracked"] = self.tracked - d["minimal"] = self.minimal return d @classmethod @@ -1040,16 +1024,13 @@ 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") - # encoded before the choice existed: every cell kept and tracked - minimal = d.pop("minimal", False) assert not d - return cls(cells, increasing, tracked, minimal) + 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}, " - f"minimal={self.minimal!r})" + f"increasing={self.increasing!r}, tracked={self.tracked!r})" ) @staticmethod @@ -1077,7 +1058,7 @@ class ReductionFactory(StrategyFactory[Tiling]): 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, minimal, the rest of the tiling sees + 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). So the reduced tiling is never larger than the tiling, and smaller at some size. """ @@ -1087,14 +1068,12 @@ def __init__( tracked: bool = True, region: str = "cell", max_cells: Optional[int] = None, - minimal: bool = True, ): 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 - self.minimal = minimal super().__init__() def candidate_sets(self, tiling: Tiling) -> Iterator[Tuple[Cell, ...]]: @@ -1158,19 +1137,18 @@ def __call__(self, comb_class: Tiling) -> Iterator[ReductionStrategy]: ] if not directions or is_rigid(comb_class, cells): continue - if self.minimal and not reduction_plan(comb_class, cells)[0]: + if not reduction_plan(comb_class, cells)[0]: continue for increasing in directions: yield self.strategy(cells, increasing) def strategy(self, cells: Tuple[Cell, ...], increasing: bool) -> ReductionStrategy: - return ReductionStrategy(cells, increasing, self.tracked, self.minimal) + 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}, " - f"minimal={self.minimal!r})" + f"region={self.region!r}, max_cells={self.max_cells!r})" ) def __str__(self) -> str: @@ -1186,14 +1164,8 @@ def to_jsonable(self) -> dict: d["tracked"] = self.tracked d["region"] = self.region d["max_cells"] = self.max_cells - d["minimal"] = self.minimal return d @classmethod def from_dict(cls, d: dict) -> "ReductionFactory": - return cls( - d.get("tracked", True), - d.get("region", "cell"), - d.get("max_cells"), - d.get("minimal", True), - ) + return cls(d.get("tracked", True), d.get("region", "cell"), d.get("max_cells")) From 59690f7d6882812b3a9b4106b779c3b52ede89ff Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Thu, 22 Oct 2026 23:05:39 +0000 Subject: [PATCH 05/21] The reduction factory's cut region: the cells on one side of a line The region "cut" offers, for each line between two adjacent columns or two adjacent rows, the active cells on each side of it: left and right of it, below and above it. These are the sets a threshold reduction reduces, the cells left of a placed point, as in Av(2143)'s tree, where every reduction is of the cells left of a column (five or seven of them), which "any" reaches only with a bound of seven cells and very many more sets. At most twice the number of columns and rows; max_cells bounds them as it bounds "any". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++-- tests/strategies/test_reduction.py | 45 ++++++++++++++++++++++++++++++ tilings/strategies/reduction.py | 36 ++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b10dfb15..31e238fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. the points in those groups, at 1, and the product in the size of the cells left out, and the rules generate and sample their objects. The factory's `region` says which sets it tries: `"cell"` (single cells, the cell reduction), `"row"`, - `"column"`, `"line"` (every set of cells in one row or one column) and `"any"` - (every set, of at most `max_cells` cells if given). + `"column"`, `"line"` (every set of cells in one row or one column), `"cut"` + (the cells on one side of a line between two columns or two rows, as the cells + left of a placed point) and `"any"` (every set); with `max_cells`, the sets of + `"cut"` and `"any"` have at most that many cells. ### Changed - `CellReductionFactory` and `CellReductionStrategy` are now the reduction of diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 8e020ba5..156dd430 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -213,6 +213,50 @@ def test_regions(two_rows, apart, row_and_column, one_cell): ReductionFactory(True, "diagonal") +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 @@ -409,6 +453,7 @@ def test_encoding(): ReductionStrategy([(0, 0), (1, 0)], True, True), CellReductionStrategy((2, 1), False, False), ReductionFactory(True, "line"), + ReductionFactory(True, "cut", 6), ReductionFactory(False, "any", 3), CellReductionFactory(True), ): diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index b01faa9d..2616e16a 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -98,7 +98,7 @@ Groups = Tuple[Tuple[Cell, ...], ...] Plan = Tuple[Groups, Tuple[Cell, ...]] Words = Dict[int, List[Cell]] -REGIONS = ("cell", "row", "column", "line", "any") +REGIONS = ("cell", "row", "column", "line", "cut", "any") def _lies_in(gps: Iterable[GriddedPerm], cells: FrozenSet[Cell]) -> bool: @@ -1053,7 +1053,13 @@ class ReductionFactory(StrategyFactory[Tiling]): - "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; - - "any": every set of cells, of at most max_cells cells if that is given. + - "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. 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 @@ -1102,6 +1108,12 @@ def candidate_sets(self, tiling: Tiling) -> Iterator[Tuple[Cell, ...]]: for size in range(1, len(line) + 1) for subset in combinations(line, size) ) + elif self.region == "cut": + candidates = ( + side + for side in self.cuts(tiling, active_cells) + if self.max_cells is None or len(side) <= self.max_cells + ) else: largest = len(active_cells) if self.max_cells is not None: @@ -1116,6 +1128,19 @@ def candidate_sets(self, tiling: Tiling) -> Iterator[Tuple[Cell, ...]]: seen.add(cells) yield cells + @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: cell_basis = tiling.cell_basis()[cell][0] @@ -1153,6 +1178,13 @@ def __repr__(self) -> str: 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: From bc268cbf621581d710dc8c92c1f708303a2c0e80 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 01:04:39 +0000 Subject: [PATCH 06/21] The reduction refuses a set that a tracked pattern of two or more points touches A tracking assumption on a gridded permutation of two or more points counts the occurrences of a pattern, and the sorting of the set's share keeps the number of points in each cell but not those occurrences: reducing a cell of such a pattern gave wrong counts or raised ValueError (the review of the pull request, round 1, finding 1). The condition now refuses such a set, as it refuses one with a component assumption, and the tracked cells the plan keeps apart are every cell of the assumptions' gridded permutations. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 25 +++++++++++++++++++++++++ tilings/strategies/reduction.py | 21 ++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 156dd430..9c194678 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -4,6 +4,7 @@ import pytest from comb_spec_searcher import CombinatorialSpecification, 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 @@ -426,6 +427,30 @@ def test_unrestricted_cell(): assert list(CellReductionFactory(True)(tiling)) == [] +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: a set with a cell of + one is not reduced (the review of the pull request, round 1, finding 1).""" + 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_not_tracked(two_rows): strategy = ReductionStrategy([(1, 0), (2, 0), (3, 1)], True, tracked=False) reduced_tiling, _ = strategy.decomposition_function(two_rows) diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index 2616e16a..493e7018 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -190,16 +190,20 @@ def _crossing_is_allowed( def _qualifies(tiling: Tiling, cell_set: FrozenSet[Cell]) -> bool: - """Return True if the set is of active cells, with no component assumption - on them, and each of its cells has an obstruction of its own: the tiling of the - set's own obstructions must have every cell of the set (an unrestricted cell - has none).""" + """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: the sorting + keeps the number of points in each cell, but not the occurrences of a pattern), + and each of its cells has an obstruction of its own: the tiling of the set's + own obstructions must have every cell of the set (an unrestricted cell has + none).""" if not cell_set or not cell_set <= tiling.active_cells: return False if any( - isinstance(assumption, ComponentAssumption) - and any(gp.pos[0] in cell_set for gp in assumption.gps) + (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 = { @@ -309,7 +313,10 @@ def _view(tiling: Tiling, cell_set: FrozenSet[Cell]) -> _View: tuple(ob for ob in tiling.obstructions if _crosses(ob, cell_set)), tuple(req for req in tiling.requirements if not _lies_in(req, cell_set)), frozenset( - gp.pos[0] for assumption in tiling.assumptions for gp in assumption.gps + cell + for assumption in tiling.assumptions + for gridded_perm in assumption.gps + for cell in gridded_perm.pos ), ) From 1b7f3c5f42c5dbf17c76040c982439bf7655ce26 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 01:09:26 +0000 Subject: [PATCH 07/21] Tests for the reduction's grouping, monotone obstructions and left-out cells Mutation testing of reduction.py (the review of the pull request, round 1, finding 2) left mutants of monotone_obstructions, _seen_alike, _moved, _left_out and _runs alive under the tests. New tests: the exact monotone obstructions of a set with two cells in a row and two in a column, in each direction; two cells of a row the rest sees alike (one group) and two it does not (two groups); a cell kept only because an obstruction touches it, beside a cell left out; a requirement list with a point in the set and one outside it; a direction a set is not reducible in; the maps for the new fixtures and the threshold; and a seeded sanity check of every reduction the factory offers on 30 random tilings, to size 4 (7 seconds). Each of the eleven mutants the review named now fails a test. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 206 ++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 5 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 9c194678..8f51845c 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -1,4 +1,5 @@ import json +import random from collections import defaultdict import pytest @@ -20,6 +21,7 @@ compression, is_reducible, is_rigid, + monotone_obstructions, reduction_children, reduction_plan, ) @@ -150,6 +152,65 @@ def threshold(): 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 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 offered_sets(tiling, region): return sorted( {strategy.cells for strategy in ReductionFactory(True, region)(tiling)} @@ -298,14 +359,24 @@ def test_sanity_check(request, fixture, cells, increasing): @pytest.mark.parametrize( - "fixture, cells", + "fixture, cells, increasing", [ - ("two_rows", [(1, 0), (2, 0), (3, 1)]), - ("apart", [(1, 0), (2, 2)]), - ("row_and_column", [(0, 0), (1, 0), (2, 0)]), + (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), + ("with_requirement_list", [(0, 0), (1, 0)], True), + ("threshold", THRESHOLD_CELLS, True), ], ) -@pytest.mark.parametrize("increasing", [True, False]) 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, @@ -349,6 +420,131 @@ def own_group_sizes(own_gp): 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_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): + """A requirement list with a point in the set and a point outside it.""" + cells = [(0, 0), (1, 0)] + assert is_reducible(with_requirement_list, cells, True) + rule = ReductionStrategy(cells, True)(with_requirement_list) + assert all(rule.sanity_check(n) for n in range(6)) + + +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(rng, cells, size): + """A gridded permutation with points in random cells, read left to right by + column and bottom to top by row.""" + positions = sorted(rng.choice(cells) for _ in range(size)) + by_value = sorted( + range(size), key=lambda index: (positions[index][1], rng.random()) + ) + values = [0] * size + for value, index in enumerate(by_value): + values[index] = value + return GriddedPerm(Perm(values), tuple(positions)) + + +def random_tiling(rng): + width, height = rng.randint(1, 3), rng.randint(1, 3) + cells = [(column, row) for column in range(width) for row in range(height)] + active = rng.sample(cells, rng.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 rng.sample(patterns, rng.randint(1, 2)): + obstructions.append(GriddedPerm.single_cell(pattern, cell)) + for _ in range(rng.randint(1, 4)): + obstructions.append(random_gridded_perm(rng, active, rng.randint(2, 3))) + requirements = ( + [[GriddedPerm.point_perm(rng.choice(active))]] if rng.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.""" + rng = random.Random(2026) + tilings = [] + while len(tilings) < 30: + tiling = random_tiling(rng) + 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 From 00af290de278fc08d4aa8cbb7cce292710905360 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 01:15:03 +0000 Subject: [PATCH 08/21] Split the reduction: the condition, plan, children and maps' words to tilings/algorithms/reduction.py The module had 1203 lines, over Tilings' max-module-lines of 1000, with a new too-many-lines disable (the review of the pull request, round 1, finding 3). As fusion has its algorithm in tilings/algorithms, the reduction's pure functions move there: the condition (is_reducible, is_rigid, monotone_obstructions), the plan (reduction_plan, with ReductionPlan, a named tuple of the groups and the cells left out), the children (reduction_children, own_is_tracked) and the words the maps are built from, with the maps themselves as reduce_gridded_perm and combine_gridded_perms; it takes Tiling for type checking only, as the other algorithm modules do, and builds tilings with the tiling's class. tilings/strategies/reduction.py keeps the argument, the constructor, the strategy and the factory, 682 lines, and drops the disable. The strategy's new public methods reduced_gp and parent_gp are now reduce_gridded_perm(comb_class, gridded_perm) and combine_gridded_perms( comb_class, reduced_gridded_perm, own_gridded_perm), with no abbreviation (finding 4). _runs and _canonical_words take their direction as a keyword, and _layout's cache says why it is there. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 15 +- tilings/algorithms/reduction.py | 646 +++++++++++++++++++++++++++++ tilings/strategies/reduction.py | 618 ++------------------------- 3 files changed, 698 insertions(+), 581 deletions(-) create mode 100644 tilings/algorithms/reduction.py diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 8f51845c..889da2c5 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -10,14 +10,7 @@ from permuta.misc import DIR_NORTH, DIR_SOUTH from tilings import GriddedPerm, Tiling from tilings import strategies as strat -from tilings.assumptions import TrackingAssumption -from tilings.strategies.cell_reduction import ( - CellReductionFactory, - CellReductionStrategy, -) -from tilings.strategies.reduction import ( - ReductionFactory, - ReductionStrategy, +from tilings.algorithms.reduction import ( compression, is_reducible, is_rigid, @@ -25,6 +18,12 @@ reduction_children, reduction_plan, ) +from tilings.assumptions import TrackingAssumption +from tilings.strategies.cell_reduction import ( + CellReductionFactory, + CellReductionStrategy, +) +from tilings.strategies.reduction import ReductionFactory, ReductionStrategy from tilings.strategies.requirement_placement import RequirementPlacementStrategy from tilings.strategy_pack import TileScopePack from tilings.tilescope import TileScope diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py new file mode 100644 index 00000000..4a437511 --- /dev/null +++ b/tilings/algorithms/reduction.py @@ -0,0 +1,646 @@ +"""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. + +`tilings.strategies.reduction` has the strategy, its constructor and its factory, +and the argument why the reduction holds. +""" + +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", + "compression", + "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(gps: 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 gp in gps for cell in gp.pos) + + +def _crosses(gp: 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 gp.pos if cell in cells) + return 0 < points_inside < len(gp) + + +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)) + patt = 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(patt, (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( + gp: 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 gp.pos if cell in cells] + if len(cells_inside) != 1: + return False + column, row = cells_inside[0] + cells_outside = [cell for cell in gp.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: the sorting + keeps the number of points in each cell, but not the occurrences of a pattern), + and each of its cells has an obstruction of its own: the tiling of the set's + own obstructions must have every cell of the set (an unrestricted cell has + none).""" + 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 _seen_gps(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> Iterator[GriddedPerm]: + """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.""" + for obstruction in tiling.obstructions: + if _crosses(obstruction, cell_set): + yield obstruction + for requirement in tiling.requirements: + if not _lies_in(requirement, cell_set): + for gp in requirement: + if any(cell in cell_set for cell in gp.pos): + yield gp + + +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.""" + if increasing is None: + return is_reducible(tiling, cells, True) or is_reducible(tiling, cells, False) + cell_set = frozenset(cells) + if not _qualifies(tiling, cell_set): + return False + ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) + return all( + _crossing_is_allowed(gp, cell_set, ordered_columns, ordered_rows) + for gp in _seen_gps(tiling, cell_set) + ) + + +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(gp: GriddedPerm) -> bool: + return gp.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 compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cell]]: + """Return the maps from the cells of the set to the cells of its own tiling, + which keeps the columns and rows of the set only, and back.""" + cell_list = sorted(set(cells)) + column_index = { + column: index + for index, column in enumerate(sorted({cell[0] for cell in cell_list})) + } + row_index = { + row: index for index, row in enumerate(sorted({cell[1] for cell in cell_list})) + } + forward = {cell: (column_index[cell[0]], row_index[cell[1]]) for cell in cell_list} + backward = {own_cell: cell for cell, own_cell in forward.items()} + return forward, backward + + +class _View(NamedTuple): + """What the rest of the tiling has to do with a set of cells: the columns and + the rows with cells outside the set, the obstructions and the requirement lists + with points outside it, and the tracked cells.""" + + outside_columns: FrozenSet[int] + outside_rows: FrozenSet[int] + crossing_obstructions: Tuple[GriddedPerm, ...] + crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] + tracked: FrozenSet[Cell] + + +def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: + outside = tiling.active_cells - cell_set + return _View( + frozenset(cell[0] for cell in outside), + frozenset(cell[1] for cell in outside), + tuple(ob for ob in tiling.obstructions if _crosses(ob, cell_set)), + tuple(req for req in tiling.requirements if not _lies_in(req, cell_set)), + frozenset( + cell + for assumption in tiling.assumptions + for gridded_perm in assumption.gps + for cell in gridded_perm.pos + ), + ) + + +def _moved(gp: GriddedPerm, source: Cell, target: Cell) -> GriddedPerm: + """The gridded permutation with its point in the source cell in the target + cell instead.""" + return GriddedPerm( + gp.patt, tuple(target if cell == source else cell for cell in gp.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 gp in requirement: + if source in gp.pos and _moved(gp, source, target) not in requirement: + return False + return True + + +def _left_out( + cells: Tuple[Cell, ...], cell_set: FrozenSet[Cell], 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.""" + touched = { + cell for gp in view.crossing_obstructions for cell in gp.pos if cell in cell_set + } + touched.update( + cell + for requirement in view.crossing_requirements + for gp in requirement + for cell in gp.pos + if cell in cell_set + ) + return tuple( + cell + for cell in cells + if cell[0] not in view.outside_columns + and cell[1] not in view.outside_rows + and cell not in 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.""" + cell_set = frozenset(cells) + view = _view(tiling, cell_set) + left_out = _left_out(cells, cell_set, 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__( + [ob for ob in tiling.obstructions if not _lies_in([ob], cell_set)] + + monotone_obstructions(kept, increasing) + + [GriddedPerm.point_perm(cell) for cell in emptied], + [req for req in tiling.requirements if not _lies_in(req, 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": + 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__( + [ob for ob in tiling.obstructions if _lies_in([ob], cell_set)] + + other_cells_emptied, + [req for req in tiling.requirements if _lies_in(req, 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 tiling of the set's own obstructions and requirements, 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(gp: 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 gp.pos: + column_words[cell[0]].append(cell) + row_words: DefaultDict[int, List[Cell]] = defaultdict(list) + for index in sorted(range(len(gp)), key=lambda index: gp.patt[index]): + row_words[gp.pos[index][1]].append(gp.pos[index]) + values_in_cell: DefaultDict[Cell, List[int]] = defaultdict(list) + for value, cell in zip(gp.patt, gp.pos): + values_in_cell[cell].append(value) + ranks = { + cell: [sorted(values).index(value) for value in 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") + patt = [0] * size + pos: 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]): + patt[position] = values[cell][rank] + pos[position] = cell + return GriddedPerm(Perm(patt), tuple(pos)) + + +def _monotone_ranks(size: int, increasing: bool) -> List[int]: + return list(range(size)) if increasing else list(range(size - 1, -1, -1)) + + +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/reduction.py b/tilings/strategies/reduction.py index 493e7018..8832ede3 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-lines """The reduction strategy, which makes a set of cells monotone. A set of active cells of a tiling is reducible, in a direction (increasing or @@ -56,20 +55,15 @@ import random from collections import Counter, defaultdict -from functools import lru_cache from itertools import combinations, product -from operator import itemgetter from typing import ( Callable, DefaultDict, Dict, - FrozenSet, Iterable, Iterator, List, - NamedTuple, Optional, - Set, Tuple, cast, ) @@ -88,523 +82,26 @@ SubTerms, Terms, ) -from permuta import Perm from tilings import GriddedPerm, Tiling -from tilings.assumptions import ComponentAssumption, TrackingAssumption +from tilings.algorithms.reduction import ( + Cell, + ReductionPlan, + combine_gridded_perms, + compression, + is_reducible, + is_rigid, + own_is_tracked, + reduce_gridded_perm, + reduction_children, + reduction_plan, +) +from tilings.assumptions import TrackingAssumption __all__ = ("ReductionConstructor", "ReductionStrategy", "ReductionFactory") -Cell = Tuple[int, int] -Groups = Tuple[Tuple[Cell, ...], ...] -Plan = Tuple[Groups, Tuple[Cell, ...]] -Words = Dict[int, List[Cell]] REGIONS = ("cell", "row", "column", "line", "cut", "any") -def _lies_in(gps: 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 gp in gps for cell in gp.pos) - - -def _crosses(gp: 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 gp.pos if cell in cells) - return 0 < points_inside < len(gp) - - -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)) - patt = 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(patt, (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( - gp: 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 gp.pos if cell in cells] - if len(cells_inside) != 1: - return False - column, row = cells_inside[0] - cells_outside = [cell for cell in gp.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: the sorting - keeps the number of points in each cell, but not the occurrences of a pattern), - and each of its cells has an obstruction of its own: the tiling of the set's - own obstructions must have every cell of the set (an unrestricted cell has - none).""" - 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 _seen_gps(tiling: Tiling, cell_set: FrozenSet[Cell]) -> Iterator[GriddedPerm]: - """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.""" - for obstruction in tiling.obstructions: - if _crosses(obstruction, cell_set): - yield obstruction - for requirement in tiling.requirements: - if not _lies_in(requirement, cell_set): - for gp in requirement: - if any(cell in cell_set for cell in gp.pos): - yield gp - - -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.""" - if increasing is None: - return is_reducible(tiling, cells, True) or is_reducible(tiling, cells, False) - cell_set = frozenset(cells) - if not _qualifies(tiling, cell_set): - return False - ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) - return all( - _crossing_is_allowed(gp, cell_set, ordered_columns, ordered_rows) - for gp in _seen_gps(tiling, cell_set) - ) - - -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(gp: GriddedPerm) -> bool: - return gp.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 compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cell]]: - """Return the maps from the cells of the set to the cells of its own tiling, - which keeps the columns and rows of the set only, and back.""" - cell_list = sorted(set(cells)) - column_index = { - column: index - for index, column in enumerate(sorted({cell[0] for cell in cell_list})) - } - row_index = { - row: index for index, row in enumerate(sorted({cell[1] for cell in cell_list})) - } - forward = {cell: (column_index[cell[0]], row_index[cell[1]]) for cell in cell_list} - backward = {own_cell: cell for cell, own_cell in forward.items()} - return forward, backward - - -class _View(NamedTuple): - """What the rest of the tiling has to do with a set of cells: the columns and - the rows with cells outside the set, the obstructions and the requirement lists - with points outside it, and the tracked cells.""" - - outside_columns: FrozenSet[int] - outside_rows: FrozenSet[int] - crossing_obstructions: Tuple[GriddedPerm, ...] - crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] - tracked: FrozenSet[Cell] - - -def _view(tiling: Tiling, cell_set: FrozenSet[Cell]) -> _View: - outside = tiling.active_cells - cell_set - return _View( - frozenset(cell[0] for cell in outside), - frozenset(cell[1] for cell in outside), - tuple(ob for ob in tiling.obstructions if _crosses(ob, cell_set)), - tuple(req for req in tiling.requirements if not _lies_in(req, cell_set)), - frozenset( - cell - for assumption in tiling.assumptions - for gridded_perm in assumption.gps - for cell in gridded_perm.pos - ), - ) - - -def _moved(gp: GriddedPerm, source: Cell, target: Cell) -> GriddedPerm: - """The gridded permutation with its point in the source cell in the target - cell instead.""" - return GriddedPerm( - gp.patt, tuple(target if cell == source else cell for cell in gp.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 gp in requirement: - if source in gp.pos and _moved(gp, source, target) not in requirement: - return False - return True - - -def _left_out( - cells: Tuple[Cell, ...], cell_set: FrozenSet[Cell], 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.""" - touched = { - cell for gp in view.crossing_obstructions for cell in gp.pos if cell in cell_set - } - touched.update( - cell - for requirement in view.crossing_requirements - for gp in requirement - for cell in gp.pos - if cell in cell_set - ) - return tuple( - cell - for cell in cells - if cell[0] not in view.outside_columns - and cell[1] not in view.outside_rows - and cell not in touched - and cell not in view.tracked - ) - - -def _runs( - tiling: Tiling, seen: List[Cell], along_row: bool, view: _View -) -> 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 - - -@lru_cache(maxsize=1024) -def _layout( - tiling: Tiling, cells: Tuple[Cell, ...] -) -> Tuple[Plan, FrozenSet[int], FrozenSet[int]]: - """The plan of the reduction (see reduction_plan), and the columns and the - rows with cells outside the set.""" - cell_set = frozenset(cells) - view = _view(tiling, cell_set) - left_out = _left_out(cells, cell_set, 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, True, view) + _runs(tiling, seen, False, view) - grouped = {cell for run in runs for cell in run} - groups = runs + [(cell,) for cell in seen if cell not in grouped] - return (tuple(sorted(groups)), left_out), view.outside_columns, view.outside_rows - - -def reduction_plan(tiling: Tiling, cells: Iterable[Cell]) -> Plan: - """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))))[0] - - -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: Plan, - 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( - [ob for ob in tiling.obstructions if not _lies_in([ob], cell_set)] - + monotone_obstructions(kept, increasing) - + [GriddedPerm.point_perm(cell) for cell in emptied], - [req for req in tiling.requirements if not _lies_in(req, 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: Plan) -> Tiling: - 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( - [ob for ob in tiling.obstructions if _lies_in([ob], cell_set)] - + other_cells_emptied, - [req for req in tiling.requirements if _lies_in(req, 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 tiling of the set's own obstructions and requirements, 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(gp: 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 gp.pos: - column_words[cell[0]].append(cell) - row_words: DefaultDict[int, List[Cell]] = defaultdict(list) - for index in sorted(range(len(gp)), key=lambda index: gp.patt[index]): - row_words[gp.pos[index][1]].append(gp.pos[index]) - values_in_cell: DefaultDict[Cell, List[int]] = defaultdict(list) - for value, cell in zip(gp.patt, gp.pos): - values_in_cell[cell].append(value) - ranks = { - cell: [sorted(values).index(value) for value in 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") - patt = [0] * size - pos: 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]): - patt[position] = values[cell][rank] - pos[position] = cell - return GriddedPerm(Perm(patt), tuple(pos)) - - -def _monotone_ranks(size: int, increasing: bool) -> List[int]: - return list(range(size)) if increasing else list(range(size - 1, -1, -1)) - - -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) - } - - class ReductionConstructor(Constructor[Tiling, GriddedPerm]): """The constructor of a reduction: the Hadamard product of the reduced tiling and the tiling of the cells' own obstructions in the variables that count the @@ -619,7 +116,7 @@ def __init__( parent: Tiling, children: Tuple[Tiling, ...], cells: Iterable[Cell], - plan: Plan, + plan: ReductionPlan, ): reduced_tiling, own_tiling = children self.cells = tuple(sorted(set(cells))) @@ -649,7 +146,7 @@ def reduced_parameter(assumption: TrackingAssumption) -> str: reduced_parameters.index(self.parent_to_reduced[parent_parameter]) for parent_parameter in parent.extra_parameters ) - if not _own_is_tracked(self.groups, left_out): + if not own_is_tracked(self.groups, left_out): self.own_group_parameters: Optional[Tuple[str, ...]] = None self.own_group_indices: Tuple[int, ...] = () else: @@ -874,7 +371,7 @@ def shifts( ) -> Tuple[int, ...]: return (0, 0) - def plan(self, comb_class: Tiling) -> Plan: + 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) @@ -930,57 +427,26 @@ def formal_step(self) -> str: noun = "cell" if len(self.cells) == 1 else "cells" return f"reducing {noun} {cells} to {direction}" - def reduced_gp(self, comb_class: Tiling, gp: GriddedPerm) -> GriddedPerm: + 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: the share of the set canonical, each group's points in - its first cell, and the points of the cells left out removed.""" - (groups, _), outside_columns, outside_rows = _layout(comb_class, self.cells) - cell_set = frozenset(self.cells) - keeper = {cell: group[0] for group in groups for cell in group} - kept = [group[0] for group in groups] - points_in_cell = Counter(keeper[cell] for cell in gp.pos if cell in keeper) - column_words, row_words, ranks = _words(gp) - 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], self.increasing) - return _from_words( - _combined_words( - column_words, - _canonical_words(kept, points_in_cell, False, self.increasing), - cell_set, - outside_columns, - ), - _combined_words( - row_words, - _canonical_words(kept, points_in_cell, True, self.increasing), - cell_set, - outside_rows, - ), - new_ranks, + the tiling's cells (see tilings.algorithms.reduction).""" + return reduce_gridded_perm( + comb_class, self.cells, gridded_perm, self.increasing ) - def parent_gp( - self, comb_class: Tiling, reduced_gp: GriddedPerm, own_gp: GriddedPerm + 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: the rest from the first, - the share of the set from the second.""" - _, outside_columns, outside_rows = _layout(comb_class, self.cells) - cell_set = frozenset(self.cells) - reduced_columns, reduced_rows, reduced_ranks = _words(reduced_gp) - own_columns, own_rows, own_ranks = _words(own_gp) - 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, outside_columns), - _combined_words(reduced_rows, own_rows, cell_set, outside_rows), - ranks, + 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( @@ -991,13 +457,16 @@ def backward_map( ) -> Iterator[GriddedPerm]: if children is None: children = self.decomposition_function(comb_class) - reduced_gp, own_gp = objs - assert reduced_gp is not None and own_gp is not None + reduced_gridded_perm, own_gridded_perm = objs + assert reduced_gridded_perm is not None and own_gridded_perm is not None _, backward = compression(self.cells) - yield self.parent_gp( + yield self.combine_gridded_perms( comb_class, - children[0].backward_map.map_gp(reduced_gp), - GriddedPerm(own_gp.patt, tuple(backward[cell] for cell in own_gp.pos)), + children[0].backward_map.map_gp(reduced_gridded_perm), + GriddedPerm( + own_gridded_perm.patt, + tuple(backward[cell] for cell in own_gridded_perm.pos), + ), ) def forward_map( @@ -1009,10 +478,13 @@ def forward_map( if children is None: children = self.decomposition_function(comb_class) forward, _ = compression(self.cells) - own_gp = obj.get_gridded_perm_in_cells(self.cells) + own_gridded_perm = obj.get_gridded_perm_in_cells(self.cells) return ( - children[0].forward_map.map_gp(self.reduced_gp(comb_class, obj)), - GriddedPerm(own_gp.patt, tuple(forward[cell] for cell in own_gp.pos)), + children[0].forward_map.map_gp(self.reduce_gridded_perm(comb_class, obj)), + GriddedPerm( + own_gridded_perm.patt, + tuple(forward[cell] for cell in own_gridded_perm.pos), + ), ) def to_jsonable(self) -> dict: From 7dafe8fba765bd430354c84f4e57ad116f86fe48 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 01:24:12 +0000 Subject: [PATCH 09/21] The reduction's readability and reuse, from the review's suggestions Following the review of the pull request (round 1, its suggestions): full names in the new code (gridded_perm, obstruction, requirement, pattern, and strategy in the tests); is_reducible takes the cells once, so an iterator serves both directions; the own tiling's own maps (its RowColMap) in place of compression, which goes; the strategy's extra parameters passed to the constructor; one generator of the matching pairs for get_terms and get_sub_objects; the sampling choices as a named tuple, enumerated as the group sizes of sum at most the size (with the sizes the parent fixes) rather than all of (n + 1)^groups; equiv comparing where the children's parameters are, not only how many; the constructor's symbols from the strategy's; the factory's region sets in helpers, its docstring on tracked=False and on what "any" costs, without the claim that the reduced tiling is never larger (a requirement inside the set goes to the own tiling, and then the reduced tiling can have the empty permutation); from_dict popping and checking its keys; ranks by Perm.to_standard. The eleven mutants the review named still fail a test. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 63 +++-- tilings/algorithms/reduction.py | 139 ++++++----- tilings/strategies/cell_reduction.py | 4 +- tilings/strategies/reduction.py | 344 ++++++++++++++++----------- 4 files changed, 324 insertions(+), 226 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 889da2c5..0b6a082d 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -11,7 +11,6 @@ from tilings import GriddedPerm, Tiling from tilings import strategies as strat from tilings.algorithms.reduction import ( - compression, is_reducible, is_rigid, monotone_obstructions, @@ -384,37 +383,54 @@ def test_maps(request, fixture, cells, increasing): strategy = ReductionStrategy(cells, increasing) reduced_tiling, own_tiling = strategy.decomposition_function(tiling) groups, _ = strategy.plan(tiling) - _, own_to_parent = compression(strategy.cells) - def reduced_group_sizes(reduced_gp): - parent_gp = reduced_tiling.backward_map.map_gp(reduced_gp) + 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_gp.pos if other == group[0]) for group in groups + sum(1 for other in parent_gridded_perm.pos if other == group[0]) + for group in groups ) - def own_group_sizes(own_gp): - own_cells = [own_to_parent[cell] for cell in own_gp.pos] + 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_gp in own_tiling.objects_of_size(n): - own_by_sizes[own_group_sizes(own_gp)].append(own_gp) + 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 gp in tiling.objects_of_size(n): - reduced_gp, own_gp = strategy.forward_map(tiling, gp) - assert reduced_gp in reduced_tiling and own_gp in own_tiling - assert next(strategy.backward_map(tiling, (reduced_gp, own_gp))) == gp - images.add((reduced_gp, own_gp)) + 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_gp, own_gp) + (reduced_gridded_perm, own_gridded_perm) for reduced_size in range(n + 1) - for reduced_gp in reduced_tiling.objects_of_size(reduced_size) - for own_gp in own_by_sizes[reduced_group_sizes(reduced_gp)] - if reduced_size + len(own_gp) - sum(reduced_group_sizes(reduced_gp)) == n + 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 @@ -658,9 +674,14 @@ 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(s, CellReductionStrategy) for s in cell_strategies) - assert sorted((s.cells, s.increasing) for s in cell_strategies) == sorted( - (s.cells, s.increasing) for s in ReductionFactory(True, "cell")(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): diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py index 4a437511..f1525e6f 100644 --- a/tilings/algorithms/reduction.py +++ b/tilings/algorithms/reduction.py @@ -33,7 +33,6 @@ __all__ = ( "combine_gridded_perms", - "compression", "is_reducible", "is_rigid", "monotone_obstructions", @@ -66,16 +65,18 @@ class _Layout(NamedTuple): outside_rows: FrozenSet[int] -def _lies_in(gps: Iterable[GriddedPerm], cells: FrozenSet[Cell]) -> bool: +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 gp in gps for cell in gp.pos) + return all( + cell in cells for gridded_perm in gridded_perms for cell in gridded_perm.pos + ) -def _crosses(gp: GriddedPerm, cells: FrozenSet[Cell]) -> bool: +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 gp.pos if cell in cells) - return 0 < points_inside < len(gp) + 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]: @@ -83,7 +84,7 @@ def monotone_obstructions(cells: Iterable[Cell], increasing: bool) -> List[Gridd cells of the set that share a row or a column, which make the share of the set canonical.""" cell_list = sorted(set(cells)) - patt = Perm((1, 0)) if increasing else Perm((0, 1)) + 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]: @@ -94,7 +95,7 @@ def monotone_obstructions(cells: Iterable[Cell], increasing: bool) -> List[Gridd continue if not increasing and left_cell[1] > right_cell[1]: continue - obstructions.append(GriddedPerm(patt, (left_cell, right_cell))) + obstructions.append(GriddedPerm(pattern, (left_cell, right_cell))) return obstructions @@ -128,7 +129,7 @@ def _ordered_lines( def _crossing_is_allowed( - gp: GriddedPerm, + gridded_perm: GriddedPerm, cells: FrozenSet[Cell], ordered_columns: Set[int], ordered_rows: Set[int], @@ -138,11 +139,11 @@ def _crossing_is_allowed( 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 gp.pos if cell in cells] + 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 gp.pos if cell not in cells] + 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: @@ -180,7 +181,9 @@ def _qualifies(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> bool: return cell_set <= cells_with_own_obstructions -def _seen_gps(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> Iterator[GriddedPerm]: +def _seen_gridded_perms( + tiling: "Tiling", cell_set: FrozenSet[Cell] +) -> Iterator[GriddedPerm]: """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.""" @@ -189,9 +192,9 @@ def _seen_gps(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> Iterator[GriddedPe yield obstruction for requirement in tiling.requirements: if not _lies_in(requirement, cell_set): - for gp in requirement: - if any(cell in cell_set for cell in gp.pos): - yield gp + for gridded_perm in requirement: + if any(cell in cell_set for cell in gridded_perm.pos): + yield gridded_perm def is_reducible( @@ -206,8 +209,8 @@ def is_reducible( return False ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) return all( - _crossing_is_allowed(gp, cell_set, ordered_columns, ordered_rows) - for gp in _seen_gps(tiling, cell_set) + _crossing_is_allowed(gridded_perm, cell_set, ordered_columns, ordered_rows) + for gridded_perm in _seen_gridded_perms(tiling, cell_set) ) @@ -219,8 +222,8 @@ def is_rigid(tiling: "Tiling", cells: Iterable[Cell]) -> bool: cell_list = sorted(set(cells)) obstructions = tiling.obstructions - def is_forbidden(gp: GriddedPerm) -> bool: - return gp.contains(*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: @@ -242,22 +245,6 @@ def is_forbidden(gp: GriddedPerm) -> bool: return True -def compression(cells: Iterable[Cell]) -> Tuple[Dict[Cell, Cell], Dict[Cell, Cell]]: - """Return the maps from the cells of the set to the cells of its own tiling, - which keeps the columns and rows of the set only, and back.""" - cell_list = sorted(set(cells)) - column_index = { - column: index - for index, column in enumerate(sorted({cell[0] for cell in cell_list})) - } - row_index = { - row: index for index, row in enumerate(sorted({cell[1] for cell in cell_list})) - } - forward = {cell: (column_index[cell[0]], row_index[cell[1]]) for cell in cell_list} - backward = {own_cell: cell for cell, own_cell in forward.items()} - return forward, backward - - class _View(NamedTuple): """What the rest of the tiling has to do with a set of cells: the columns and the rows with cells outside the set, the obstructions and the requirement lists @@ -275,8 +262,16 @@ def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: return _View( frozenset(cell[0] for cell in outside), frozenset(cell[1] for cell in outside), - tuple(ob for ob in tiling.obstructions if _crosses(ob, cell_set)), - tuple(req for req in tiling.requirements if not _lies_in(req, cell_set)), + tuple( + obstruction + for obstruction in tiling.obstructions + if _crosses(obstruction, cell_set) + ), + tuple( + requirement + for requirement in tiling.requirements + if not _lies_in(requirement, cell_set) + ), frozenset( cell for assumption in tiling.assumptions @@ -286,11 +281,12 @@ def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: ) -def _moved(gp: GriddedPerm, source: Cell, target: Cell) -> GriddedPerm: +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( - gp.patt, tuple(target if cell == source else cell for cell in gp.pos) + gridded_perm.patt, + tuple(target if cell == source else cell for cell in gridded_perm.pos), ) @@ -305,8 +301,11 @@ def _seen_alike(tiling: "Tiling", source: Cell, target: Cell, view: _View) -> bo ).contains(*tiling.obstructions): return False for requirement in view.crossing_requirements: - for gp in requirement: - if source in gp.pos and _moved(gp, source, target) not in requirement: + for gridded_perm in requirement: + if ( + source in gridded_perm.pos + and _moved(gridded_perm, source, target) not in requirement + ): return False return True @@ -318,13 +317,16 @@ def _left_out( their row or column, no point of an obstruction or requirement with points outside the set, and not tracked.""" touched = { - cell for gp in view.crossing_obstructions for cell in gp.pos if cell in cell_set + cell + for gridded_perm in view.crossing_obstructions + for cell in gridded_perm.pos + if cell in cell_set } touched.update( cell for requirement in view.crossing_requirements - for gp in requirement - for cell in gp.pos + for gridded_perm in requirement + for cell in gridded_perm.pos if cell in cell_set ) return tuple( @@ -425,10 +427,18 @@ def _reduced_tiling( kept = [group[0] for group in groups] emptied = [cell for group in groups for cell in group[1:]] + list(left_out) return tiling.__class__( - [ob for ob in tiling.obstructions if not _lies_in([ob], cell_set)] + [ + 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], - [req for req in tiling.requirements if not _lies_in(req, cell_set)], + [ + 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 []), ) @@ -446,9 +456,17 @@ def _own_tiling( if (column, row) not in cell_set ] return tiling.__class__( - [ob for ob in tiling.obstructions if _lies_in([ob], cell_set)] + [ + obstruction + for obstruction in tiling.obstructions + if _lies_in([obstruction], cell_set) + ] + other_cells_emptied, - [req for req in tiling.requirements if _lies_in(req, cell_set)], + [ + 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) @@ -476,22 +494,23 @@ def reduction_children( ) -def _words(gp: GriddedPerm) -> Tuple[Words, Words, Dict[Cell, List[int]]]: +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 gp.pos: + 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(gp)), key=lambda index: gp.patt[index]): - row_words[gp.pos[index][1]].append(gp.pos[index]) + 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(gp.patt, gp.pos): + for value, cell in zip(gridded_perm.patt, gridded_perm.pos): values_in_cell[cell].append(value) ranks = { - cell: [sorted(values).index(value) for value in values] - for cell, values in values_in_cell.items() + cell: list(Perm.to_standard(values)) for cell, values in values_in_cell.items() } return column_words, row_words, ranks @@ -516,15 +535,15 @@ def _from_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") - patt = [0] * size - pos: List[Cell] = [(0, 0)] * size + 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]): - patt[position] = values[cell][rank] - pos[position] = cell - return GriddedPerm(Perm(patt), tuple(pos)) + 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]: diff --git a/tilings/strategies/cell_reduction.py b/tilings/strategies/cell_reduction.py index 574eb04d..610ec344 100644 --- a/tilings/strategies/cell_reduction.py +++ b/tilings/strategies/cell_reduction.py @@ -70,4 +70,6 @@ def to_jsonable(self) -> dict: @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 index 8832ede3..0384ed1b 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -50,19 +50,22 @@ 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`). +is the cell reduction (`tilings.strategies.cell_reduction`). The condition, the +plan, the children and the words of the maps are in `tilings.algorithms.reduction`. """ import random from collections import Counter, defaultdict -from itertools import combinations, product +from itertools import combinations from typing import ( + Any, Callable, DefaultDict, Dict, Iterable, Iterator, List, + NamedTuple, Optional, Tuple, cast, @@ -87,7 +90,6 @@ Cell, ReductionPlan, combine_gridded_perms, - compression, is_reducible, is_rigid, own_is_tracked, @@ -102,6 +104,26 @@ 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 parts numbers, each at least 0, of sum 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 tiling of the cells' own obstructions in the variables that count the @@ -109,6 +131,9 @@ class ReductionConstructor(Constructor[Tiling, GriddedPerm]): 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__( @@ -117,51 +142,56 @@ def __init__( children: Tuple[Tiling, ...], cells: Iterable[Cell], plan: ReductionPlan, + parent_to_reduced: Optional[Dict[str, str]] = None, ): reduced_tiling, own_tiling = children self.cells = tuple(sorted(set(cells))) self.groups, left_out = plan self.leaves_out = bool(left_out) - reduced_parameters = reduced_tiling.extra_parameters - - def reduced_parameter(assumption: TrackingAssumption) -> str: - return reduced_tiling.get_assumption_parameter( - reduced_tiling.forward_map.map_assumption(assumption) - ) - + if parent_to_reduced is None: + parent_to_reduced = { + parent_parameter: self._parameter_of(reduced_tiling, assumption) + for parent_parameter, assumption in zip( + parent.extra_parameters, parent.assumptions + ) + } + self.parent_to_reduced = parent_to_reduced self.reduced_group_parameters = tuple( - reduced_parameter(TrackingAssumption.from_cells([group[0]])) + self._parameter_of( + reduced_tiling, TrackingAssumption.from_cells([group[0]]) + ) for group in self.groups ) - self.reduced_group_indices = tuple( - reduced_parameters.index(name) for name in self.reduced_group_parameters + self.reduced_group_indices = self._indices( + reduced_tiling, self.reduced_group_parameters ) - self.parent_to_reduced = { - parent_parameter: reduced_parameter(assumption) - for parent_parameter, assumption in zip( - parent.extra_parameters, parent.assumptions - ) - } - self.parent_indices = tuple( - reduced_parameters.index(self.parent_to_reduced[parent_parameter]) - for parent_parameter in parent.extra_parameters + self.parent_indices = self._indices( + reduced_tiling, + [self.parent_to_reduced[name] for name in parent.extra_parameters], ) - if not own_is_tracked(self.groups, left_out): - self.own_group_parameters: Optional[Tuple[str, ...]] = None - self.own_group_indices: Tuple[int, ...] = () - else: - forward, _ = compression(self.cells) + self.own_group_parameters: Optional[Tuple[str, ...]] = None + self.own_group_indices: Tuple[int, ...] = () + if own_is_tracked(self.groups, left_out): self.own_group_parameters = tuple( - own_tiling.get_assumption_parameter( - TrackingAssumption.from_cells([forward[cell] for cell in group]) - ) + self._parameter_of(own_tiling, TrackingAssumption.from_cells(group)) for group in self.groups ) - self.own_group_indices = tuple( - own_tiling.extra_parameters.index(name) - for name in self.own_group_parameters + 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, n: int) -> range: """The numbers of points the cells left out may have.""" return range(n + 1) if self.leaves_out else range(1) @@ -195,9 +225,31 @@ def _split(self, reduced_parameters: Parameters) -> Tuple[Parameters, Parameters tuple(reduced_parameters[index] for index in self.parent_indices), ) + def _pairs( + self, + n: 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(n): + reduced_values = reduced_by_parameters(n - 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 with each number of points in - the groups, for objects of the size.""" + """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 @@ -207,19 +259,11 @@ def get_terms( self, parent_terms: Callable[[int], Terms], subterms: SubTerms, n: int ) -> Terms: reduced_terms, own_terms = subterms - own_counts_by_size: Dict[int, Counter] = {} terms: Terms = Counter() - for left_out_size in self._left_out_sizes(n): - for reduced_parameters, reduced_count in reduced_terms( - n - left_out_size - ).items(): - group_sizes, parent_parameters = self._split(reduced_parameters) - own_size = sum(group_sizes) + left_out_size - if own_size not in own_counts_by_size: - own_counts_by_size[own_size] = self._own_counts(own_terms, own_size) - own_count = own_counts_by_size[own_size][group_sizes] - if own_count: - terms[parent_parameters] += reduced_count * own_count + 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( @@ -238,50 +282,46 @@ def get_sub_objects( self, subobjs: SubObjects, n: int ) -> Iterator[Tuple[Parameters, Tuple[List[Optional[GriddedPerm]], ...]]]: reduced_objects, own_objects = subobjs - own_by_size: Dict[int, DefaultDict[Parameters, List[Optional[GriddedPerm]]]] - own_by_size = {} - for left_out_size in self._left_out_sizes(n): - for reduced_parameters, reduced_list in reduced_objects( - n - left_out_size - ).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] = self._own_objects(own_objects, own_size) - own_list = own_by_size[own_size].get(group_sizes, []) - if reduced_list and own_list: - yield parent_parameters, (reduced_list, own_list) + 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, n: int, parent_parameters: Dict[str, int] - ) -> Iterator[Tuple[int, Dict[str, int], int, 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 size and the parameters of the reduced - tiling, and the size and the parameters of the own tiling.""" + 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(n): reduced_size = n - left_out_size - for group_sizes in product( - range(reduced_size + 1), repeat=len(self.groups) + if fixed_total > reduced_size: + continue + for free_sizes in _bounded_compositions( + reduced_size - fixed_total, len(free) ): - if sum(group_sizes) > reduced_size: - continue + group_sizes = list(fixed_sizes) + for index, size in zip(free, free_sizes): + group_sizes[index] = size reduced_parameters = dict(fixed_parameters) - if all( - reduced_parameters.setdefault(name, group_size) == group_size - for name, group_size in zip( - self.reduced_group_parameters, group_sizes - ) - ): - yield ( - reduced_size, - reduced_parameters, - sum(group_sizes) + left_out_size, - self._own_parameters(group_sizes), - ) + 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, @@ -296,43 +336,47 @@ def random_sample_sub_objects( for choice in self._sample_choices(n, parameters): total += self._choice_count(subrecs, choice) if random_choice <= total: - reduced_size, reduced_parameters, own_size, own_parameters = choice return ( - subsamplers[0](n=reduced_size, **reduced_parameters), - subsamplers[1](n=own_size, **own_parameters), + 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: Tuple[int, Dict[str, int], int, Dict[str, int]] - ) -> int: - """The number of pairs of objects for the choice of the sizes and the - parameters of the reduced tiling and of the own tiling.""" - reduced_size, reduced_parameters, own_size, own_parameters = choice - reduced_count = subrecs[0](n=reduced_size, **reduced_parameters) + 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=own_size, **own_parameters) + 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: as many groups, 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. This errs on the side of + not equivalent.""" return ( isinstance(other, ReductionConstructor) and len(self.groups) == len(other.groups) and self.leaves_out == other.leaves_out - and len(self.parent_indices) == len(other.parent_indices), + 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: - return "↣" + return ReductionStrategy.get_eq_symbol() @staticmethod def get_op_symbol() -> str: - return "⊙" + return ReductionStrategy.get_op_symbol() def __str__(self) -> str: return "Hadamard product in the reduced cells" @@ -392,7 +436,11 @@ def constructor( if children is None: children = self.decomposition_function(comb_class) return ReductionConstructor( - comb_class, children, self.cells, self.plan(comb_class) + comb_class, + children, + self.cells, + self.plan(comb_class), + self.extra_parameters(comb_class, children)[0], ) def reverse_constructor( @@ -459,14 +507,10 @@ def backward_map( 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 - _, backward = compression(self.cells) yield self.combine_gridded_perms( comb_class, children[0].backward_map.map_gp(reduced_gridded_perm), - GriddedPerm( - own_gridded_perm.patt, - tuple(backward[cell] for cell in own_gridded_perm.pos), - ), + children[1].backward_map.map_gp(own_gridded_perm), ) def forward_map( @@ -477,14 +521,9 @@ def forward_map( ) -> Tuple[Optional[GriddedPerm], ...]: if children is None: children = self.decomposition_function(comb_class) - forward, _ = compression(self.cells) - own_gridded_perm = obj.get_gridded_perm_in_cells(self.cells) return ( children[0].forward_map.map_gp(self.reduce_gridded_perm(comb_class, obj)), - GriddedPerm( - own_gridded_perm.patt, - tuple(forward[cell] for cell in own_gridded_perm.pos), - ), + children[1].forward_map.map_gp(obj.get_gridded_perm_in_cells(self.cells)), ) def to_jsonable(self) -> dict: @@ -538,14 +577,18 @@ class ReductionFactory(StrategyFactory[Tiling]): 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. + 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). So the reduced tiling - is never larger than the tiling, and smaller at some size. + 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__( @@ -564,48 +607,57 @@ def __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) - lines: List[List[Cell]] = [] - if self.region in ("row", "line"): - lines.extend( - [cell for cell in active_cells if cell[1] == row] - for row in range(tiling.dimensions[1]) - ) - if self.region in ("column", "line"): - lines.extend( - [cell for cell in active_cells if cell[0] == column] - for column in range(tiling.dimensions[0]) - ) 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": - candidates: Iterable[Tuple[Cell, ...]] = ((cell,) for cell in active_cells) - elif self.region in ("row", "column"): - candidates = (tuple(line) for line in lines) - elif self.region == "line": - candidates = ( + return ((cell,) for cell in active_cells) + if self.region in ("row", "column"): + return (tuple(line) for line in self._lines(tiling, active_cells)) + if self.region == "line": + return ( subset - for line in lines + for line in self._lines(tiling, active_cells) for size in range(1, len(line) + 1) for subset in combinations(line, size) ) - elif self.region == "cut": - candidates = ( + 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 ) - else: - largest = len(active_cells) - if self.max_cells is not None: - largest = min(largest, self.max_cells) - candidates = ( - subset - for size in range(1, largest + 1) - for subset in combinations(active_cells, size) + 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, active_cells: List[Cell]) -> 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( + [cell for cell in active_cells if cell[1] == row] + for row in range(height) ) - for cells in candidates: - if cells and cells not in seen: - seen.add(cells) - yield cells + if self.region in ("column", "line"): + lines.extend( + [cell for cell in active_cells if cell[0] == column] + for column in range(width) + ) + return lines @staticmethod def cuts(tiling: Tiling, active_cells: List[Cell]) -> Iterator[Tuple[Cell, ...]]: @@ -623,8 +675,8 @@ def cuts(tiling: Tiling, active_cells: List[Cell]) -> Iterator[Tuple[Cell, ...]] @staticmethod def is_finite_cell(tiling: Tiling, cell: Cell) -> bool: cell_basis = tiling.cell_basis()[cell][0] - return any(patt.is_increasing() for patt in cell_basis) and any( - patt.is_decreasing() for patt in cell_basis + 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]: @@ -641,7 +693,7 @@ def __call__(self, comb_class: Tiling) -> Iterator[ReductionStrategy]: ] if not directions or is_rigid(comb_class, cells): continue - if not reduction_plan(comb_class, cells)[0]: + if not reduction_plan(comb_class, cells).groups: continue for increasing in directions: yield self.strategy(cells, increasing) @@ -679,4 +731,8 @@ def to_jsonable(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "ReductionFactory": - return cls(d.get("tracked", True), d.get("region", "cell"), d.get("max_cells")) + tracked = d.pop("tracked", True) + region = d.pop("region", "cell") + max_cells = d.pop("max_cells", None) + assert not d + return cls(tracked, region, max_cells) From fd370165a75fc6a0773ae4c3882002527f783f96 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 01:25:22 +0000 Subject: [PATCH 10/21] The CHANGELOG says what changed in the cell reduction's public API The review of the pull request (round 1, finding 5): the changes to CellReductionConstructor, CellReductionStrategy's formal step and its reduced tiling (now simplified, so a specification saved with the old strategy may not rebuild its children identically: on 300 random tilings, 16 of 538 cell reductions differ, all in the reduced tiling's unsimplified obstructions; none could have counted, as the old constructor raised NotImplementedError), and the removed helpers of CellReductionFactory, are now in the CHANGELOG, whose Added bullet is shorter. test_specification asserts a reduction of three cells, as the description says. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 50 ++++++++++++++++++------------ tests/strategies/test_reduction.py | 2 +- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e238fe..eb4c63f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,28 +7,38 @@ 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 the tiling of their own - obstructions and requirements goes beside the reduced tiling. An obstruction - with one point in the set may see that point along a row or a column the set - has other cells in, when the set's own obstructions already put those cells in - the order of the reduction. The reduced tiling keeps only what the rest of the - tiling sees of the set: it leaves out the cells the rest does not see, and keeps - one cell for each group of cells it sees as one, tracked once in both children. - The count is the Hadamard product of the two children in the variables counting - the points in those groups, at 1, and the product in the size of the cells left - out, and the rules generate and sample their objects. The factory's `region` - says which sets it tries: `"cell"` (single cells, the cell reduction), `"row"`, - `"column"`, `"line"` (every set of cells in one row or one column), `"cut"` - (the cells on one side of a line between two columns or two rows, as the cells - left of a placed point) and `"any"` (every set); with `max_cells`, the sets of - `"cut"` and `"any"` have at most that many cells. +- `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 now the reduction of - single cells, so they count, generate and sample objects, and the kitchen sink - pack uses `ReductionFactory` with the region `"cell"`. A cell with no - obstruction of its own, or in a component assumption, is no longer reduced. +- `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 cells and the plan. 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_reduction.py b/tests/strategies/test_reduction.py index 0b6a082d..7a669a07 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -730,7 +730,7 @@ def test_specification(): spec = TileScope("1234_1342_2413", pack).auto_search() assert isinstance(spec, CombinatorialSpecification) assert any( - isinstance(rule.strategy, ReductionStrategy) and len(rule.strategy.cells) > 1 + isinstance(rule.strategy, ReductionStrategy) and len(rule.strategy.cells) == 3 for rule in spec.rules_dict.values() ) basis = [Perm((0, 1, 2, 3)), Perm((0, 2, 3, 1)), Perm((1, 3, 0, 2))] From a4cf74a0629be6128842e92183728e937c34c6d2 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 03:13:51 +0000 Subject: [PATCH 11/21] Tests for a cell only a requirement list sees, and for two cells of a row a column sees apart The review's round 2 found two mutants that broke counts and passed every test, round 1's mutations at the other copy of a line that occurs twice: - in _left_out, the cells a requirement list with points outside the set touches (round 1's tests covered the obstructions' copy). In the top row of row_above_a_cell with a point required in B or below it, B is kept though its row and column have no cell outside the set, and A is left out; - in _runs, the place of a cell along a row (the copy in _canonical_words was covered). In the same row without the requirement, B and C are two groups, as C's column has a cell outside the set. The same mutation of _canonical_words, along a row when decreasing, broke only the forward map: unlike_row's complement, reduced decreasing with its two cells kept, is added to test_maps for it. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 7a669a07..ed43ffcc 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -182,6 +182,13 @@ 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 with_requirement_list(): """alike_row with a point required in A or in C.""" @@ -209,6 +216,36 @@ def touched_only(): ) +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 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))]] + ) + + def offered_sets(tiling, region): return sorted( {strategy.cells for strategy in ReductionFactory(True, region)(tiling)} @@ -371,7 +408,10 @@ def test_sanity_check(request, fixture, cells, increasing): + [ ("alike_row", [(0, 0), (1, 0)], True), ("unlike_row", [(0, 0), (1, 0)], True), + ("unlike_row_decreasing", [(0, 0), (1, 0)], 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), ("threshold", THRESHOLD_CELLS, True), ], ) @@ -500,6 +540,28 @@ def test_requirement_list(with_requirement_list): assert all(rule.sanity_check(n) for n in range(6)) +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)) + + +def test_split_by_column(split_by_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.""" + 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)) + + def test_direction_not_reducible(threshold): """A strategy applied in a direction its set is not reducible in.""" with pytest.raises(StrategyDoesNotApply): From f17929cfecef853f70b209760d0ef027e8e7e919 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 03:13:51 +0000 Subject: [PATCH 12/21] is_reducible reads its cells once, as 7dafe8f's message said it did 7dafe8f's message says is_reducible takes the cells once, so that an iterator serves both directions, but the code was not changed: with no direction, it passed the cells to the increasing call and then to the decreasing one, so an iterator was used up by the first, and a set reducible decreasing only was not reducible (the review's round 2). The cells are now made a set first. test_reducible_from_an_iterator passes an iterator of the cells of alike_row's complement, which is reducible decreasing only. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 11 +++++++++++ tilings/algorithms/reduction.py | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index ed43ffcc..d07b1202 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -262,6 +262,17 @@ def test_reducible(two_rows, apart): assert not is_reducible(apart, [(1, 0)]) +def test_reducible_from_an_iterator(alike_row): + """With no direction given, the cells are read once, so an iterator serves for + both directions: here the second, as the complement of alike_row is reducible + decreasing only.""" + tiling = alike_row.complement() + cells = [(0, 0), (1, 0)] + assert not is_reducible(tiling, cells, True) + assert is_reducible(tiling, cells, False) + assert is_reducible(tiling, iter(cells)) + + def test_rigid(two_rows): assert is_rigid(two_rows, [(3, 1)]) assert not is_rigid(two_rows, [(0, 1), (3, 1)]) diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py index f1525e6f..d5c65056 100644 --- a/tilings/algorithms/reduction.py +++ b/tilings/algorithms/reduction.py @@ -202,9 +202,11 @@ def is_reducible( ) -> 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.""" - if increasing is None: - return is_reducible(tiling, cells, True) or is_reducible(tiling, cells, False) 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) From 20828578b36331d52f7831e143301576161704dd Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 03:20:50 +0000 Subject: [PATCH 13/21] The reduction's docstrings, reuse and names, from the review's round 2 suggestions - The condition, and the argument why the reduction holds, are in the docstring of tilings.algorithms.reduction, where the code is, which defines the own tiling once; the strategy module's says what its constructor counts. - One selection of what the rest of the tiling sees the set through: _seen_gridded_perms reads the view, for is_reducible and _left_out alike. - ReductionConstructor(parent, children, plan, parent_to_reduced): the cells, never read, and the fallback for a missing map, which the strategy always passes, are gone, as the CHANGELOG says. - ReductionFactory._lines uses Tiling.cells_in_row and cells_in_col, from_dict pops its keys without defaults, and is_finite_cell, strategy and the constructor's symbol methods have docstrings; _own_tiling says why it is not Tiling.sub_tiling. - _monotone_ranks uses Perm.identity and Perm.monotone_decreasing; the constructor's helpers take parent_size, and the tests a random_generator. - test_equiv; test_tracked_pattern's docstring gives the reason. - The CHANGELOG's code span is on one line. On 10,400 random tilings, every set of up to four cells has the same reducibility in each direction and the same plan as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- tests/strategies/test_reduction.py | 55 ++++++--- tilings/algorithms/reduction.py | 176 ++++++++++++++++++----------- tilings/strategies/reduction.py | 127 +++++++-------------- 4 files changed, 194 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4c63f2..f9dcc0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. its children identically. None could have been counted: the old constructor raised `NotImplementedError`. - `CellReductionConstructor` is `ReductionConstructor`, which takes the parent, - the children, the cells and the plan. The old `CellReductionConstructor - (parameter)` counted nothing: each of its methods raised `NotImplementedError`. + 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`, diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index d07b1202..2b9aeda4 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -4,7 +4,7 @@ import pytest -from comb_spec_searcher import CombinatorialSpecification, Strategy +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 @@ -579,12 +579,12 @@ def test_direction_not_reducible(threshold): ReductionStrategy(THRESHOLD_CELLS, False)(threshold) -def random_gridded_perm(rng, cells, size): +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(rng.choice(cells) for _ in range(size)) + positions = sorted(random_generator.choice(cells) for _ in range(size)) by_value = sorted( - range(size), key=lambda index: (positions[index][1], rng.random()) + range(size), key=lambda index: (positions[index][1], random_generator.random()) ) values = [0] * size for value, index in enumerate(by_value): @@ -592,10 +592,12 @@ def random_gridded_perm(rng, cells, size): return GriddedPerm(Perm(values), tuple(positions)) -def random_tiling(rng): - width, height = rng.randint(1, 3), rng.randint(1, 3) +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 = rng.sample(cells, rng.randint(1, min(len(cells), 4))) + 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)) @@ -604,12 +606,20 @@ def random_tiling(rng): GriddedPerm.point_perm(cell) for cell in cells if cell not in active ] for cell in active: - for pattern in rng.sample(patterns, rng.randint(1, 2)): + for pattern in random_generator.sample( + patterns, random_generator.randint(1, 2) + ): obstructions.append(GriddedPerm.single_cell(pattern, cell)) - for _ in range(rng.randint(1, 4)): - obstructions.append(random_gridded_perm(rng, active, rng.randint(2, 3))) + 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(rng.choice(active))]] if rng.random() < 0.3 else [] + [[GriddedPerm.point_perm(random_generator.choice(active))]] + if random_generator.random() < 0.3 + else [] ) return Tiling(obstructions, requirements) @@ -618,10 +628,10 @@ def random_tiling(rng): 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.""" - rng = random.Random(2026) + random_generator = random.Random(2026) tilings = [] while len(tilings) < 30: - tiling = random_tiling(rng) + tiling = random_tiling(random_generator) if not tiling.is_empty() and len(tiling.active_cells) >= 2: tilings.append(tiling) reductions = 0 @@ -713,8 +723,9 @@ def test_unrestricted_cell(): 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: a set with a cell of - one is not reduced (the review of the pull request, round 1, finding 1).""" + 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), @@ -735,6 +746,20 @@ def test_tracked_pattern(): assert all(rule.sanity_check(n) for n in range(5)) +def test_equiv(alike_row, unlike_row): + """Two reductions' constructors are equivalent when they pair their children + the same way: the alike row's and its complement's, one group each, but not + the alike row's and the unlike row's, one group and two.""" + cells = [(0, 0), (1, 0)] + alike = ReductionStrategy(cells, True).constructor(alike_row) + complement = ReductionStrategy(cells, False).constructor(alike_row.complement()) + unlike = ReductionStrategy(cells, True).constructor(unlike_row) + assert alike.equiv(complement)[0] + assert not alike.equiv(unlike)[0] + assert not unlike.equiv(alike)[0] + assert not alike.equiv(DisjointUnion(alike_row, (alike_row,), ({},)))[0] + + def test_not_tracked(two_rows): strategy = ReductionStrategy([(1, 0), (2, 0), (3, 1)], True, tracked=False) reduced_tiling, _ = strategy.decomposition_function(two_rows) diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py index d5c65056..b457b248 100644 --- a/tilings/algorithms/reduction.py +++ b/tilings/algorithms/reduction.py @@ -2,8 +2,62 @@ 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. -`tilings.strategies.reduction` has the strategy, its constructor and its factory, -and the argument why the reduction holds. +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; each cell of the set must lie in one of its +obstructions, as `Tiling` takes a cell in no obstruction and no requirement to +be empty. + +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 @@ -158,11 +212,9 @@ def _crossing_is_allowed( 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: the sorting - keeps the number of points in each cell, but not the occurrences of a pattern), - and each of its cells has an obstruction of its own: the tiling of the set's - own obstructions must have every cell of the set (an unrestricted cell has - none).""" + 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 (an unrestricted cell + lies in none).""" if not cell_set or not cell_set <= tiling.active_cells: return False if any( @@ -181,20 +233,54 @@ def _qualifies(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> bool: return cell_set <= cells_with_own_obstructions +class _View(NamedTuple): + """What the rest of the tiling has to do with a set of cells: the columns and + the rows with cells outside the set, the obstructions and the requirement lists + with points outside it, and the tracked cells.""" + + outside_columns: FrozenSet[int] + outside_rows: FrozenSet[int] + crossing_obstructions: Tuple[GriddedPerm, ...] + crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] + tracked: FrozenSet[Cell] + + +def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: + outside = tiling.active_cells - cell_set + return _View( + frozenset(cell[0] for cell in outside), + frozenset(cell[1] for cell in outside), + tuple( + obstruction + for obstruction in tiling.obstructions + if _crosses(obstruction, cell_set) + ), + tuple( + requirement + for requirement in tiling.requirements + if not _lies_in(requirement, cell_set) + ), + frozenset( + cell + for assumption in tiling.assumptions + for gridded_perm in assumption.gps + for cell in gridded_perm.pos + ), + ) + + def _seen_gridded_perms( - tiling: "Tiling", cell_set: FrozenSet[Cell] + view: _View, cell_set: FrozenSet[Cell] ) -> Iterator[GriddedPerm]: - """The obstructions with points in the set and outside it, and the gridded + """The gridded permutations the rest of the tiling sees the set through: 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.""" - for obstruction in tiling.obstructions: - if _crosses(obstruction, cell_set): - yield obstruction - for requirement in tiling.requirements: - if not _lies_in(requirement, cell_set): - for gridded_perm in requirement: - if any(cell in cell_set for cell in gridded_perm.pos): - yield gridded_perm + yield from view.crossing_obstructions + for requirement in view.crossing_requirements: + for gridded_perm in requirement: + if any(cell in cell_set for cell in gridded_perm.pos): + yield gridded_perm def is_reducible( @@ -212,7 +298,7 @@ def is_reducible( ordered_columns, ordered_rows = _ordered_lines(tiling, cell_set, increasing) return all( _crossing_is_allowed(gridded_perm, cell_set, ordered_columns, ordered_rows) - for gridded_perm in _seen_gridded_perms(tiling, cell_set) + for gridded_perm in _seen_gridded_perms(_view(tiling, cell_set), cell_set) ) @@ -247,42 +333,6 @@ def is_forbidden(gridded_perm: GriddedPerm) -> bool: return True -class _View(NamedTuple): - """What the rest of the tiling has to do with a set of cells: the columns and - the rows with cells outside the set, the obstructions and the requirement lists - with points outside it, and the tracked cells.""" - - outside_columns: FrozenSet[int] - outside_rows: FrozenSet[int] - crossing_obstructions: Tuple[GriddedPerm, ...] - crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] - tracked: FrozenSet[Cell] - - -def _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: - outside = tiling.active_cells - cell_set - return _View( - frozenset(cell[0] for cell in outside), - frozenset(cell[1] for cell in outside), - tuple( - obstruction - for obstruction in tiling.obstructions - if _crosses(obstruction, cell_set) - ), - tuple( - requirement - for requirement in tiling.requirements - if not _lies_in(requirement, cell_set) - ), - frozenset( - cell - for assumption in tiling.assumptions - for gridded_perm in assumption.gps - for cell in gridded_perm.pos - ), - ) - - 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.""" @@ -320,17 +370,10 @@ def _left_out( outside the set, and not tracked.""" touched = { cell - for gridded_perm in view.crossing_obstructions + for gridded_perm in _seen_gridded_perms(view, cell_set) for cell in gridded_perm.pos if cell in cell_set } - touched.update( - cell - for requirement in view.crossing_requirements - for gridded_perm in requirement - for cell in gridded_perm.pos - if cell in cell_set - ) return tuple( cell for cell in cells @@ -449,6 +492,9 @@ def _reduced_tiling( 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 = [ @@ -486,8 +532,8 @@ def reduction_children( """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 tiling of the set's own obstructions and requirements, each - group tracked if there are two or more, or cells left out.""" + 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 ( @@ -549,7 +595,7 @@ def _from_words( def _monotone_ranks(size: int, increasing: bool) -> List[int]: - return list(range(size)) if increasing else list(range(size - 1, -1, -1)) + return list(Perm.identity(size) if increasing else Perm.monotone_decreasing(size)) def _canonical_words( diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index 0384ed1b..fd464980 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -1,57 +1,19 @@ -"""The reduction strategy, which makes a set of cells monotone. - -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; - -and every requirement list lies in the set, or each of its gridded permutations -has no point in the set or one point in it that the obstructions may have. - -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. 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 tiling of the set's own obstructions and requirements, with as many -points in the cells of the set. - -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. Each group is tracked once, in both -children, and 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: +"""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. +Each group is tracked once, in both children, and 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`). The condition, the -plan, the children and the words of the maps are in `tilings.algorithms.reduction`. +is the cell reduction (`tilings.strategies.cell_reduction`). """ import random @@ -126,9 +88,9 @@ def _bounded_compositions(bound: int, parts: int) -> Iterator[Tuple[int, ...]]: class ReductionConstructor(Constructor[Tiling, GriddedPerm]): """The constructor of a reduction: the Hadamard product of the reduced tiling - and the tiling of the cells' own obstructions 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. + 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) @@ -140,21 +102,12 @@ def __init__( self, parent: Tiling, children: Tuple[Tiling, ...], - cells: Iterable[Cell], plan: ReductionPlan, - parent_to_reduced: Optional[Dict[str, str]] = None, + parent_to_reduced: Dict[str, str], ): reduced_tiling, own_tiling = children - self.cells = tuple(sorted(set(cells))) self.groups, left_out = plan self.leaves_out = bool(left_out) - if parent_to_reduced is None: - parent_to_reduced = { - parent_parameter: self._parameter_of(reduced_tiling, assumption) - for parent_parameter, assumption in zip( - parent.extra_parameters, parent.assumptions - ) - } self.parent_to_reduced = parent_to_reduced self.reduced_group_parameters = tuple( self._parameter_of( @@ -192,9 +145,9 @@ 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, n: int) -> range: + def _left_out_sizes(self, parent_size: int) -> range: """The numbers of points the cells left out may have.""" - return range(n + 1) if self.leaves_out else range(1) + 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.""" @@ -227,7 +180,7 @@ def _split(self, reduced_parameters: Parameters) -> Tuple[Parameters, Parameters def _pairs( self, - n: int, + 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]]: @@ -236,8 +189,8 @@ def _pairs( 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(n): - reduced_values = reduced_by_parameters(n - left_out_size) + 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 @@ -288,7 +241,7 @@ def get_sub_objects( yield parent_parameters, (reduced_list, own_list) def _sample_choices( - self, n: int, parent_parameters: Dict[str, int] + 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 @@ -302,8 +255,8 @@ def _sample_choices( ] 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(n): - reduced_size = n - left_out_size + 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( @@ -372,10 +325,12 @@ def equiv( @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: @@ -384,7 +339,7 @@ def __str__(self) -> str: 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 cells' + 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.""" @@ -438,7 +393,6 @@ def constructor( return ReductionConstructor( comb_class, children, - self.cells, self.plan(comb_class), self.extra_parameters(comb_class, children)[0], ) @@ -619,11 +573,11 @@ def _region_sets( 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, active_cells)) + return (tuple(line) for line in self._lines(tiling)) if self.region == "line": return ( subset - for line in self._lines(tiling, active_cells) + for line in self._lines(tiling) for size in range(1, len(line) + 1) for subset in combinations(line, size) ) @@ -642,21 +596,15 @@ def _region_sets( for subset in combinations(active_cells, size) ) - def _lines(self, tiling: Tiling, active_cells: List[Cell]) -> List[List[Cell]]: + 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( - [cell for cell in active_cells if cell[1] == row] - for row in range(height) - ) + lines.extend(sorted(tiling.cells_in_row(row)) for row in range(height)) if self.region in ("column", "line"): - lines.extend( - [cell for cell in active_cells if cell[0] == column] - for column in range(width) - ) + lines.extend(sorted(tiling.cells_in_col(column)) for column in range(width)) return lines @staticmethod @@ -674,6 +622,9 @@ def cuts(tiling: Tiling, active_cells: List[Cell]) -> Iterator[Tuple[Cell, ...]] @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 @@ -699,6 +650,8 @@ def __call__(self, comb_class: Tiling) -> Iterator[ReductionStrategy]: 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: @@ -731,8 +684,8 @@ def to_jsonable(self) -> dict: @classmethod def from_dict(cls, d: dict) -> "ReductionFactory": - tracked = d.pop("tracked", True) - region = d.pop("region", "cell") - max_cells = d.pop("max_cells", None) + tracked = d.pop("tracked") + region = d.pop("region") + max_cells = d.pop("max_cells") assert not d return cls(tracked, region, max_cells) From c96840c0ac9e98d0f5a326f26cb05a0b8ec852c4 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 05:24:17 +0000 Subject: [PATCH 14/21] Tests for a cell the rest sees only through a two-point gridded permutation of a requirement list The review's round 3: every requirement in the tests was a single point, so the branch of _seen_gridded_perms that selects a requirement's gridded permutation with a point in the set and a point outside it was never taken (any( and all( agree on a single point), and any( changed to all( passed every test. - seen_through_a_21 is the review's tiling: A = (0, 1), in a column and a row with no cell outside the set, is seen only through the 21 from A down to C = (1, 0) of the list [a point in D = (2, 0), that 21]. Its plan, the "row" region offering the set, both directions' sanity checks to size 5, and test_maps. - left_out_beside_a_21 is row_above_a_cell with [a 21 from A down to D, a 12 in D] required: A is kept through the 21 and B left out, test_left_out_beside_kept with a requirement for the obstruction. Both lists have a second gridded permutation, as Tiling splits a lone 21 between two cells that share no row or column into a point in each. - test_requirement_with_two_points_in_the_set: a 12 from A to B in a list with a point in C makes alike_row's two cells not reducible increasing. With any( as all(, 8 of these tests fail. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 2b9aeda4..02158d2b 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -246,6 +246,40 @@ def seen_through_requirement(): ) +@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)} @@ -393,6 +427,8 @@ def test_children(two_rows): ("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)]), ], ) @pytest.mark.parametrize("increasing", [True, False]) @@ -423,6 +459,8 @@ def test_sanity_check(request, fixture, cells, increasing): ("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), ], ) @@ -551,6 +589,22 @@ def test_requirement_list(with_requirement_list): assert all(rule.sanity_check(n) for n in range(6)) +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: here a 12 from A to B, in + a list with a point in C. The direction is given, as alike_row is not + reducible decreasing anyway.""" + tiling = Tiling( + alike_row.obstructions, + [[GriddedPerm(Perm((0, 1)), ((0, 0), (1, 0))), GriddedPerm.point_perm((2, 0))]], + ) + cells = [(0, 0), (1, 0)] + assert is_reducible(alike_row, cells, True) + 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 @@ -573,6 +627,31 @@ def test_split_by_column(split_by_column): assert all(rule.sanity_check(n) for n in range(5)) +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): From 3a455a08dfef97b18169cbd9b9ee95d1acac2941 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 05:24:30 +0000 Subject: [PATCH 15/21] is_reducible reads the gridded permutations the rest sees one at a time again 2082857 made is_reducible build the whole view (the lines with cells outside the set, the tracked cells and every crossing obstruction) before its first check, where fd37016 stopped at the first gridded permutation not allowed. On every set of at most three cells of 12 tilings of 10 and 11 active cells, grown by point placements, it was 1.4 to 1.7 times slower (the review's round 3, question 7(b)). Now _crossing_obstructions and _crossing_requirements are generators, and _seen_gridded_perms(tiling, cell_set) chains them for is_reducible. _view builds its tuples, and the cells of the set that a seen gridded permutation touches, from the same three, so the selection is still made in one place. _View has the set and the touched cells, and _left_out takes the view alone (the review's suggestion 2). On those sets: the same answers as fd37016 and 2082857, and as fast as fd37016 after warm-up. On 10,400 random tilings (61,570 sets of up to four cells), the same reducibility in each direction, the same answer for an iterator and the same plans as 2082857. Co-Authored-By: Claude Opus 5.5 --- tilings/algorithms/reduction.py | 122 ++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py index b457b248..91f0690f 100644 --- a/tilings/algorithms/reduction.py +++ b/tilings/algorithms/reduction.py @@ -233,51 +233,37 @@ def _qualifies(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> bool: return cell_set <= cells_with_own_obstructions -class _View(NamedTuple): - """What the rest of the tiling has to do with a set of cells: the columns and - the rows with cells outside the set, the obstructions and the requirement lists - with points outside it, and the tracked cells.""" - - outside_columns: FrozenSet[int] - outside_rows: FrozenSet[int] - crossing_obstructions: Tuple[GriddedPerm, ...] - crossing_requirements: Tuple[Tuple[GriddedPerm, ...], ...] - tracked: FrozenSet[Cell] +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 _view(tiling: "Tiling", cell_set: FrozenSet[Cell]) -> _View: - outside = tiling.active_cells - cell_set - return _View( - frozenset(cell[0] for cell in outside), - frozenset(cell[1] for cell in outside), - tuple( - obstruction - for obstruction in tiling.obstructions - if _crosses(obstruction, cell_set) - ), - tuple( - requirement - for requirement in tiling.requirements - if not _lies_in(requirement, cell_set) - ), - frozenset( - cell - for assumption in tiling.assumptions - for gridded_perm in assumption.gps - for cell in gridded_perm.pos - ), +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( - view: _View, cell_set: FrozenSet[Cell] + tiling: "Tiling", cell_set: FrozenSet[Cell] ) -> Iterator[GriddedPerm]: - """The gridded permutations the rest of the tiling sees the set through: 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 view.crossing_obstructions - for requirement in view.crossing_requirements: + """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 @@ -296,9 +282,48 @@ def is_reducible( 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(_view(tiling, cell_set), cell_set) + 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 + ), ) @@ -362,24 +387,16 @@ def _seen_alike(tiling: "Tiling", source: Cell, target: Cell, view: _View) -> bo return True -def _left_out( - cells: Tuple[Cell, ...], cell_set: FrozenSet[Cell], view: _View -) -> Tuple[Cell, ...]: +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.""" - touched = { - cell - for gridded_perm in _seen_gridded_perms(view, cell_set) - for cell in gridded_perm.pos - if cell in cell_set - } return tuple( cell - for cell in cells + 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 touched + and cell not in view.touched and cell not in view.tracked ) @@ -430,9 +447,8 @@ def _runs( 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.""" - cell_set = frozenset(cells) - view = _view(tiling, cell_set) - left_out = _left_out(cells, cell_set, view) + 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 From 11c0b5d47b52a2df0f682330f79e194cb16851fc Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 05:24:30 +0000 Subject: [PATCH 16/21] The review's round 3 suggestions: equiv's tests, the regions' sets, docstrings - test_equiv: pairs that differ only in leaving a cell out, only in the place of the parent's parameter, or only in the place of the group's parameter in the reduced tiling, none equivalent; equivalent pairs of one group and of two; and one group each, with a cell left out in one. equiv no longer compares the number of groups: reduced_group_indices has an entry for each. The constructor's groups are a local name, as nothing else read them. - test_candidate_sets: the sets each region tries on an L of three cells, the "line" region's single cells among them. - The strategies module's docstring says when the own tiling tracks the groups, and _bounded_compositions' docstring is reworded. - The condition that each cell of the set lie in an obstruction of the set's own is stricter than its reason, since a requirement of the set's own would keep the cell active in the own tiling too. The docstrings say so; the condition is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 83 ++++++++++++++++++++++++++---- tilings/algorithms/reduction.py | 12 +++-- tilings/strategies/reduction.py | 28 +++++----- 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 02158d2b..9b15c7c8 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -355,6 +355,30 @@ def test_regions(two_rows, apart, row_and_column, one_cell): ReductionFactory(True, "diagonal") +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 @@ -825,17 +849,56 @@ def test_tracked_pattern(): assert all(rule.sanity_check(n) for n in range(5)) -def test_equiv(alike_row, unlike_row): +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: the alike row's and its complement's, one group each, but not - the alike row's and the unlike row's, one group and two.""" - cells = [(0, 0), (1, 0)] - alike = ReductionStrategy(cells, True).constructor(alike_row) - complement = ReductionStrategy(cells, False).constructor(alike_row.complement()) - unlike = ReductionStrategy(cells, True).constructor(unlike_row) - assert alike.equiv(complement)[0] - assert not alike.equiv(unlike)[0] - assert not unlike.equiv(alike)[0] + 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, whose own tiling is tracked + (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] diff --git a/tilings/algorithms/reduction.py b/tilings/algorithms/reduction.py index 91f0690f..cc91292f 100644 --- a/tilings/algorithms/reduction.py +++ b/tilings/algorithms/reduction.py @@ -41,9 +41,10 @@ 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; each cell of the set must lie in one of its -obstructions, as `Tiling` takes a cell in no obstruction and no requirement to -be empty. +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 @@ -213,8 +214,9 @@ 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 (an unrestricted cell - lies in none).""" + 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( diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index fd464980..85e5bdea 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -6,9 +6,11 @@ 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. -Each group is tracked once, in both children, and 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: +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), @@ -77,7 +79,8 @@ class SampleChoice(NamedTuple): def _bounded_compositions(bound: int, parts: int) -> Iterator[Tuple[int, ...]]: - """The tuples of parts numbers, each at least 0, of sum at most bound.""" + """The tuples of length parts of numbers at least 0 whose sum is at most + bound.""" if parts == 0: yield () return @@ -106,14 +109,14 @@ def __init__( parent_to_reduced: Dict[str, str], ): reduced_tiling, own_tiling = children - self.groups, left_out = plan + 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 self.groups + for group in groups ) self.reduced_group_indices = self._indices( reduced_tiling, self.reduced_group_parameters @@ -124,10 +127,10 @@ def __init__( ) self.own_group_parameters: Optional[Tuple[str, ...]] = None self.own_group_indices: Tuple[int, ...] = () - if own_is_tracked(self.groups, left_out): + if own_is_tracked(groups, left_out): self.own_group_parameters = tuple( self._parameter_of(own_tiling, TrackingAssumption.from_cells(group)) - for group in self.groups + for group in groups ) self.own_group_indices = self._indices( own_tiling, self.own_group_parameters @@ -307,13 +310,12 @@ 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: as many groups, 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. This errs on the side of - not equivalent.""" + 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.""" return ( isinstance(other, ReductionConstructor) - and len(self.groups) == len(other.groups) and self.leaves_out == other.leaves_out and (self.own_group_parameters is None) == (other.own_group_parameters is None) From aa11c8b6a3e391e85388d486c01e1510d7d81c63 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 06:49:05 +0000 Subject: [PATCH 17/21] Tests of what is_reducible, is_rigid and the strategies' encodings return None of these changes a count, so no test that compares counts saw them: - test_degenerate_sets: the empty set, a set with an empty cell and a set with a cell off the tiling are not reducible; the cell of a one-cell tiling is. - test_reducible_with_no_direction, which test_reducible_from_an_iterator becomes: a set reducible increasing only, and one reducible decreasing only, are reducible with no direction given, from a list and from an iterator. - test_encoding: ReductionStrategy and CellReductionStrategy decode to themselves in each direction, tracked or not. One combination of each was round-tripped, so a from_dict that dropped or swapped increasing and tracked went unseen. - test_rigid: two cells of a column, in no fixed order and in each fixed order, and two cells of a row in a fixed order, the second of which is not monotone. Only cells of a row were tested, so the column branch, and the skip of reversed pairs taken as a break, went unseen. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 71 +++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 9b15c7c8..45f9640f 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -296,15 +296,36 @@ def test_reducible(two_rows, apart): assert not is_reducible(apart, [(1, 0)]) -def test_reducible_from_an_iterator(alike_row): - """With no direction given, the cells are read once, so an iterator serves for - both directions: here the second, as the complement of alike_row is reducible - decreasing only.""" - tiling = alike_row.complement() +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)] - assert not is_reducible(tiling, cells, True) - assert is_reducible(tiling, cells, False) - assert is_reducible(tiling, iter(cells)) + 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): @@ -318,6 +339,23 @@ def test_rigid(two_rows): [], ) 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 + increasing_cells = {(0, 0): [Perm((1, 0))], (0, 1): [Perm((1, 0))]} + column = [(0, 0), (0, 1)] + assert not is_rigid(tiling_from(increasing_cells, [], []), column) + for crossing in ( + (Perm((1, 0)), ((0, 1), (0, 0))), + (Perm((0, 1)), ((0, 0), (0, 1))), + ): + assert is_rigid(tiling_from(increasing_cells, [crossing], []), column) def test_regions(two_rows, apart, row_and_column, one_cell): @@ -930,14 +968,23 @@ def test_cell_reduction(one_cell, two_rows): def test_encoding(): - for strategy in ( - ReductionStrategy([(0, 0), (1, 0)], True, True), - CellReductionStrategy((2, 1), False, False), + """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__ From bb99646d4c62350b7c4e458035809994616235c5 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 06:49:19 +0000 Subject: [PATCH 18/21] test_specification searches level by level, on a class whose every specification there reduces three cells auto_search looks for a specification as its timing allows and extracts one at random, the smallest of the random proof trees it builds within a time limit. Av(1234, 1342, 2413) also has specifications without any reduction, one of 20 rules at the fifth level. So the specification found there reduced no set of three cells in one of 20 runs of the test, and in one mutation run whose mutant could not change a search, and the test failed. On Av(1243, 2314, 2413), with the same pack, the fourth level is the first with a specification. Without the rules of reductions of three cells, no specification is left there: pruning the rules without them leaves no root, under three hash seeds. So the specification extracted reduces three cells in every run, and it counts the class to length 8. The test passed under 16 hash seeds, in about 7 seconds each. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 45f9640f..a2f3efe9 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -994,7 +994,13 @@ def test_encoding(): @pytest.mark.timeout(120) def test_specification(): - """A specification of Av(1234, 1342, 2413) that reduces a set of three cells.""" + """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, @@ -1014,13 +1020,16 @@ def test_specification(): pack = pack.make_fusion(tracked=True).add_initial( ReductionFactory(True, "any", max_cells=3) ) - spec = TileScope("1234_1342_2413", pack).auto_search() + 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, 2, 3)), Perm((0, 2, 3, 1)), Perm((1, 3, 0, 2))] + 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) ] From d2939c03e948cb522f1e8328cb19f94c3a8cd97b Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 06:49:19 +0000 Subject: [PATCH 19/21] Tests of the factory's skips, the seen-alike test's requirement branch, the strategy's cells and the sampling's compositions - test_requirement_with_two_points_in_the_set also takes a lone 231 across A, B and C: two points in the set and one outside it, which only is_reducible's selection of a requirement's gridded permutations decides. - test_factory_skips: a single finite cell, though reducible and not rigid, and a set the rest of the tiling sees nothing of are not offered. - test_requirement_list asserts the plans. A point required in A or in C keeps A and B apart; a point required in A, in B or in C sees them alike. - test_strategy_cells: a strategy's cells are sorted and without repeats. - test_bounded_compositions: the compositions the random sampling goes through, against their number. - equiv says why it compares the own tiling's tracking and the places of its groups' parameters, which follow from the rest; test_equiv's fifth pair differs in two ways, as its comment now says. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 89 +++++++++++++++++++++++++----- tilings/strategies/reduction.py | 3 + 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index a2f3efe9..df028717 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -1,4 +1,5 @@ import json +import math import random from collections import defaultdict @@ -22,7 +23,11 @@ CellReductionFactory, CellReductionStrategy, ) -from tilings.strategies.reduction import ReductionFactory, ReductionStrategy +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 @@ -643,28 +648,42 @@ def test_left_out_beside_kept(touched_only): assert all(rule.sanity_check(n) for n in range(6)) -def test_requirement_list(with_requirement_list): - """A requirement list with a point in the set and a point outside it.""" +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: here a 12 from A to B, in - a list with a point in C. The direction is given, as alike_row is not - reducible decreasing anyway.""" - tiling = Tiling( - alike_row.obstructions, - [[GriddedPerm(Perm((0, 1)), ((0, 0), (1, 0))), GriddedPerm.point_perm((2, 0))]], - ) + 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) - assert not is_reducible(tiling, cells, True) - with pytest.raises(StrategyDoesNotApply): - ReductionStrategy(cells, True)(tiling) + 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): @@ -862,6 +881,25 @@ def test_unrestricted_cell(): assert list(CellReductionFactory(True)(tiling)) == [] +def test_factory_skips(): + """The factory does not offer a single finite cell, though it is reducible and + not rigid, nor a set the rest of the tiling sees nothing of: a factor.""" + finite_cell = tiling_from( + {(0, 0): [Perm((0, 1, 2)), Perm((2, 1, 0))], (1, 0): [Perm((1, 0))]}, + [(Perm((0, 1)), ((0, 0), (1, 0)))], + [], + ) + assert is_reducible(finite_cell, [(0, 0)]) + assert not is_rigid(finite_cell, [(0, 0)]) + assert list(ReductionFactory(True, "cell")(finite_cell)) == [] + 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 @@ -921,7 +959,8 @@ def tracking(tiling, cell): constructor(seen_through_requirement, [(0, 1), (1, 1), (2, 1)]), False, ), - # one group each, a cell left out in one only, whose own tiling is tracked + # 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 @@ -940,6 +979,19 @@ def tracking(tiling, cell): 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): strategy = ReductionStrategy([(1, 0), (2, 0), (3, 1)], True, tracked=False) reduced_tiling, _ = strategy.decomposition_function(two_rows) @@ -967,6 +1019,15 @@ def test_cell_reduction(one_cell, two_rows): 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.""" diff --git a/tilings/strategies/reduction.py b/tilings/strategies/reduction.py index 85e5bdea..1b77108b 100644 --- a/tilings/strategies/reduction.py +++ b/tilings/strategies/reduction.py @@ -314,6 +314,9 @@ def equiv( 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 From 83bd9adf6e9ee8ebd1ecefb6423cb8ed90f4b4b2 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 10:09:30 +0000 Subject: [PATCH 20/21] Tests of the maps along a column, the grouping, a column's order, and the factory's tracking, defaults and finite cells Each of these went unseen by the tests: - test_maps: unlike_column and unlike_column_decreasing, the inverses of unlike_row and unlike_row_decreasing, in which the reduction keeps the two cells of a column; test_groups_along_a_column asserts their plans. No case kept two cells of one column, so column words put in the wrong order went unseen. - Two cells of a row that are two groups: seen_through_requirement's A and B, which the rest sees alike one way only, and split_by_first_column, the reverse of split_by_column, whose first cell of the row joins no group, as its column has a cell outside the set. Their plans are asserted, and test_sanity_check runs both in each direction: checking one way only, or starting a group at a cell that does not join, made each one group. - test_crossing_in_a_column: a cell of the set alone in its column, which a crossing there touches, is reducible. Its column, 0, differs from its row, 1, as in no other test of a crossing in a column, so reading the set's rows as its columns went unseen. - test_not_tracked: the strategies of ReductionFactory and CellReductionFactory are tracked as the factory is. - test_factory_defaults: a factory is tracked, of the region "cell" and without a bound unless told otherwise. - test_factory_skips: a cell whose basis has an increasing pattern only, or a decreasing one only, is not finite, and is offered alone. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 119 +++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index df028717..0a986693 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -194,6 +194,21 @@ def unlike_row_decreasing(): 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.""" @@ -243,6 +258,13 @@ 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.""" @@ -301,6 +323,21 @@ def test_reducible(two_rows, apart): 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 @@ -398,6 +435,13 @@ def test_regions(two_rows, apart, row_and_column, one_cell): ReductionFactory(True, "diagonal") +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).""" @@ -496,6 +540,8 @@ def test_children(two_rows): ("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]) @@ -523,6 +569,8 @@ def test_sanity_check(request, fixture, cells, increasing): ("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), @@ -638,6 +686,20 @@ def test_groups_along_a_row(alike_row, unlike_row): 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.""" @@ -697,15 +759,27 @@ def test_seen_through_requirement(seen_through_requirement): ) 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): +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.""" + 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): @@ -882,16 +956,25 @@ def test_unrestricted_cell(): def test_factory_skips(): - """The factory does not offer a single finite cell, though it is reducible and - not rigid, nor a set the rest of the tiling sees nothing of: a factor.""" - finite_cell = tiling_from( - {(0, 0): [Perm((0, 1, 2)), Perm((2, 1, 0))], (1, 0): [Perm((1, 0))]}, - [(Perm((0, 1)), ((0, 0), (1, 0)))], - [], - ) - assert is_reducible(finite_cell, [(0, 0)]) - assert not is_rigid(finite_cell, [(0, 0)]) - assert list(ReductionFactory(True, "cell")(finite_cell)) == [] + """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)] ) @@ -992,12 +1075,22 @@ def test_bounded_compositions(): ) -def test_not_tracked(two_rows): +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): From 90b040d426096802c5d7efced3a384a6b960541b Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 10:09:30 +0000 Subject: [PATCH 21/21] Tests of a column's rigidity away from row 0, extra_parameters without the children, the factories' names and can_be_equivalent - test_rigid's column is in rows 1 and 2 of column 0. In rows 0 and 1, the first cell's row equals the second cell's column, so is_rigid comparing those two passed test_rigid, and only test_specification failed. - test_parent_assumptions calls extra_parameters without the children, which it then computes; comb_spec_searcher always passes them. - test_regions checks the names of the factories of the regions "row", "column", "line" and "any", with a bound and without. - test_children checks that the rule is never an equivalence. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_reduction.py | 34 ++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/strategies/test_reduction.py b/tests/strategies/test_reduction.py index 0a986693..b8b54e19 100644 --- a/tests/strategies/test_reduction.py +++ b/tests/strategies/test_reduction.py @@ -389,13 +389,14 @@ def test_rigid(two_rows): ) 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 - increasing_cells = {(0, 0): [Perm((1, 0))], (0, 1): [Perm((1, 0))]} - column = [(0, 0), (0, 1)] + # 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, 1), (0, 0))), - (Perm((0, 1)), ((0, 0), (0, 1))), + (Perm((1, 0)), ((0, 2), (0, 1))), + (Perm((0, 1)), ((0, 1), (0, 2))), ): assert is_rigid(tiling_from(increasing_cells, [crossing], []), column) @@ -433,6 +434,17 @@ def test_regions(two_rows, apart, row_and_column, one_cell): ) 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(): @@ -527,6 +539,8 @@ def test_children(two_rows): 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( @@ -937,11 +951,17 @@ def test_threshold_inverse(threshold): def test_parent_assumptions(apart): - """The parent's tracked cells keep their counts.""" - rule = ReductionStrategy([(1, 0), (2, 2)], True)(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():