From 7111b609775d81f79d89c979d3d753f432a03b7c Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Wed, 21 Oct 2026 08:50:02 +0000 Subject: [PATCH 1/6] Give the equation of a fusion whose sides take the same value FusionConstructor.get_equation raised NotImplementedError when both sides of the fused region are given the same value: no one-sided parameters, and the parent's fusion parameters, if any, covering the whole region, as for an untracked parent with a tracked child. Its divided difference (q F(q) - p F(p)) / (q - p) then has q = p, and its limit is the derivative F(q) + q F'(q): each child with k points in the fused region gives k + 1 parents. A side that must hold a point loses the split that leaves it empty, F(q) for each, as in the other cases. test_fusion_gfs gets the six equations it marked as not implemented, and a new test checks the derivative against the counting of three such fusions. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ tests/strategies/test_fusion_strat.py | 61 +++++++++++++++++++++--- tilings/strategies/fusion/constructor.py | 30 ++++++++++-- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c317fe0..245be0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed +- `FusionConstructor.get_equation` gives the equation of a fusion whose two sides + are given the same value, such as one with an untracked parent and a tracked + child: the derivative F(q) + q F'(q) of the child's function F of the fuse + parameter, less F(q) for each side that must hold a point. It used to raise + NotImplementedError. ## [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..051fbdcf 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 Derivative, Eq, Function, Lambda, expand, var from comb_spec_searcher.strategies import Rule from tilings import GriddedPerm, Tiling @@ -350,7 +350,11 @@ def eq_equality(e1, e2): t1 = easy_fusable() rules = list(FusionFactory()(t1)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, F1.subs({k_0: 1}) + Derivative(F1, k_0).subs({k_0: 1})) + assert eq_equality(actual_eq, expected_eq) t2 = easy_fusable(track_left=True) rules = list(FusionFactory()(t2)) @@ -364,7 +368,11 @@ def eq_equality(e1, e2): t3 = easy_fusable(track_left=True, track_right=True, same_tracking=True) rules = list(FusionFactory()(t3)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x, k_0) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, F1 + k_0 * Derivative(F1, k_0)) + assert eq_equality(actual_eq, expected_eq) t4 = easy_fusable(track_left=True, track_right=True, same_tracking=False) rules = list(FusionFactory()(t4)) @@ -378,7 +386,11 @@ def eq_equality(e1, e2): t5 = easy_fusable(pos_left=True) rules = list(FusionFactory()(t5)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, Derivative(F1, k_0).subs({k_0: 1})) + assert eq_equality(actual_eq, expected_eq) t6 = easy_fusable(pos_left=True, track_left=True) rules = list(FusionFactory()(t6)) @@ -403,7 +415,11 @@ def eq_equality(e1, e2): ) rules = list(FusionFactory()(t8)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x, k_0) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, k_0 * Derivative(F1, k_0)) + assert eq_equality(actual_eq, expected_eq) t9 = easy_fusable( pos_left=True, track_left=True, track_right=True, same_tracking=False @@ -432,7 +448,11 @@ def eq_equality(e1, e2): t11 = easy_fusable(pos_left=True, pos_right=True) rules = list(FusionFactory()(t11)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, Derivative(F1, k_0).subs({k_0: 1}) - F1.subs({k_0: 1})) + assert eq_equality(actual_eq, expected_eq) t12 = easy_fusable(pos_left=True, pos_right=True, track_left=True) rules = list(FusionFactory()(t12)) @@ -454,7 +474,11 @@ def eq_equality(e1, e2): ) rules = list(FusionFactory()(t13)) assert len(rules) == 1 - # EQ NOT IMPLEMENTED + F0 = Function("F0")(x, k_0) + F1 = Function("F1")(x, k_0) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + expected_eq = Eq(F0, k_0 * Derivative(F1, k_0) - F1) + assert eq_equality(actual_eq, expected_eq) t14 = easy_fusable( pos_left=True, @@ -564,3 +588,26 @@ def test_indexed_backward_map(): r.indexed_backward_map((gp,), i, True) == target for i, target in enumerate(reversed(order)) ) + + +def test_fusion_derivative_equation_counts(): + """ + When both sides of the fused region are given the same value, the equation + is a derivative. It must agree with the counting: each child with k points + in the fused region gives k + 1 parents, less the splits leaving a side + that must hold a point empty. + """ + x, k_0 = var("x"), var("k_0") + for kwargs in ({}, {"pos_left": True}, {"pos_left": True, "pos_right": True}): + rule = list(FusionFactory()(easy_fusable(**kwargs)))[0] + parent, child = rule.comb_class, rule.children[0] + F0, F1 = Function("F0")(x), Function("F1")(x, k_0) + eq = rule.constructor.get_equation(F0, (F1,)) + series = sum( + x**n * k_0 ** child.get_parameters(gp)[0] + for n in range(6) + for gp in child.objects_of_size(n) + ) + rhs = expand(eq.rhs.replace(Function("F1"), Lambda((x, k_0), series)).doit()) + for n in range(6): + assert rhs.coeff(x, n) == sum(1 for _ in parent.objects_of_size(n)) diff --git a/tilings/strategies/fusion/constructor.py b/tilings/strategies/fusion/constructor.py index 0f279237..722294a5 100644 --- a/tilings/strategies/fusion/constructor.py +++ b/tilings/strategies/fusion/constructor.py @@ -210,10 +210,7 @@ def get_equation( if fuse_type in ("right", "both"): 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" - ) + return self._derivative_equation(lhs_func, rhs_func, subs, q) subs1 = {**subs} subs1[self.fuse_parameter] = q / left_vars subs2 = {**subs} @@ -241,6 +238,31 @@ def get_equation( - to_subtract, ) + def _derivative_equation( + self, + lhs_func: sympy.Function, + rhs_func: sympy.Function, + subs: Dict[str, Any], + value: sympy.Expr, + ) -> sympy.Eq: + """ + The equation when both sides of the fused region are given the same + value. The divided difference (q F(q) - p F(p)) / (q - p) of the other + cases, with F the child's function of the fuse parameter, is then its + limit, the derivative F(q) + q F'(q): each child with k points in the + fused region gives k + 1 parents. A side that must hold a point loses + the split that leaves it empty, F(q) for each. + """ + fuse_subs = {**subs, self.fuse_parameter: value} + at_value = rhs_func.subs(fuse_subs, simultaneous=True) + derivative = sympy.Derivative(rhs_func, sympy.var(self.fuse_parameter)) + slope = derivative.subs( # pylint: disable=assignment-from-no-return + fuse_subs, simultaneous=True + ) + return sympy.Eq( + lhs_func, at_value + value * slope - sum(self.min_points) * at_value + ) + def reliance_profile(self, n: int, **parameters: int) -> RelianceProfile: raise NotImplementedError From 65b7a510d40133a578329bf71cf826f0e8e1370e Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Wed, 21 Oct 2026 13:28:36 +0000 Subject: [PATCH 2/6] Descriptive variable names in the counting test As a reviewer asked: parent_function, child_function, equation, gridded_perm, size and expanded in place of F0, F1, eq, gp, n and rhs, in the test this pull request adds. The cases filled in test_fusion_gfs keep that test's own names. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_fusion_strat.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/strategies/test_fusion_strat.py b/tests/strategies/test_fusion_strat.py index 051fbdcf..2f0d63ef 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -601,13 +601,18 @@ def test_fusion_derivative_equation_counts(): for kwargs in ({}, {"pos_left": True}, {"pos_left": True, "pos_right": True}): rule = list(FusionFactory()(easy_fusable(**kwargs)))[0] parent, child = rule.comb_class, rule.children[0] - F0, F1 = Function("F0")(x), Function("F1")(x, k_0) - eq = rule.constructor.get_equation(F0, (F1,)) + parent_function = Function("F0")(x) + child_function = Function("F1")(x, k_0) + equation = rule.constructor.get_equation(parent_function, (child_function,)) series = sum( - x**n * k_0 ** child.get_parameters(gp)[0] - for n in range(6) - for gp in child.objects_of_size(n) + x**size * k_0 ** child.get_parameters(gridded_perm)[0] + for size in range(6) + for gridded_perm in child.objects_of_size(size) ) - rhs = expand(eq.rhs.replace(Function("F1"), Lambda((x, k_0), series)).doit()) - for n in range(6): - assert rhs.coeff(x, n) == sum(1 for _ in parent.objects_of_size(n)) + expanded = expand( + equation.rhs.replace(Function("F1"), Lambda((x, k_0), series)).doit() + ) + for size in range(6): + assert expanded.coeff(x, size) == sum( + 1 for _ in parent.objects_of_size(size) + ) From 2ca7374a84a4bba2d36373fd4c272a3b00cc1ea1 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 11:32:19 +0000 Subject: [PATCH 3/6] The fusion's derivative equation differentiates in a variable of its own When both sides of the fused region are given the same value, the equation differentiated the child's function in the fuse parameter after substituting the parent's parameters into it. The child's fuse parameter can have the name of a parent's parameter that another of the child's parameters is given: with the parent tracking a cell beside the region and not the region, the child's fuse parameter is k_0, and its parameter for that cell, k_1, is given the parent's k_0. sympy kept the substitution in a Subs of the derivative, whose bound k_0 took in the parent's k_0, so the derivative went through both, and the equation was wrong. The child's function is now written in a sympy Dummy for the fuse parameter, the parent's parameters substituted simultaneously, and differentiated in the Dummy. A comment says why the line keeps its pylint disable: pylint takes sympy's subs for a function that returns None. The new tests compare each equation with the objects, monomial by monomial in the parent's parameters, to size 4: - test_fusion_derivative_beside_a_tracked_cell: a cell (2, 0) beside the region tracked, and the region too or not; the first was wrong before. - test_fusion_one_sided_beyond_the_region: one parameter tracking a side of the region and (2, 0) together. It is one-sided, so the equation stays the divided difference, though both sides are given the value 1; a guard reduced to p == q passed every test before. - test_fusion_derivative_beside_eleven_tracked_cells: the child's k_1 to k_11 given the parent's k_0 to k_10. sympy substitutes a dictionary's names in sorted order, k_10 before k_9, so one at a time would give k_10's place the parent's k_8; with fewer parameters the order never matters here. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_fusion_strat.py | 112 ++++++++++++++++++++++- tilings/strategies/fusion/constructor.py | 18 +++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/tests/strategies/test_fusion_strat.py b/tests/strategies/test_fusion_strat.py index 2f0d63ef..7e1579fa 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -1,5 +1,5 @@ import pytest -from sympy import Derivative, Eq, Function, Lambda, expand, var +from sympy import Derivative, Eq, Function, Lambda, cancel, expand, var from comb_spec_searcher.strategies import Rule from tilings import GriddedPerm, Tiling @@ -304,7 +304,12 @@ def easy_fusable( track_left=False, track_right=False, same_tracking=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. 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,6 +317,8 @@ 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: @@ -319,7 +326,10 @@ def easy_fusable( if pos_right: reqs.append([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( @@ -616,3 +626,101 @@ def test_fusion_derivative_equation_counts(): assert expanded.coeff(x, size) == sum( 1 for _ in parent.objects_of_size(size) ) + + +def column_fusion(tiling): + """The rule fusing the tiling's columns 0 and 1.""" + rules = [ + rule + for rule in FusionFactory()(tiling) + 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("track_region", [False, True]) +def test_fusion_derivative_beside_a_tracked_cell(track_region): + """The parent tracks a cell (2, 0) beside the fused region, and the region too + or not. The child's fuse parameter can have the name of the parent's parameter + of (2, 0), which the child's parameter of (2, 0) is given: the equation must + differentiate in the fuse parameter alone.""" + region = {"track_left": True, "track_right": True, "same_tracking": True} + tiling = easy_fusable(tracked_beyond=[[(2, 0)]], **(region if track_region else {})) + assert equation_gives_the_parent(column_fusion(tiling)) + + +@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_derivative_beside_eleven_tracked_cells(): + """The pair of easy_fusable beside eleven tracked cells of at most one point + each, so that the child's parameters k_1 to k_11 are given the parent's k_0 + to k_10. Substituting them one at a time, in sympy's order of their names, + which puts k_10 before k_9, would give k_10's place the parent's k_8: the + child's parameters must be substituted simultaneously.""" + beside = [(column, 0) for column in range(2, 13)] + tiling = Tiling( + obstructions=[ + GriddedPerm.single_cell((0, 1), (0, 0)), + GriddedPerm((0, 1), ((0, 0), (1, 0))), + GriddedPerm.single_cell((0, 1), (1, 0)), + ] + + [ + GriddedPerm.single_cell(patt, cell) + for cell in beside + for patt in ((0, 1), (1, 0)) + ], + assumptions=[ + TrackingAssumption([GriddedPerm.single_cell((0,), cell)]) for cell in beside + ], + ) + assert equation_gives_the_parent(column_fusion(tiling), largest=2) diff --git a/tilings/strategies/fusion/constructor.py b/tilings/strategies/fusion/constructor.py index 722294a5..adef52ac 100644 --- a/tilings/strategies/fusion/constructor.py +++ b/tilings/strategies/fusion/constructor.py @@ -252,12 +252,22 @@ def _derivative_equation( limit, the derivative F(q) + q F'(q): each child with k points in the fused region gives k + 1 parents. A side that must hold a point loses the split that leaves it empty, F(q) for each. + + The derivative is taken in a variable of its own: the child's fuse + parameter can have the name of a parent's parameter that another of the + child's parameters is given, which substituting into a derivative in the + fuse parameter would then differentiate too. """ - fuse_subs = {**subs, self.fuse_parameter: value} - at_value = rhs_func.subs(fuse_subs, simultaneous=True) - derivative = sympy.Derivative(rhs_func, sympy.var(self.fuse_parameter)) + fuse_variable = sympy.Dummy("fuse") + child_in_fuse_variable = rhs_func.subs( + {**subs, self.fuse_parameter: fuse_variable}, simultaneous=True + ) + at_value = child_in_fuse_variable.subs(fuse_variable, value) + derivative = sympy.Derivative(child_in_fuse_variable, fuse_variable) + # sympy's subs returns the substituted expression, which pylint does not + # see, taking it for a function that returns None (E1111) slope = derivative.subs( # pylint: disable=assignment-from-no-return - fuse_subs, simultaneous=True + fuse_variable, value ) return sympy.Eq( lhs_func, at_value + value * slope - sum(self.min_points) * at_value From a7a1e4cc00edc7b74ab73562a0e884623eaefd04 Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Fri, 23 Oct 2026 11:32:19 +0000 Subject: [PATCH 4/6] Tests of the fusion's equations against the objects, the derivative cases each a case of its own - eq_equality compares the left sides too, so that an equation of the child's function in place of the parent's is seen. - test_fusion_derivative_equation: the six equations of fusions whose sides are given the same value, moved from test_fusion_gfs, each a case of its own, so that one failing does not hide the others. - test_fusion_equation_counts: every combination of easy_fusable's trackings and positive sides, among them a point required in either cell, whose equation the derivative now gives: the TODO saying there was none, and t14's stale comment, go. - test_fusion_equation_counts_of_fixtures: the fusions of small_tiling, two cells a side, and the component fusions. test_fusion_derivative_equation_counts, of three untracked fusions, is superseded by test_fusion_equation_counts. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_fusion_strat.py | 197 +++++++++++++------------- 1 file changed, 100 insertions(+), 97 deletions(-) diff --git a/tests/strategies/test_fusion_strat.py b/tests/strategies/test_fusion_strat.py index 7e1579fa..6f4f8d21 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -304,12 +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. tracked_beyond adds - an increasing cell (2, 0) beside them and an assumption tracking each list of - cells it gives.""" + 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 = [ @@ -325,6 +326,13 @@ def easy_fusable( 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 = [ TrackingAssumption([GriddedPerm.single_cell((0,), cell) for cell in cells]) @@ -349,23 +357,17 @@ 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 are in test_fusion_derivative_equation.""" 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 - F0 = Function("F0")(x) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, F1.subs({k_0: 1}) + Derivative(F1, k_0).subs({k_0: 1})) - assert eq_equality(actual_eq, expected_eq) - t2 = easy_fusable(track_left=True) rules = list(FusionFactory()(t2)) assert len(rules) == 1 @@ -375,15 +377,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 - F0 = Function("F0")(x, k_0) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, F1 + k_0 * Derivative(F1, k_0)) - assert eq_equality(actual_eq, expected_eq) - t4 = easy_fusable(track_left=True, track_right=True, same_tracking=False) rules = list(FusionFactory()(t4)) assert len(rules) == 1 @@ -393,15 +386,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 - F0 = Function("F0")(x) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, Derivative(F1, k_0).subs({k_0: 1})) - assert eq_equality(actual_eq, expected_eq) - t6 = easy_fusable(pos_left=True, track_left=True) rules = list(FusionFactory()(t6)) assert len(rules) == 1 @@ -420,17 +404,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 - F0 = Function("F0")(x, k_0) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, k_0 * Derivative(F1, k_0)) - assert eq_equality(actual_eq, expected_eq) - t9 = easy_fusable( pos_left=True, track_left=True, track_right=True, same_tracking=False ) @@ -455,15 +428,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 - F0 = Function("F0")(x) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, Derivative(F1, k_0).subs({k_0: 1}) - F1.subs({k_0: 1})) - assert eq_equality(actual_eq, expected_eq) - t12 = easy_fusable(pos_left=True, pos_right=True, track_left=True) rules = list(FusionFactory()(t12)) assert len(rules) == 1 @@ -475,21 +439,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 - F0 = Function("F0")(x, k_0) - F1 = Function("F1")(x, k_0) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - expected_eq = Eq(F0, k_0 * Derivative(F1, k_0) - F1) - assert eq_equality(actual_eq, expected_eq) - t14 = easy_fusable( pos_left=True, pos_right=True, @@ -499,7 +448,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,)) @@ -509,9 +457,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)( @@ -600,32 +545,51 @@ def test_indexed_backward_map(): ) -def test_fusion_derivative_equation_counts(): - """ - When both sides of the fused region are given the same value, the equation - is a derivative. It must agree with the counting: each child with k points - in the fused region gives k + 1 parents, less the splits leaving a side - that must hold a point empty. - """ +@pytest.mark.parametrize( + "case", + [ + "untracked", + "tracked", + "left positive", + "left positive, tracked", + "both positive", + "both positive, tracked", + ], +) +def test_fusion_derivative_equation(case): + """The equations of fusions whose two sides are given the same value: the + derivative F(q) + q F'(q) of the divided difference, less F(q) for each side + that must hold a point.""" x, k_0 = var("x"), var("k_0") - for kwargs in ({}, {"pos_left": True}, {"pos_left": True, "pos_right": True}): - rule = list(FusionFactory()(easy_fusable(**kwargs)))[0] - parent, child = rule.comb_class, rule.children[0] - parent_function = Function("F0")(x) - child_function = Function("F1")(x, k_0) - equation = rule.constructor.get_equation(parent_function, (child_function,)) - series = sum( - x**size * k_0 ** child.get_parameters(gridded_perm)[0] - for size in range(6) - for gridded_perm in child.objects_of_size(size) - ) - expanded = expand( - equation.rhs.replace(Function("F1"), Lambda((x, k_0), series)).doit() - ) - for size in range(6): - assert expanded.coeff(x, size) == sum( - 1 for _ in parent.objects_of_size(size) - ) + F1 = Function("F1")(x, k_0) + at_one = F1.subs({k_0: 1}) + slope_at_one = Derivative(F1, k_0).subs({k_0: 1}) + region = {"track_left": True, "track_right": True, "same_tracking": True} + fusable, tracked, expected = { + "untracked": ({}, False, at_one + slope_at_one), + "tracked": (region, True, F1 + k_0 * Derivative(F1, k_0)), + "left positive": ({"pos_left": True}, False, slope_at_one), + "left positive, tracked": ( + {"pos_left": True, **region}, + True, + k_0 * Derivative(F1, k_0), + ), + "both positive": ( + {"pos_left": True, "pos_right": True}, + False, + slope_at_one - at_one, + ), + "both positive, tracked": ( + {"pos_left": True, "pos_right": True, **region}, + True, + k_0 * Derivative(F1, k_0) - F1, + ), + }[case] + rules = list(FusionFactory()(easy_fusable(**fusable))) + assert len(rules) == 1 + F0 = Function("F0")(x, k_0) if tracked else Function("F0")(x) + actual_eq = rules[0].constructor.get_equation(F0, (F1,)) + assert eq_equality(actual_eq, Eq(F0, expected)) def column_fusion(tiling): @@ -724,3 +688,42 @@ def test_fusion_derivative_beside_eleven_tracked_cells(): ], ) assert equation_gives_the_parent(column_fusion(tiling), largest=2) + + +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}, +} + + +@pytest.mark.parametrize("tracking", TRACKINGS) +@pytest.mark.parametrize("positive", POSITIVES) +def test_fusion_equation_counts(tracking, positive): + """The equation of each of easy_fusable's fusions, of both kinds, against the + objects.""" + tiling = easy_fusable(**TRACKINGS[tracking], **POSITIVES[positive]) + assert equation_gives_the_parent(column_fusion(tiling)) + + +@pytest.mark.parametrize( + "fusion", + ["row_fusion", "col_fusion", "component_row_fusion", "component_col_fusion"], +) +def test_fusion_equation_counts_of_fixtures(request, fusion): + """The equations of the fusions of small_tiling, whose fused rows and columns + have two cells each, and of the component fusions, against the objects.""" + assert equation_gives_the_parent(request.getfixturevalue(fusion)) From d6088451f43ce3b23614c1b71ed0efcb8792ecfc Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Sun, 25 Oct 2026 13:59:39 +0000 Subject: [PATCH 5/6] No equation where both sides take the same value, and the error says why The derivative equation is taken out again: a fusion whose two sides are given the same value has no equation, as on develop. The error now says which fusions these are, and that adding to the parent an assumption that tracks one side of the region first makes the fusion one-sided. A program that needs the equation can graft that in itself, at the cost of a rule. The tests compare the equations develop gives with the objects, and check that the fusions with no equation raise the error: - test_fusion_equation_counts: the one-sided trackings of easy_fusable with each of its positive sides, a point in either cell among them. - test_fusion_one_sided_beyond_the_region, as before. - test_fusion_both_sides_tracked_beside_a_tracked_cell, in place of the test beside eleven tracked cells: the child's k_1 is given the parent's k_2 and its k_0 the sides' values, so the child's parameters must be substituted simultaneously here too; one at a time fails this test alone. - test_fusion_with_both_sides_the_same_value and test_fusion_of_fixtures_with_both_sides_the_same_value, in place of the derivative's tests: an untracked parent, or one tracking the whole region, with each positive side, and the fixtures' fusions. The CHANGELOG entry is a ### Changed, for the message. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +- tests/strategies/test_fusion_strat.py | 124 +++++++---------------- tilings/strategies/fusion/constructor.py | 43 ++------ 3 files changed, 46 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 245be0d9..2c2e1b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] -### Fixed -- `FusionConstructor.get_equation` gives the equation of a fusion whose two sides - are given the same value, such as one with an untracked parent and a tracked - child: the derivative F(q) + q F'(q) of the child's function F of the fuse - parameter, less F(q) for each side that must hold a point. It used to raise - NotImplementedError. +### 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 6f4f8d21..96ac408e 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -1,5 +1,5 @@ import pytest -from sympy import Derivative, Eq, Function, Lambda, cancel, expand, var +from sympy import Eq, Function, Lambda, cancel, expand, var from comb_spec_searcher.strategies import Rule from tilings import GriddedPerm, Tiling @@ -363,7 +363,8 @@ def eq_equality(e1, e2): def test_fusion_gfs(): """The equations of fusions whose two sides are given different values. Those - whose sides are given the same value are in test_fusion_derivative_equation.""" + 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") @@ -545,53 +546,6 @@ def test_indexed_backward_map(): ) -@pytest.mark.parametrize( - "case", - [ - "untracked", - "tracked", - "left positive", - "left positive, tracked", - "both positive", - "both positive, tracked", - ], -) -def test_fusion_derivative_equation(case): - """The equations of fusions whose two sides are given the same value: the - derivative F(q) + q F'(q) of the divided difference, less F(q) for each side - that must hold a point.""" - x, k_0 = var("x"), var("k_0") - F1 = Function("F1")(x, k_0) - at_one = F1.subs({k_0: 1}) - slope_at_one = Derivative(F1, k_0).subs({k_0: 1}) - region = {"track_left": True, "track_right": True, "same_tracking": True} - fusable, tracked, expected = { - "untracked": ({}, False, at_one + slope_at_one), - "tracked": (region, True, F1 + k_0 * Derivative(F1, k_0)), - "left positive": ({"pos_left": True}, False, slope_at_one), - "left positive, tracked": ( - {"pos_left": True, **region}, - True, - k_0 * Derivative(F1, k_0), - ), - "both positive": ( - {"pos_left": True, "pos_right": True}, - False, - slope_at_one - at_one, - ), - "both positive, tracked": ( - {"pos_left": True, "pos_right": True, **region}, - True, - k_0 * Derivative(F1, k_0) - F1, - ), - }[case] - rules = list(FusionFactory()(easy_fusable(**fusable))) - assert len(rules) == 1 - F0 = Function("F0")(x, k_0) if tracked else Function("F0")(x) - actual_eq = rules[0].constructor.get_equation(F0, (F1,)) - assert eq_equality(actual_eq, Eq(F0, expected)) - - def column_fusion(tiling): """The rule fusing the tiling's columns 0 and 1.""" rules = [ @@ -644,17 +598,6 @@ def equation_gives_the_parent(rule, largest=4): ) -@pytest.mark.parametrize("track_region", [False, True]) -def test_fusion_derivative_beside_a_tracked_cell(track_region): - """The parent tracks a cell (2, 0) beside the fused region, and the region too - or not. The child's fuse parameter can have the name of the parent's parameter - of (2, 0), which the child's parameter of (2, 0) is given: the equation must - differentiate in the fuse parameter alone.""" - region = {"track_left": True, "track_right": True, "same_tracking": True} - tiling = easy_fusable(tracked_beyond=[[(2, 0)]], **(region if track_region else {})) - assert equation_gives_the_parent(column_fusion(tiling)) - - @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 @@ -665,29 +608,14 @@ def test_fusion_one_sided_beyond_the_region(side): assert equation_gives_the_parent(column_fusion(tiling)) -def test_fusion_derivative_beside_eleven_tracked_cells(): - """The pair of easy_fusable beside eleven tracked cells of at most one point - each, so that the child's parameters k_1 to k_11 are given the parent's k_0 - to k_10. Substituting them one at a time, in sympy's order of their names, - which puts k_10 before k_9, would give k_10's place the parent's k_8: the - child's parameters must be substituted simultaneously.""" - beside = [(column, 0) for column in range(2, 13)] - tiling = Tiling( - obstructions=[ - GriddedPerm.single_cell((0, 1), (0, 0)), - GriddedPerm((0, 1), ((0, 0), (1, 0))), - GriddedPerm.single_cell((0, 1), (1, 0)), - ] - + [ - GriddedPerm.single_cell(patt, cell) - for cell in beside - for patt in ((0, 1), (1, 0)) - ], - assumptions=[ - TrackingAssumption([GriddedPerm.single_cell((0,), cell)]) for cell in beside - ], - ) - assert equation_gives_the_parent(column_fusion(tiling), largest=2) +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 = { @@ -710,20 +638,36 @@ def test_fusion_derivative_beside_eleven_tracked_cells(): } -@pytest.mark.parametrize("tracking", TRACKINGS) +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, of both kinds, against the - objects.""" + """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_equation_counts_of_fixtures(request, fusion): - """The equations of the fusions of small_tiling, whose fused rows and columns - have two cells each, and of the component fusions, against the objects.""" - assert equation_gives_the_parent(request.getfixturevalue(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)) diff --git a/tilings/strategies/fusion/constructor.py b/tilings/strategies/fusion/constructor.py index adef52ac..69ff58d5 100644 --- a/tilings/strategies/fusion/constructor.py +++ b/tilings/strategies/fusion/constructor.py @@ -210,7 +210,13 @@ def get_equation( if fuse_type in ("right", "both"): q *= sympy.var(parent_fuse_parameter) if left_vars == 1 and right_vars == 1 and p == q: - return self._derivative_equation(lhs_func, rhs_func, subs, q) + raise NotImplementedError( + "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 subs2 = {**subs} @@ -238,41 +244,6 @@ def get_equation( - to_subtract, ) - def _derivative_equation( - self, - lhs_func: sympy.Function, - rhs_func: sympy.Function, - subs: Dict[str, Any], - value: sympy.Expr, - ) -> sympy.Eq: - """ - The equation when both sides of the fused region are given the same - value. The divided difference (q F(q) - p F(p)) / (q - p) of the other - cases, with F the child's function of the fuse parameter, is then its - limit, the derivative F(q) + q F'(q): each child with k points in the - fused region gives k + 1 parents. A side that must hold a point loses - the split that leaves it empty, F(q) for each. - - The derivative is taken in a variable of its own: the child's fuse - parameter can have the name of a parent's parameter that another of the - child's parameters is given, which substituting into a derivative in the - fuse parameter would then differentiate too. - """ - fuse_variable = sympy.Dummy("fuse") - child_in_fuse_variable = rhs_func.subs( - {**subs, self.fuse_parameter: fuse_variable}, simultaneous=True - ) - at_value = child_in_fuse_variable.subs(fuse_variable, value) - derivative = sympy.Derivative(child_in_fuse_variable, fuse_variable) - # sympy's subs returns the substituted expression, which pylint does not - # see, taking it for a function that returns None (E1111) - slope = derivative.subs( # pylint: disable=assignment-from-no-return - fuse_variable, value - ) - return sympy.Eq( - lhs_func, at_value + value * slope - sum(self.min_points) * at_value - ) - def reliance_profile(self, n: int, **parameters: int) -> RelianceProfile: raise NotImplementedError From 85475570f2f51bc08ac232cc107664b5ede3027d Mon Sep 17 00:00:00 2001 From: Henning Ulfarsson Date: Sun, 25 Oct 2026 14:41:29 +0000 Subject: [PATCH 6/6] One rule on two cells, and the empty split's parameters substituted at once - column_fusion asserts, on a tiling of one row and two columns, that FusionFactory makes only the one fusion, as test_fusion_gfs did for the cases it no longer has. A tiling with a third column has another fusion, so there the rule is picked by its name. - test_fusion_both_sides_tracked_beside_a_tracked_cell_with_a_positive_side: the same tiling with a positive side, so that the equation also subtracts the child with one side empty, whose parameters must be substituted simultaneously too. Substituting them one at a time passed every test before. Co-Authored-By: Claude Opus 5.5 --- tests/strategies/test_fusion_strat.py | 30 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/strategies/test_fusion_strat.py b/tests/strategies/test_fusion_strat.py index 96ac408e..a3a25f94 100644 --- a/tests/strategies/test_fusion_strat.py +++ b/tests/strategies/test_fusion_strat.py @@ -547,12 +547,13 @@ def test_indexed_backward_map(): def column_fusion(tiling): - """The rule fusing the tiling's columns 0 and 1.""" - rules = [ - rule - for rule in FusionFactory()(tiling) - if rule.formal_step == "fuse columns 0 and 1" - ] + """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] @@ -671,3 +672,20 @@ def test_fusion_of_fixtures_with_both_sides_the_same_value(request, fusion): 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))