Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tabbycat/draw/generator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
("bub_dn_accom", _("Bubble down (to accommodate)")),
("no_bub_updn", _("Can't bubble up/down")),
("pullup", _("Pull-up team")),
("side_imb", _("Side imbalance")),
("seen_pullup", _("Team previously saw pullup")),
("deviation", _("Pairing deviation")),
("history", _("History conflict")),
("inst", _("Institution conflict")),
)

def get_two_team_generator(draw_type, avoid_conflicts='australs', side_allocations=None, **kwargs):
Expand Down
28 changes: 19 additions & 9 deletions tabbycat/draw/generator/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@ def avoid_conflicts(self, pairings):
"""Graph optimisation avoids conflicts, so method is extraneous."""
pass

def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:
def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]:
if t1 is t2: # Same team
return

penalty = 0
if self.options["avoid_history"]:
penalty += t1.seen(t2) * self.options["history_penalty"]
seen = t1.seen(t2)
if seen:
flags.append(f'history|{seen}')
penalty += seen * self.options["history_penalty"]
if self.options["avoid_institution"] and t1.same_institution(t2):
flags.append('inst')
penalty += self.options["institution_penalty"]

# Add penalty of a side imbalance
Expand All @@ -40,7 +44,7 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:

if self.options["max_times_on_one_side"] > 0:
if max(t1_affs, t1_negs, t2_affs, t1_negs) > self.options["max_times_on_one_side"]:
return None
return

# Only declare an imbalance if both sides have been on the same side more often
# Affs are positive, negs are negative. If teams have opposite signs, negative imbalance
Expand All @@ -53,6 +57,9 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:
# (+5 - +4) becoming (+4 - +5), in a severe case.
magnitude = (abs(t1_affs - t1_negs) + abs(t2_affs - t2_negs)) // 2

if imbalance and magnitude:
flags.append(f'side_imb|{magnitude}')

penalty += imbalance * magnitude * self.options["side_penalty"]

return penalty
Expand All @@ -71,14 +78,17 @@ def generate_pairings(self, brackets):
n_teams = self.get_n_teams(teams)
for k, t1 in enumerate(teams):
for t2 in teams[k+1:]:
penalty = self.assignment_cost(t1, t2, n_teams, j)
flags = []
team_flags = {t: [] for t in [t1, t2]}
penalty = self.assignment_cost(t1, t2, n_teams, flags, team_flags, j)
if penalty is not None:
graph.add_edge(t1, t2, weight=penalty)
graph.add_edge(t1, t2, weight=penalty, flags=flags, team_flags=team_flags)

# nx.nx_pydot.write_dot(graph, sys.stdout)
for pairing in sorted(nx.min_weight_matching(graph), key=lambda p: self.room_rank_ordering(p)):
i += 1
pairings[points].append(Pairing(teams=pairing, bracket=points, room_rank=i))
edge = graph.get_edge_data(*pairing)
pairings[points].append(Pairing(teams=pairing, bracket=points, room_rank=i, flags=edge['flags'], team_flags=edge['team_flags']))

return pairings

