Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
219 changes: 172 additions & 47 deletions tests/strategies/test_fusion_strat.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -304,22 +304,40 @@ 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 = [
GriddedPerm.single_cell((0, 1), (0, 0)),
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(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,))
Expand All @@ -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)(
Expand Down Expand Up @@ -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))
7 changes: 5 additions & 2 deletions tilings/strategies/fusion/constructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading