diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c317fe0..2c2e1b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed +- The `NotImplementedError` of `FusionConstructor.get_equation` for a fusion whose + two sides are given the same value, such as one with an untracked parent, says + so, and that adding to the parent an assumption tracking one side of the region + first makes the fusion one-sided. ## [4.1.0] - 2026-01-15 ### Changed diff --git a/tests/strategies/test_fusion_strat.py b/tests/strategies/test_fusion_strat.py index 3427b045..a3a25f94 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -1,5 +1,5 @@ import pytest -from sympy import Eq, Function, var +from sympy import Eq, Function, Lambda, cancel, expand, var from comb_spec_searcher.strategies import Rule from tilings import GriddedPerm, Tiling @@ -304,7 +304,13 @@ def easy_fusable( track_left=False, track_right=False, same_tracking=False, + pos_either=False, + tracked_beyond=(), ): + """Two decreasing cells, (0, 0) and (1, 0), every point of the left one + above those of the right one, so that their columns fuse. With pos_either, a + point is required in one of them; tracked_beyond adds an increasing cell (2, 0) + beside them and an assumption tracking each list of cells it gives.""" if same_tracking: assert track_left and track_right obs = [ @@ -312,14 +318,26 @@ def easy_fusable( GriddedPerm((0, 1), ((0, 0), (1, 0))), GriddedPerm.single_cell((0, 1), (1, 0)), ] + if tracked_beyond: + obs.append(GriddedPerm.single_cell((1, 0), (2, 0))) reqs = [] if pos_left: reqs.append([GriddedPerm.single_cell((0,), (0, 0))]) if pos_right: reqs.append([GriddedPerm.single_cell((0,), (1, 0))]) + if pos_either: + reqs.append( + [ + GriddedPerm.single_cell((0,), (0, 0)), + GriddedPerm.single_cell((0,), (1, 0)), + ] + ) - ass = [] + ass = [ + TrackingAssumption([GriddedPerm.single_cell((0,), cell) for cell in cells]) + for cells in tracked_beyond + ] if same_tracking: ass.append( TrackingAssumption( @@ -339,19 +357,18 @@ def easy_fusable( return tiling +def eq_equality(e1, e2): + return e1.lhs == e2.lhs and (e1.rhs - e2.rhs).simplify() == 0 + + def test_fusion_gfs(): + """The equations of fusions whose two sides are given different values. Those + whose sides are given the same value have none + (test_fusion_with_both_sides_the_same_value).""" x = var("x") k_0 = var("k_0") k_1 = var("k_1") - def eq_equality(e1, e2): - return (e1.rhs - e2.rhs).simplify() == 0 - - t1 = easy_fusable() - rules = list(FusionFactory()(t1)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t2 = easy_fusable(track_left=True) rules = list(FusionFactory()(t2)) assert len(rules) == 1 @@ -361,11 +378,6 @@ def eq_equality(e1, e2): expected_eq = Eq(F0, (k_0 * F1 - F1.subs({k_0: 1})) / (k_0 - 1)) assert eq_equality(actual_eq, expected_eq) - t3 = easy_fusable(track_left=True, track_right=True, same_tracking=True) - rules = list(FusionFactory()(t3)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t4 = easy_fusable(track_left=True, track_right=True, same_tracking=False) rules = list(FusionFactory()(t4)) assert len(rules) == 1 @@ -375,11 +387,6 @@ def eq_equality(e1, e2): expected_eq = Eq(F0, (k_0 * F1 - k_1 * F1.subs({k_0: k_1})) / (k_0 - k_1)) assert eq_equality(actual_eq, expected_eq) - t5 = easy_fusable(pos_left=True) - rules = list(FusionFactory()(t5)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t6 = easy_fusable(pos_left=True, track_left=True) rules = list(FusionFactory()(t6)) assert len(rules) == 1 @@ -398,13 +405,6 @@ def eq_equality(e1, e2): expected_eq = Eq(F0, (k_0 * F1 - F1.subs({k_0: 1})) / (k_0 - 1) - F1) assert eq_equality(actual_eq, expected_eq) - t8 = easy_fusable( - pos_left=True, track_left=True, track_right=True, same_tracking=True - ) - rules = list(FusionFactory()(t8)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t9 = easy_fusable( pos_left=True, track_left=True, track_right=True, same_tracking=False ) @@ -429,11 +429,6 @@ def eq_equality(e1, e2): expected_eq = Eq(F0, (k_0 * F1 - k_1 * F1.subs({k_0: k_1})) / (k_0 - k_1) - F1) assert eq_equality(actual_eq, expected_eq) - t11 = easy_fusable(pos_left=True, pos_right=True) - rules = list(FusionFactory()(t11)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t12 = easy_fusable(pos_left=True, pos_right=True, track_left=True) rules = list(FusionFactory()(t12)) assert len(rules) == 1 @@ -445,17 +440,6 @@ def eq_equality(e1, e2): ) assert eq_equality(actual_eq, expected_eq) - t13 = easy_fusable( - pos_left=True, - pos_right=True, - track_left=True, - track_right=True, - same_tracking=True, - ) - rules = list(FusionFactory()(t13)) - assert len(rules) == 1 - # EQ NOT IMPLEMENTED - t14 = easy_fusable( pos_left=True, pos_right=True, @@ -465,7 +449,6 @@ def eq_equality(e1, e2): ) rules = list(FusionFactory()(t14)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED F0 = Function("F0")(x, k_0, k_1) F1 = Function("F1")(x, k_0) actual_eq = rules[0].constructor.get_equation(F0, (F1,)) @@ -475,9 +458,6 @@ def eq_equality(e1, e2): ) assert eq_equality(actual_eq, expected_eq) - # TODO: Add tests for the versions where there is a list requirement with a point - # in each cell. Equations for this are not currently implemented. - def test_indexed_forward_map(): assert FusionStrategy(col_idx=0, tracked=True)( @@ -564,3 +544,148 @@ def test_indexed_backward_map(): r.indexed_backward_map((gp,), i, True) == target for i, target in enumerate(reversed(order)) ) + + +def column_fusion(tiling): + """The rule fusing the tiling's columns 0 and 1. On a tiling of one row and two + columns it is the only rule FusionFactory makes; a tiling with a third column has + another fusion too.""" + rules = list(FusionFactory()(tiling)) + if tiling.dimensions == (2, 1): + assert len(rules) == 1 + rules = [rule for rule in rules if rule.formal_step == "fuse columns 0 and 1"] + assert len(rules) == 1 + return rules[0] + + +def objects_series(tiling, parameters, x, largest): + """The tiling's objects of size at most largest, as a polynomial in x and the + parameters.""" + total = 0 + for size in range(largest + 1): + for gridded_perm in tiling.objects_of_size(size): + term = x**size + for parameter, value in zip( + parameters, tiling.get_parameters(gridded_perm) + ): + term *= parameter**value + total += term + return expand(total) + + +def equation_gives_the_parent(rule, largest=4): + """Whether the rule's equation, expanded with the child's objects, gives the + parent's objects of each size up to largest, monomial by monomial in the + parent's parameters.""" + x = var("x") + parent, child = rule.comb_class, rule.children[0] + parent_parameters = [var(name) for name in parent.extra_parameters] + child_parameters = [var(name) for name in child.extra_parameters] + child_function = Function("F1") + equation = rule.constructor.get_equation( + Function("F0")(x, *parent_parameters), + (child_function(x, *child_parameters),), + ) + child_series = objects_series(child, child_parameters, x, largest) + expanded = expand( + equation.rhs.replace( + child_function, Lambda((x, *child_parameters), child_series) + ).doit() + ) + parent_series = objects_series(parent, parent_parameters, x, largest) + return all( + expand(cancel(expanded.coeff(x, size) - parent_series.coeff(x, size))) == 0 + for size in range(largest + 1) + ) + + +@pytest.mark.parametrize("side", [(0, 0), (1, 0)], ids=["left", "right"]) +def test_fusion_one_sided_beyond_the_region(side): + """One parameter of the parent tracks a side of the region, the left cell or + the right one, and the cell (2, 0) beside the region together. It is + one-sided, so the equation stays the divided difference, though both sides of + the region are given the value 1.""" + tiling = easy_fusable(tracked_beyond=[[side, (2, 0)]]) + assert equation_gives_the_parent(column_fusion(tiling)) + + +def test_fusion_both_sides_tracked_beside_a_tracked_cell(): + """The parent tracks the two sides of the region apart, k_0 and k_1, and a cell + (2, 0) beside it, k_2, which the child's k_1 is given while its k_0 is given the + sides' values: substituting one name at a time would give k_0's place the value + of k_1 and then that of k_2, so the child's parameters must be substituted + simultaneously.""" + tiling = easy_fusable(track_left=True, track_right=True, tracked_beyond=[[(2, 0)]]) + assert equation_gives_the_parent(column_fusion(tiling)) + + +TRACKINGS = { + "untracked": {}, + "left tracked": {"track_left": True}, + "right tracked": {"track_right": True}, + "both tracked apart": {"track_left": True, "track_right": True}, + "both tracked together": { + "track_left": True, + "track_right": True, + "same_tracking": True, + }, +} +POSITIVES = { + "none positive": {}, + "left positive": {"pos_left": True}, + "right positive": {"pos_right": True}, + "both positive": {"pos_left": True, "pos_right": True}, + "either positive": {"pos_either": True}, +} + + +SAME_VALUE = ("untracked", "both tracked together") # both sides given the same value + + +@pytest.mark.parametrize( + "tracking", [name for name in TRACKINGS if name not in SAME_VALUE] +) +@pytest.mark.parametrize("positive", POSITIVES) +def test_fusion_equation_counts(tracking, positive): + """The equation of each of easy_fusable's fusions with a one-sided parameter, + against the objects.""" + tiling = easy_fusable(**TRACKINGS[tracking], **POSITIVES[positive]) + assert equation_gives_the_parent(column_fusion(tiling)) + + +@pytest.mark.parametrize("tracking", SAME_VALUE) +@pytest.mark.parametrize("positive", POSITIVES) +def test_fusion_with_both_sides_the_same_value(tracking, positive): + """A fusion whose two sides are given the same value, its parent untracked or + tracking the whole region, has no equation, and the error says so.""" + tiling = easy_fusable(**TRACKINGS[tracking], **POSITIVES[positive]) + with pytest.raises(NotImplementedError, match="given the same value"): + equation_gives_the_parent(column_fusion(tiling)) + + +@pytest.mark.parametrize( + "fusion", + ["row_fusion", "col_fusion", "component_row_fusion", "component_col_fusion"], +) +def test_fusion_of_fixtures_with_both_sides_the_same_value(request, fusion): + """The fusions of small_tiling, whose fused rows and columns have two cells + each, and the component fusions, their parents untracked, have no equation.""" + with pytest.raises(NotImplementedError, match="given the same value"): + equation_gives_the_parent(request.getfixturevalue(fusion)) + + +@pytest.mark.parametrize( + "positive", + [{"pos_left": True}, {"pos_right": True}, {"pos_left": True, "pos_right": True}], + ids=["left positive", "right positive", "both positive"], +) +def test_fusion_both_sides_tracked_beside_a_tracked_cell_with_a_positive_side( + positive, +): + """As test_fusion_both_sides_tracked_beside_a_tracked_cell, with a positive + side, so that the equation also subtracts the child with one side empty, whose + parameters must be substituted simultaneously too.""" + tiling = easy_fusable( + track_left=True, track_right=True, tracked_beyond=[[(2, 0)]], **positive + ) + assert equation_gives_the_parent(column_fusion(tiling)) diff --git a/tilings/strategies/fusion/constructor.py b/tilings/strategies/fusion/constructor.py index 0f279237..69ff58d5 100644 --- a/tilings/strategies/fusion/constructor.py +++ b/tilings/strategies/fusion/constructor.py @@ -211,8 +211,11 @@ def get_equation( q *= sympy.var(parent_fuse_parameter) if left_vars == 1 and right_vars == 1 and p == q: raise NotImplementedError( - "Not handled case with no left and right vars, and new fuse " - "parameter, or only parent fusion parameter covered entire region" + "No equation for a fusion whose two sides are given the same " + "value: it has no one-sided parameters, and the parent's fusion " + "parameters, if any, cover the whole region. Adding to the parent " + "an assumption that tracks one side of the region first makes the " + "fusion one-sided." ) subs1 = {**subs} subs1[self.fuse_parameter] = q / left_vars