Expand All @@ -92,8 +102,8 @@ class GraphAllocatedSidesMixin(GraphGeneratorMixin):
This is possible as assigning the sides creates a bipartite graph rather than
a more complete graph."""

def assignment_cost(self, t1, t2, size):
penalty = super().assignment_cost(t1, t2, size)
def assignment_cost(self, t1, t2, size, flags, team_flags):
penalty = super().assignment_cost(t1, t2, size, flags, team_flags)
if penalty is None:
return munkres.DISALLOWED
return penalty
Expand All @@ -105,7 +115,7 @@ def generate_pairings(self, brackets):
for points, pool in brackets.items():
pairings[points] = []
n_teams = len(pool[DebateSide.AFF]) + len(pool[DebateSide.NEG])
matrix = [[self.assignment_cost(aff, neg, n_teams) for neg in pool[DebateSide.NEG]] for aff in pool[DebateSide.AFF]]
matrix = [[self.assignment_cost(aff, neg, n_teams, [], {}) for neg in pool[DebateSide.NEG]] for aff in pool[DebateSide.AFF]]

for i_aff, i_neg in munkres.Munkres().compute(matrix):
i += 1
Expand Down
56 changes: 33 additions & 23 deletions tabbycat/draw/generator/powerpair.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,28 +288,30 @@ def update_subranks(self, brackets):
pass


class GraphCostMixin:
class PowerPairedGraphCostMixin:

def get_n_teams(self, teams: list['Team']) -> int:
# Use max subrank to get the penalties for match deviations;
# necessary for enumerated seed values
return max([t.subrank for t in teams if t.subrank is not None], default=0)

def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:
penalty = super().assignment_cost(t1, t2, size)
def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]:
penalty = super().assignment_cost(t1, t2, size, flags, team_flags)
if penalty is None:
return None

# Add penalty for seeing the pullup again
if self.options["pullup_debates_penalty"] and t1.points != t2.points:
penalty += max(t1.pullup_debates, t2.pullup_debates) * self.options["pullup_debates_penalty"]
if (add_penalty := max(t1.pullup_debates, t2.pullup_debates)):
team_flags[max([t1, t2], key=attrgetter('points'))].append(f'seen_pullup|{add_penalty}')
penalty += add_penalty * self.options["pullup_debates_penalty"]

# Add penalty for deviations in the pairing method
if self.options["pairing_method"] != "random":
penalty += self.calculate_pairing_penalty(t1, t2, size, bracket)
penalty += self.calculate_pairing_penalty(t1, t2, size, flags, team_flags, bracket)
return penalty

def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int:
def calculate_pairing_penalty(self, t1, t2, size, flags, team_flags, bracket=None) -> int:
subpool_penalty_func = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS)

# Set the subrank to be last for pulled-up teams
Expand All @@ -319,7 +321,10 @@ def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int:
subranks.append(size)
else:
subranks.append(t.subrank)
return subpool_penalty_func(subranks, size, bracket) * self.options["pairing_penalty"]

if imbalance := subpool_penalty_func(subranks, size, bracket):
flags.append(f'deviation|{subpool_penalty_func(subranks, size, bracket)}')
return imbalance * self.options["pairing_penalty"]

@staticmethod
def _pairings_slide(teams, size: int, bracket: Optional[int] = None) -> int:
Expand Down Expand Up @@ -477,11 +482,16 @@ def _one_up_one_down(self, pairings):
pairing.teams = list(new)


class GraphPowerPairedDrawGenerator(GraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator):
pass
class GraphPowerPairedDrawGenerator(PowerPairedGraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator):
def annotate_team_flags(self, pairings):
"""Only flag that can be added is 'pullup', and can only be determined after generation"""
for pairing in pairings:
for team in pairing.teams:
if team.points < max(t.points for t in pairing.teams):
pairing.add_team_flags(team, ['pullup'])


class SingleGraphPowerPairedDrawGenerator(GraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator):
class SingleGraphPowerPairedDrawGenerator(PowerPairedGraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator):

def generate(self):
max_points = max([t.points for t in self.teams if t.points is not None], default=0)
Expand All @@ -498,11 +508,11 @@ def generate(self):
self.annotate_team_flags(draw) # operates in-place
return draw

def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:
def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]:
min_points = min(t1.points, t2.points)
max_points = max(t1.points, t2.points)
size = self.n_teams_per_points[max_points]
penalty = super().assignment_cost(t1, t2, size)
penalty = super().assignment_cost(t1, t2, size, flags, team_flags)
if penalty is None:
return None

Expand All @@ -513,18 +523,25 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]:
return None

pullup_team = min([t1, t2], key=attrgetter('points')) # Include penalty for the pulled up team
team_flags[pullup_team].append(f'pullup|{pullup_team.pullup_magnitude + 1}')
penalty += pullup_team.pullup_magnitude
return penalty

def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int:
def calculate_pairing_penalty(self, t1, t2, size, flags, team_flags, bracket=None) -> int:
subpool_penalty_func = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS)

# Set the subrank to be last for pulled-up teams
if t1.points != t2.points:
team_in_bracket = max([t1, t2], key=attrgetter('points'))
return subpool_penalty_func([team_in_bracket.subrank, size+1], size+1, bracket) * self.options["pairing_penalty"]
penalty = subpool_penalty_func([team_in_bracket.subrank, size+1], size+1, bracket)
if penalty:
flags.append(f'deviation|{penalty}')
return penalty * self.options["pairing_penalty"]

return subpool_penalty_func([t1.subrank, t2.subrank], size, bracket) * self.options["pairing_penalty"]
penalty = subpool_penalty_func([t1.subrank, t2.subrank], size, bracket)
if penalty:
flags.append(f'deviation|{penalty}')
return penalty * self.options["pairing_penalty"]

def annotate_team_pullup_precedence(self, teams):
sort_function = self.get_option_function("odd_bracket", self.ODD_BRACKET_FUNCTIONS)
Expand Down Expand Up @@ -571,13 +588,6 @@ def _pullup_lowest_ds_rank(team, size=None):
def _pullup_lowest_ds_rank_npulls(team, size=None):
return [team.npullups, -team.draw_strength_rank]

def annotate_team_flags(self, pairings):
"""Only flag that can be added is 'pullup', and can only be determined after generation"""
for pairing in pairings:
for team in pairing.teams:
if team.points < max(t.points for t in pairing.teams):
pairing.add_team_flags(team, ['pullup'])


class AustralsPowerPairedDrawGenerator(AustralsPairingMixin, BasePowerPairedDrawGenerator):
pass
Expand Down Expand Up @@ -845,7 +855,7 @@ def _intermediate_brackets_with_up_down():
raise NotImplementedError("Intermediate brackets with conflict avoidance isn't supported with allocated sides.")


class GraphPowerPairedWithAllocatedSidesDrawGenerator(GraphCostMixin, GraphAllocatedSidesMixin, PowerPairedWithAllocatedSidesDrawGenerator):
class GraphPowerPairedWithAllocatedSidesDrawGenerator(PowerPairedGraphCostMixin, GraphAllocatedSidesMixin, PowerPairedWithAllocatedSidesDrawGenerator):
pass


Expand Down
55 changes: 28 additions & 27 deletions tabbycat/draw/tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,18 +451,18 @@ class TestPowerPairedDrawGenerator(unittest.TestCase):
pairing_penalty=1,
),
[(12, 2, [], [], ['pullup'], True),
(3, 14, [], [], [], True),
(17, 11, [], [], [], True), # Prefers a 2-pairing deviation
(3, 14, ['deviation|1'], [], [], True),
(17, 11, ['deviation|2'], [], [], True), # Prefers a 2-pairing deviation
(8, 6, [], [], [], True),
(4, 7, [], [], ['pullup'], True),
(9, 24, [], [], [], False),
(15, 23, [], [], [], True),
(4, 7, ['deviation|1'], [], ['pullup'], True),
(9, 24, ['deviation|1'], [], [], False),
(15, 23, ['deviation|1'], [], [], True),
(18, 25, [], [], [], False),
(22, 1, [], [], ['pullup'], True),
(5, 21, [], [], [], True),
(10, 20, [], [], [], False),
(10, 20, ['deviation|2'], [], [], False),
(16, 26, [], [], [], True),
(19, 13, [], [], ['pullup'], True)]]
(19, 13, ['deviation|2'], [], ['pullup'], True)]]

expected[6] = [ # Should be identical to [5]
dict(
Expand All @@ -478,19 +478,19 @@ class TestPowerPairedDrawGenerator(unittest.TestCase):
pairing_penalty=1,
pullup_penalty=10,
),
[(12, 2, [], [], ['pullup'], True),
(3, 14, [], [], [], True),
(17, 11, [], [], [], True),
[(12, 2, [], [], ['pullup|1'], True),
(3, 14, ['deviation|1'], [], [], True),
(17, 11, ['deviation|2'], [], [], True),
(8, 6, [], [], [], True),
(4, 7, [], [], ['pullup'], True),
(9, 24, [], [], [], False),
(15, 23, [], [], [], True),
(4, 7, ['deviation|1'], [], ['pullup|1'], True),
(9, 24, ['deviation|1'], [], [], False),
(15, 23, ['deviation|1'], [], [], True),
(18, 25, [], [], [], False),
(22, 1, [], [], ['pullup'], True),
(22, 1, [], [], ['pullup|1'], True),
(5, 21, [], [], [], True),
(10, 20, [], [], [], False),
(10, 20, ['deviation|2'], [], [], False),
(16, 26, [], [], [], True),
(19, 13, [], [], ['pullup'], True)]]
(19, 13, ['deviation|2'], [], ['pullup|1'], True)]]

combinations = [(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6)]

Expand All @@ -510,19 +510,20 @@ def test_draw(self):
actual_teams = tuple([t.id for t in actual.teams])
expected_teams = (exp_aff, exp_neg)

if same_affs:
self.assertEqual(set(actual_teams), set(expected_teams))
else:
self.assertEqual(actual_teams, expected_teams)
with self.subTest(aff=exp_aff, neg=exp_neg):
if same_affs:
self.assertEqual(set(actual_teams), set(expected_teams))
else:
self.assertEqual(actual_teams, expected_teams)

self.assertEqual(actual.flags, exp_flags)
self.assertEqual(actual.flags, exp_flags)

if exp_aff == actual.teams[0].id:
self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_aff_flags)
self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_neg_flags)
else:
self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_aff_flags)
self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_neg_flags)
if exp_aff == actual.teams[0].id:
self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_aff_flags)
self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_neg_flags)
else:
self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_aff_flags)
self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_neg_flags)


class TestPowerPairedWithAllocatedSidesDrawGeneratorPartOddBrackets(unittest.TestCase):
Expand Down
Loading