TabbycatDebate/tabbycat

View on GitHub
tabbycat/draw/generator/powerpair.py

Summary

Maintainability
C
1 day
Test Coverage
B
89%
import random
from collections import OrderedDict
from typing import Optional

from django.utils.translation import gettext as _

from .common import BasePairDrawGenerator, DrawFatalError, DrawUserError
from .graph import GraphAllocatedSidesMixin, GraphGeneratorMixin
from .one_up_one_down import OneUpOneDownSwapper
from .pairing import Pairing


class BasePowerPairedDrawGenerator(BasePairDrawGenerator):
    """Power-paired draw.

    If there are allocated sides, use BasePowerPairedWithAllocatedSidesDrawGenerator
    instead.

    Options:
        "odd_bracket" - Odd bracket resolution method. Permitted values:

            "pullup_top"    - Pull up the top team from the next bracket down.
            "pullup_bottom" - Pull up the bottom team from the next bracket down.
            "pullup_random" - Pull up a random team from the next bracket down.
            "intermediate"  - The bottom team from the odd bracket and the top
                              team from the next bracket down face each other in
                              an intermediate bracket.
            "intermediate_bubble_up_down" - Like "intermediate", but will swap
                              teams that conflict by history or institution.

            or a function taking a dict mapping floats to lists of Team-like
            objects, and operating on the dict in-place.

        "pullup_restriction" - Restriction on who can be pulled up. Permitted values:

            "none"             - No restriction.
            "least_to_date"    - Choose from teams who have been pulled up the
                                 least number of times in previous rounds.
            "lowest_ds_wins"   - Choose from teams who have the lowest draw strength by
                                 wins (indicative of having been against easier teams)
            "lowest_ds_speaks" - Choose from teams who have the lowest draw strength by
                                 speaks (indicative of having been against easier teams)

        "pairing_method" - How to pair teams. Permitted values:
            (best explained by example, these examples have a ten-team bracket)

            "slide"  - 1 vs 6, 2 vs 7, ..., 5 vs 10.
            "fold"   - 1 vs 10, 2 vs 9, ..., 5 vs 6.
            "random" - Pairs chosen randomly.

            or a function taking a dict mapping floats to even-length lists of
            Team-like objects, and returning a list of Pairing objects with
            those teams.

        "avoid_conflicts" - How to avoid conflicts. Permitted values:

            "off"             - Do not attempt to avoid conflicts.
            "one_up_one_down" - Swap conflicted teams with the debate above or
                                below, in accordance with Australasian
                                Intervarsity Debating Association rules.
            "graph"           - Find the minimum-cost matching in a generated
                                graph of the teams in a bracket.
    """

    requires_even_teams = True
    requires_prev_results = False

    DEFAULT_OPTIONS = {
        "odd_bracket"           : "intermediate_bubble_up_down",
        "pairing_method"        : "slide",
        "avoid_conflicts"       : "one_up_one_down",
        "pullup_restriction"    : "none",
        "pullup_debates_penalty": 0,
    }

    PAIRING_FUNCTIONS = {
        "fold"                  : "_pairings_fold",
        "slide"                 : "_pairings_slide",
        "random"                : "_pairings_random",
        "adjacent"              : "_pairings_adjacent",
        "fold_top_adjacent_rest": "_pairings_fold_top_adjacent_rest",
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.check_teams_for_attribute("points")

        if self.options["odd_bracket"].startswith("intermediate"):
            noninteger = [team.points == int(team.points) for team in self.teams].count(False)
            if noninteger > 0:
                raise DrawUserError(_("%(noninteger)d out of %(total)d teams have a noninteger "
                    "first metric in the team standings. Intermediate brackets require the first "
                    "team standings metric to be an integer (typically points or wins).") % {
                    'noninteger': noninteger, 'total': len(self.teams)})

        pullup_metric = self.PULLUP_RESTRICTION_METRICS[self.options["pullup_restriction"]]
        if pullup_metric is not None:
            self.check_teams_for_attribute(pullup_metric, checkfunc=lambda x: isinstance(x, (int, float)))

    def generate(self):
        self._brackets = self._make_raw_brackets()
        self.resolve_odd_brackets(self._brackets)  # operates in-place

        self._pairings = self.generate_pairings(self._brackets)
        self.avoid_conflicts(self._pairings)  # operates in-place

        self._draw = list()
        for bracket in self._pairings.values():
            self._draw.extend(bracket)

        self.allocate_sides(self._draw)  # operates in-place
        self.annotate_team_flags(self._draw)  # operates in-place
        return self._draw

    def _make_raw_brackets(self):
        """Returns an OrderedDict mapping bracket names (normally numbers)
        to lists."""
        brackets = OrderedDict()
        teams = list(self.teams)
        while len(teams) > 0:
            top_team = teams.pop(0)
            points = top_team.points
            pool = [top_team]
            while len(teams) > 0 and teams[0].points == points:
                pool.append(teams.pop(0))
            brackets[points] = pool
        return brackets

    # Pullup restrictions
    PULLUP_RESTRICTION_METRICS = {
        "least_to_date": "npullups",
        "lowest_ds_wins": "draw_strength",
        "lowest_ds_speaks": "draw_strength_speaks",
        "none": None,
    }

    def _pullup_filter(self, teams):
        """Returns a function that takes one argument, a team, and returns a
        bool, indicating whether that team is eligible to be pulled up."""
        option = self.options["pullup_restriction"]
        try:
            metric = self.PULLUP_RESTRICTION_METRICS[option]
        except KeyError:
            raise ValueError("Invalid option for pullup_restriction: {0}".format(option))

        if metric is None:
            return teams
        else:
            least = min(getattr(team, metric) for team in teams)
            return [team for team in teams if getattr(team, metric) == least]

    # Odd bracket resolutions
    ODD_BRACKET_FUNCTIONS = {
        "pullup_top"                 : "_pullup_top",
        "pullup_bottom"              : "_pullup_bottom",
        "pullup_middle"              : "_pullup_middle",
        "pullup_random"              : "_pullup_random",
        "intermediate"               : "_intermediate_brackets",
        "intermediate_bubble_up_down": "_intermediate_brackets_with_bubble_up_down",
    }

    def resolve_odd_brackets(self, brackets):
        """Returns a function taking an OrderedDict as returned by
        _make_raw_brackets(), and adjusting that OrderedDict in-place to
        guarantee that all brackets have an even number of teams."""
        function = self.get_option_function("odd_bracket", self.ODD_BRACKET_FUNCTIONS)
        return function(brackets)

    def _pullup_top(self, brackets):
        self._pullup(brackets, lambda x: 0)

    def _pullup_middle(self, brackets):
        self._pullup(brackets, lambda x: x // 2 - (random.randrange(2) if x % 2 == 0 else 0))

    def _pullup_bottom(self, brackets):
        self._pullup(brackets, lambda x: -1)

    def _pullup_random(self, brackets):
        self._pullup(brackets, lambda x: random.randrange(x))

    def _pullup(self, brackets, pos):
        """'brackets' is what is returned by _make_raw_brackets().
        'pos' is a function taking the number of teams to choose from,
        and returning an index for which team to take as the pullup.
        Operates in-place. Does not remove empty brackets."""
        pullup_needed_for = None

        for teams in brackets.values():
            if pullup_needed_for:
                pullup_eligible_teams = self._pullup_filter(teams)
                pullup_team = pullup_eligible_teams[pos(len(pullup_eligible_teams))]
                teams.remove(pullup_team)
                self.add_team_flag(pullup_team, "pullup")
                if hasattr(pullup_team, 'subrank'):
                    pullup_team.subrank = None
                pullup_needed_for.append(pullup_team)
                pullup_needed_for = None

            if len(teams) % 2 != 0:
                pullup_needed_for = teams

        if pullup_needed_for:
            raise DrawFatalError("Last bracket is still odd!\n" + repr(pullup_needed_for))

    @classmethod
    def _intermediate_brackets(cls, brackets):
        """Operates in-place."""
        new = OrderedDict()
        odd_team = None
        for points, teams in brackets.items():
            if odd_team:
                new[points+0.5] = [odd_team, teams.pop(0)]
                odd_team = None
            if len(teams) % 2 != 0:
                odd_team = teams.pop()
            if len(teams) > 0:
                new[points] = teams
        if odd_team:
            raise DrawFatalError("Last bracket is still odd!\n" + repr(odd_team))
        brackets.clear()
        brackets.update(new)

    def _intermediate_brackets_with_bubble_up_down(self, brackets):
        """Operates in-place.
        Requires Team.institution and Team.seen() to be defined."""
        self._intermediate_brackets(brackets)  # operates in-place
        # Check each of the intermediate brackets for conflicts.
        # If there is one, try swapping the top team with the bottom team
        # of the bracket above. Failing that, try the same with the bottom
        # team and the top team of the bracket below. Failing that, give up.
        # Note: Under no circumstances do we swap both teams.

        def _check_conflict(team1, team2):
            try:
                if team1.institution == team2.institution:
                    return 1  # Institution
                if team1.seen(team2):
                    return 2  # History
            except AttributeError:
                raise DrawFatalError("For conflict avoidance, teams must have attributes 'institution' and 'seen'.")
            return 0  # No conflict

        for points, teams in brackets.items():
            if int(points) == points:
                continue  # Skip non-intermediate brackets
            # a couple of checks
            assert points % 0.5 == 0
            assert teams[0].points > teams[1].points
            conflict = _check_conflict(*teams)
            if not conflict:
                continue # leave alone if no conflict

            # bubble up, if there exists such a bubble
            # swap bottom team from higher bracket with top team from
            # intermediate bracket.
            if points+0.5 in brackets:
                swap_team = brackets[points+0.5][-1] # bottom team
                if not _check_conflict(swap_team, teams[1]):
                    self.add_team_flag(teams[0], (conflict == 1) and "bub_up_inst" or "bub_up_hist")
                    self.add_team_flag(swap_team, "bub_up_accom")
                    teams[0], brackets[points+0.5][-1] = swap_team, teams[0]
                    continue

            # bubble down, if bubble up didn't work
            if points-0.5 in brackets:
                swap_team = brackets[points-0.5][0]  # Bottom team
                if not _check_conflict(swap_team, teams[0]):
                    self.add_team_flag(teams[1], (conflict == 1) and "bub_dn_inst" or "bub_dn_hist")
                    self.add_team_flag(swap_team, "bub_dn_accom")
                    teams[1], brackets[points-0.5][0] = swap_team, teams[1]
                    continue

            # if nothing worked, add a "didn't work" flag
            self.add_team_flag(teams[0], "no_bub_updn")


class GraphCostMixin:

    def assignment_cost(self, t1, t2, size, bracket=None):
        penalty = super().assignment_cost(t1, t2, size)
        if penalty is None:
            return None

        # Add penalty for seeing the pullup again
        has_pullup = 'pullup' in self.team_flags.get(t1, []) or 'pullup' in self.team_flags.get(t2, [])
        if self.options["pullup_debates_penalty"] and has_pullup:
            penalty += max(t1.pullup_debates, t2.pullup_debates) * self.options["pullup_debates_penalty"]

        if self.options["pairing_method"] != "random":
            subpool_penalty_func = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS)

            # Set the subrank to be last for pulled-up teams
            for team in [t1, t2]:
                if team.subrank is None:
                    team.subrank = size

            penalty += subpool_penalty_func([t1, t2], size, bracket) * self.options["pairing_penalty"]
        return penalty

    @staticmethod
    def _pairings_slide(teams, size: int, bracket: Optional[int] = None) -> int:
        return abs(abs(teams[0].subrank - teams[1].subrank) - size // 2)

    @staticmethod
    def _pairings_fold(teams, size: int, bracket: Optional[int] = None) -> int:
        return abs(teams[0].subrank + teams[1].subrank - 1 - size)

    @staticmethod
    def _pairings_random(teams, size: int, bracket: Optional[int] = None) -> int:
        return 0

    @staticmethod
    def _pairings_adjacent(teams, size: int, bracket: Optional[int] = None) -> int:
        return abs(teams[0].subrank - teams[1].subrank) - 1

    @classmethod
    def _pairings_fold_top_adjacent_rest(cls, teams, size: int, bracket: Optional[int] = None) -> int:
        if bracket == 0:
            return cls._pairings_fold(teams, size)
        return cls._pairings_adjacent(teams, size)


class AustralsPairingMixin:

    def generate_pairings(self, brackets):
        """Returns a function taking an OrderedDict as returned by
        resolve_odd_brackets(), and returning a list of Debates."""
        function = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS)
        return function(brackets)

    @staticmethod
    def _pairings(brackets, subpool_func):
        pairings = OrderedDict()
        i = 1
        for points, teams in brackets.items():
            bracket = list()
            top, bottom = subpool_func(teams)
            for p_teams in zip(top, bottom):
                pairing = Pairing(teams=p_teams, bracket=points, room_rank=i)
                bracket.append(pairing)
                i += 1
            pairings[points] = bracket
        return pairings

    @staticmethod
    def _pairings_top_special(brackets, top_subpool_func, rest_subpool_func):
        pairings = OrderedDict()
        i = 1
        subpool_funcs = [top_subpool_func] + [rest_subpool_func] * (len(brackets) - 1)
        for (points, teams), subpool_func in zip(brackets.items(), subpool_funcs):
            bracket = list()
            top, bottom = subpool_func(teams)
            for p_teams in zip(top, bottom):
                pairing = Pairing(teams=p_teams, bracket=points, room_rank=i)
                bracket.append(pairing)
                i += 1
            pairings[points] = bracket
        return pairings

    @staticmethod
    def _subpool_slide(teams):
        num_debates = len(teams) // 2
        top = teams[:num_debates]
        bottom = teams[num_debates:]
        return top, bottom

    @staticmethod
    def _subpool_fold(teams):
        num_debates = len(teams) // 2
        top = teams[:num_debates]
        bottom = teams[num_debates:]
        bottom.reverse()
        return top, bottom

    @staticmethod
    def _subpool_shuffle(teams):
        num_debates = len(teams) // 2
        random.shuffle(teams)
        top = teams[:num_debates]
        bottom = teams[num_debates:]
        return top, bottom

    @staticmethod
    def _subpool_adjacent(teams):
        return teams[0::2], teams[1::2]

    @classmethod
    def _pairings_slide(cls, brackets):
        return cls._pairings(brackets, cls._subpool_slide)

    @classmethod
    def _pairings_fold(cls, brackets):
        return cls._pairings(brackets, cls._subpool_fold)

    @classmethod
    def _pairings_random(cls, brackets):
        return cls._pairings(brackets, cls._subpool_shuffle)

    @classmethod
    def _pairings_adjacent(cls, brackets):
        return cls._pairings(brackets, cls._subpool_adjacent)

    @classmethod
    def _pairings_fold_top_adjacent_rest(cls, brackets):
        return cls._pairings_top_special(brackets, cls._subpool_fold, cls._subpool_adjacent)

    # Conflict avoidance
    AVOID_CONFLICT_FUNCTIONS = {
        "one_up_one_down": "_one_up_one_down",
        "graph"          : None,
    }

    def avoid_conflicts(self, pairings):
        """Returns a function taking a list of Pairings returned by
        generate_pairings(), and adjusting it in-place to avoid conflicts."""
        if self.options["avoid_conflicts"] == "off":
            return
        function = self.get_option_function("avoid_conflicts", self.AVOID_CONFLICT_FUNCTIONS)
        return function(pairings)

    def _one_up_one_down(self, pairings):
        """We pass the pairings to one_up_one_down.py, then infer annotations
        based on the result."""

        for bracket in pairings.values():
            pairs = [tuple(p.teams) for p in bracket]
            pairs_orig = list(pairs)  # Keep a copy for comparison
            option_names = ["avoid_history", "avoid_institution", "history_penalty", "institution_penalty"]
            options = dict((key, self.options[key]) for key in option_names)
            swapper = OneUpOneDownSwapper(**options)
            pairs_new = swapper.run(pairs)
            swaps = swapper.swaps

            for i, (pairing, orig, new) in enumerate(zip(bracket, pairs_orig, pairs_new)):
                assert tuple(pairing.teams) == orig
                assert (i in swaps or i-1 in swaps) == (orig != new)
                if orig != new:
                    if pairing.conflict_hist:
                        pairing.add_flag("1u1d_hist")
                    if pairing.conflict_inst:
                        pairing.add_flag("1u1d_inst")
                    if not (pairing.conflict_hist or pairing.conflict_inst):
                        pairing.add_flag("1u1d_other")
                    pairing.teams = list(new)


class GraphPowerPairedDrawGenerator(GraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator):
    pass


class AustralsPowerPairedDrawGenerator(AustralsPairingMixin, BasePowerPairedDrawGenerator):
    pass


class PowerPairedWithAllocatedSidesDrawGenerator(BasePowerPairedDrawGenerator):
    """Power-paired draw with allocated sides.
    Override functions of PowerPairedDrawGenerator where sides need to be constrained.
    All teams must have an 'allocated_side' attribute which must be either
    'aff' or 'neg' (case-sensitive).
    Options are as for PowerPairedDrawGenerator, except that the allowable values
    for "odd_bracket" are:
        "pullup_top"
        "pullup_bottom"
        "pullup_random"
        "intermediate1" - the excess teams in a bracket begin an intermediate bracket,
            which is filled by teams allocated to the other side from lower brackets,
            starting from the top of the next bracket down and pulling up as many
            teams as necessary. This may involve pulling up teams from multiple
            brackets if there aren't enough in the next bracket down.
        "intermediate2" - the excess teams in a bracket begin an intermediate bracket,
            which is filled by teams allocated to the other side from lower brackets.
            However, if there aren't enough teams in the next bracket down, then only
            those teams are pulled up into this intermediate bracket, and the excess
            teams (of the original excess) form a new, lower, intermediate bracket (but
            still higher than the next bracket down). So there can be multiple
            intermediate brackets between two brackets.
    """

    DEFAULT_OPTIONS = {
        "odd_bracket"           : "intermediate1",
        "pairing_method"        : "fold",
        "avoid_conflicts"       : None,
        "pullup_restriction"    : "none",
    }

    def __init__(self, *args, **kwargs):
        super(PowerPairedWithAllocatedSidesDrawGenerator, self).__init__(*args, **kwargs)
        self.check_teams_for_attribute("allocated_side", choices=["aff", "neg"])

    def _make_raw_brackets(self):
        """Returns an OrderedDict mapping bracket names (normally numbers)
        to (unordered) dicts. Each unordered dict has an 'aff' and a 'neg' key,
        each mapping to a list of teams."""
        brackets = OrderedDict()
        teams = list(self.teams)
        while len(teams) > 0:
            top_team = teams.pop(0)
            points = top_team.points
            pool = {"aff": list(), "neg": list()}
            pool[top_team.allocated_side].append(top_team)
            while len(teams) > 0 and teams[0].points == points:
                team = teams.pop(0)
                side = team.allocated_side
                pool[side].append(team)
            brackets[points] = pool
        return brackets

    ODD_BRACKET_FUNCTIONS = {
        "pullup_top"                  : "_pullup_top",
        "pullup_bottom"               : "_pullup_bottom",
        "pullup_random"               : "_pullup_random",
        "intermediate1"               : "_intermediate_brackets_1",
        "intermediate2"               : "_intermediate_brackets_2",
    }

    def _pullup_top(self, brackets):
        self._pullup(brackets, lambda x, num: range(0, num))

    def _pullup_bottom(self, brackets):
        self._pullup(brackets, lambda x, num: range(-num, 0))

    def _pullup_random(self, brackets):
        self._pullup(brackets, lambda x, num: random.sample(list(range(x)), num))

    # Overriding functions for resolving odd brackets:
    def _pullup(self, brackets, indices):
        """'brackets' is what is returned by _make_raw_brackets().
        'pos' is a function taking the number of teams to choose from
        and number of teams required, and returning a list of indices
        for which teams to take as the pullup.
        Operates in-place. Does not remove empty brackets."""

        # Tuples: (teams_list, side, number_needed)
        # List by highest bracket first.
        pullups_needed_for = list()

        for pool in brackets.values():

            # First, try to fulfil any pullups needed from higher brackets.
            # There's no guarantee we will have enough teams in this bracket to
            # fulfil all requirements.
            new_pullups_needed_for = list()
            for pullups_needed_teams, side, number_needed in pullups_needed_for:
                # Figure out which team indices we're pulling up.
                if len(pool[side]) < number_needed:
                    # If there are an unsufficient number of teams, pull up all of them
                    # and add to next pullups needed list.
                    pullup_indices = range(len(pool[side]))
                    new_pullups_needed_for.append((pullups_needed_teams, side, number_needed - len(pool[side])))
                else:
                    # Otherwise, pull up the number required.
                    pullup_indices = indices(len(pool[side]), number_needed)

                pullup_teams = list()
                for i in pullup_indices:
                    # Don't use pop, because that mucks up the indices.
                    pullup_team = pool[side][i]
                    self.add_team_flag(pullup_team, "pullup")
                    pullup_teams.append(pullup_team)

                # Now remove those teams from the bracket.
                # Again, avoiding pop, because it changes the indices.
                for team in pullup_teams:
                    pool[side].remove(team)

                # Finally, add our pullup teams to the destination list.
                pullups_needed_teams.extend(pullup_teams)

            # Then, figure out if we need any pullups in *this* bracket.
            aff_surplus = len(pool["aff"]) - len(pool["neg"])  # Could be negative
            if aff_surplus > 0:
                new_pullups_needed_for.append((pool["neg"], "neg", aff_surplus))
            elif aff_surplus < 0:
                new_pullups_needed_for.append((pool["aff"], "aff", -aff_surplus))

            # Assign the new pullups-needed list, then start again!
            pullups_needed_for = new_pullups_needed_for

        if pullups_needed_for:
            raise DrawFatalError("Last bracket still needed pullups!\n" + repr(pullups_needed_for))

    @classmethod
    def _intermediate_brackets_1(cls, brackets):
        """Operates in-place.
        This implements the first intermediate brackets method, where there is at most
        one intermediate bracket between brackets, but may have pullups from multiple
        brackets.
        """
        new = OrderedDict()
        unfilled = OrderedDict()

        for points, pool in brackets.items():

            to_delete_from_unfilled = []

            # First, check for unfilled intermediate brackets
            for unfilled_points, unfilled_pool in unfilled.items():
                aff_surplus = len(unfilled_pool["aff"]) - len(unfilled_pool["neg"])
                if aff_surplus > 0:
                    # Take the top teams from negative pool as appropriate.
                    # Note that there may not be enough teams; if there aren't,
                    # then this line just takes all of them.
                    unfilled_pool["neg"].extend(pool["neg"][:aff_surplus])
                    del pool["neg"][:aff_surplus]
                elif aff_surplus < 0:
                    # Take the top teams from affirmative pool as appropriate.
                    unfilled_pool["aff"].extend(pool["aff"][:-aff_surplus])
                    del pool["aff"][:-aff_surplus]
                # If the bubble now looks good, move it to the main brackets and
                # mark it for deletion from the unfilled buffer.
                if len(unfilled_pool["aff"]) == len(unfilled_pool["neg"]):
                    new[unfilled_points] = unfilled_pool
                    to_delete_from_unfilled.append(unfilled_points)

            # Delete the unfilled brackets tht were marked for deletion
            for unfilled_points in to_delete_from_unfilled:
                del unfilled[unfilled_points]

            # Find lesser and greater of number of aff and neg teams.
            nums_teams = list(map(len, list(pool.values())))
            n = min(nums_teams)
            m = max(nums_teams)

            # Assign the main bracket
            new[points] = {"aff": pool["aff"][:n], "neg": pool["neg"][:n]}

            # Assign the intermediate bracket, if any
            if m > n:
                unfilled[points-0.5] = {"aff": pool["aff"][n:], "neg": pool["neg"][n:]}

        if unfilled:
            raise DrawFatalError("There are still unfilled intermediate brackets!\n" + repr(unfilled))

        # Currently, the brackets are out of order, since e.g. 3.5 would have been
        # inserted after 3 (or maybe even after 2). Let's change that:
        new_sorted = sorted(list(new.items()), key=lambda x: x[0], reverse=True)

        brackets.clear()
        brackets.update(new_sorted)

    @classmethod
    def _intermediate_brackets_2(cls, brackets):
        """Operates in-place.
        This implements the second intermediate brackets method, where all debates
        in the same intermediate bracket have the same number of wins, but there
        might be multiple intermediate brackets between brackets.
        """

        new = OrderedDict()
        unfilled = OrderedDict()
        intermediates = OrderedDict()  # Values are lists of {"aff", "neg"} dicts
        for points, pool in brackets.items():

            to_delete_from_unfilled = []

            # First, check for unfilled intermediate brackets
            for unfilled_points, unfilled_pool in unfilled.items():
                intermediates.setdefault(unfilled_points, list())
                if unfilled_pool["aff"] and unfilled_pool["neg"]:
                    raise DrawFatalError("An unfilled pool unexpectedly had both affirmative and negative teams.")
                elif unfilled_pool["aff"]:
                    # In a new bracket, take the lesser of how many excess affirmative
                    # teams there are, and how many negative teams in the pool we have.
                    num_teams = min(len(unfilled_pool["aff"]), len(pool["neg"]))
                    intermediates[unfilled_points].append({
                        "aff": unfilled_pool["aff"][:num_teams],
                        "neg": pool["neg"][:num_teams],
                    })
                    del unfilled_pool["aff"][:num_teams]
                    del pool["neg"][:num_teams]
                elif unfilled_pool["neg"]:
                    # Take the top teams from affirmative pool as appropriate.
                    num_teams = min(len(unfilled_pool["neg"]), len(pool["aff"]))
                    intermediates[unfilled_points].append({
                        "aff": pool["aff"][:num_teams],
                        "neg": unfilled_pool["neg"][:num_teams],
                    })
                    del pool["aff"][:num_teams]
                    del unfilled_pool["neg"][:num_teams]
                # If we've exhausted the unfilled pool, add all these
                # intermediate brackets to the main list of brackets and mark
                # them for deletion from the unfilled buffer.
                if not unfilled_pool["aff"] and not unfilled_pool["neg"]:
                    num_brackets = len(intermediates[unfilled_points])
                    for i, intermediate_pool in enumerate(intermediates[unfilled_points], start=1):
                        intermediate_points = unfilled_points - i / (num_brackets + 1)
                        new[intermediate_points] = intermediate_pool
                    to_delete_from_unfilled.append(unfilled_points)

            # Delete the unfilled brackets tht were marked for deletion
            for unfilled_points in to_delete_from_unfilled:
                del unfilled[unfilled_points]

            # Find lesser and greater of number of aff and neg teams.
            nums_teams = list(map(len, list(pool.values())))
            n = min(nums_teams)
            m = max(nums_teams)

            # Assign the main bracket
            new[points] = {"aff": pool["aff"][:n], "neg": pool["neg"][:n]}

            # Take note of the excess teams, if any
            if m > n:
                unfilled[points] = {"aff": pool["aff"][n:], "neg": pool["neg"][n:]}

        if unfilled:
            raise DrawFatalError("There are still unfilled intermediate brackets!\n" + repr(unfilled))

        # Currently, the brackets are out of order, since e.g. 3.5 would have been
        # inserted after 3 (or maybe even after 2). Let's change that:
        new_sorted = sorted(list(new.items()), key=lambda x: x[0], reverse=True)

        brackets.clear()
        brackets.update(new_sorted)

    @staticmethod
    def _intermediate_brackets_with_up_down():
        """This should never be called - the associated option string is removed
        from the allowable list above."""
        raise NotImplementedError("Intermediate brackets with conflict avoidance isn't supported with allocated sides.")


class GraphPowerPairedWithAllocatedSidesDrawGenerator(GraphCostMixin, GraphAllocatedSidesMixin, PowerPairedWithAllocatedSidesDrawGenerator):
    pass


class AustralsPowerPairedWithAllocatedSidesDrawGenerator(AustralsPairingMixin, PowerPairedWithAllocatedSidesDrawGenerator):

    @staticmethod
    def _pairings(brackets, presort_func):
        pairings = OrderedDict()
        i = 1
        for points, pool in brackets.items():
            assert len(pool["aff"]) == len(pool["neg"])
            bracket = list()
            presort_func(pool)
            for teams in zip(pool["aff"], pool["neg"]):
                pairing = Pairing(teams=teams, bracket=points, room_rank=i)
                bracket.append(pairing)
                i = i + 1
            pairings[points] = bracket
        return pairings

    @classmethod
    def _pairings_slide(cls, brackets):
        def slide(pool):
            pass  # Do nothing
        return cls._pairings(brackets, slide)

    @classmethod
    def _pairings_fold(cls, brackets):
        def fold(pool):
            pool["neg"].reverse()
        return cls._pairings(brackets, fold)

    @classmethod
    def _pairings_random(cls, brackets):
        def shuffle(pool):
            random.shuffle(pool["aff"])
            random.shuffle(pool["neg"])
        return cls._pairings(brackets, shuffle)