diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8eae56..e032c6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI/CD +name: CI on: push: @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macOS-latest] - python-version: [3.6, 3.7, 3.8] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@master @@ -27,20 +27,25 @@ jobs: python -m pip install --upgrade pip setuptools wheel python -m pip install -r requirements.txt python -m pip install -q --no-cache-dir -e . - python -m pip install -q --no-cache-dir pytest pytest-cov isort black + python -m pip install -q --no-cache-dir pytest pytest-cov + python -m pip install -q --no-cache-dir isort black flake8 python -m pip install -q --no-cache-dir sphinx sphinx_rtd_theme python -m pip install -q --no-cache-dir nbval sphinxcontrib-bibtex python -m pip install -q --no-cache-dir matplotlib pandas PyYAML python -m pip install -q --no-cache-dir ipython==7.10 python -m pip list - name: Lint with Black - if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' + if: matrix.python-version == 3.8 && matrix.os == 'ubuntu-latest' run: | python -m black --check --diff -l 80 . - name: Lint imports with isort - if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' + if: matrix.python-version == 3.8 && matrix.os == 'ubuntu-latest' run: | python -m isort -w 80 -m 3 --trailing-comma --check-only . + - name: Lint with flake8 + if: matrix.python-version == 3.8 && matrix.os == 'ubuntu-latest' + run: | + python -m flake8 --max-line-length=80 --ignore=E203,W503 . - name: Test with pytest run: | python -m pytest --cov=matching --cov-fail-under=100 tests diff --git a/CHANGES.rst b/CHANGES.rst index 8ced1bd..335e2a3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ History ======= +v1.4 - 2020-11-04 +----------------- + +- Add abstract classes for players, games and matchings. +- Implement extended algorithm for SR, and clean up HR/SM algorithms. +- Move all of the algorithms to their own module, `matching.algorithms`. + v1.3.3 - 2020-10-15 ------------------- diff --git a/README.rst b/README.rst index 21ea1be..edc6bda 100644 --- a/README.rst +++ b/README.rst @@ -97,11 +97,11 @@ The ``Matching`` object +++++++++++++++++++++++ This matching is not a standard Python dictionary, though it does largely look -and behave like one. It is in fact an instance of the ``Matching`` class: +and behave like one. It is in fact an instance of the ``SingleMatching`` class: >>> matching = game.matching >>> type(matching) - + This dictionary-like object is primarily useful as a teaching device that eases the process of manipulating a matching after a solution has been found. @@ -116,9 +116,9 @@ Despite passing dictionaries of strings here, the matching displays instances of >>> matching = game.matching >>> for suitor in matching: ... print(type(suitor)) - - - + + + This is because ``create_from_dictionaries`` creates instances of the appropriate player classes first and passes them to the game class. Using diff --git a/docs/bibliography.bib b/docs/bibliography.bib index 0d79673..a6d35c2 100644 --- a/docs/bibliography.bib +++ b/docs/bibliography.bib @@ -12,7 +12,8 @@ @book{Aus13 title = {Pride and Prejudice}, year = {1813}, author = {Austen, Jane}, - publisher = {T. Egerton, Military Library, Whitehall, London, UK}, + publisher = {T. Egerton, Military Library}, + address = {Whitehall, London, UK}, } @article{DF81, @@ -26,6 +27,15 @@ @article{DF81 doi = {10.2307/2321753} } +@book{GI89, + author = {Gusfield, Dan and Irving, Robert W.}, + title = {The Stable Marriage Problem: Structure and Algorithms}, + year = {1989}, + isbn = {0262071185}, + publisher = {MIT Press}, + address = {Cambridge, MA, USA}, +} + @article{GS62, title = {College Admissions and the Stability of Marriage}, author = {Gale, David and Shapley, Lloyd}, diff --git a/docs/reference/source/matching.algorithms.rst b/docs/reference/source/matching.algorithms.rst new file mode 100644 index 0000000..67b144d --- /dev/null +++ b/docs/reference/source/matching.algorithms.rst @@ -0,0 +1,54 @@ +matching.algorithms package +=========================== + +Submodules +---------- + +matching.algorithms.hospital\_resident module +--------------------------------------------- + +.. automodule:: matching.algorithms.hospital_resident + :members: + :undoc-members: + :show-inheritance: + +matching.algorithms.stable\_marriage module +------------------------------------------- + +.. automodule:: matching.algorithms.stable_marriage + :members: + :undoc-members: + :show-inheritance: + +matching.algorithms.stable\_roommates module +-------------------------------------------- + +.. automodule:: matching.algorithms.stable_roommates + :members: + :undoc-members: + :show-inheritance: + +matching.algorithms.student\_allocation module +---------------------------------------------- + +.. automodule:: matching.algorithms.student_allocation + :members: + :undoc-members: + :show-inheritance: + +matching.algorithms.util module +------------------------------- + +.. automodule:: matching.algorithms.util + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: matching.algorithms + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/reference/source/matching.games.rst b/docs/reference/source/matching.games.rst index 5422ae1..9c85943 100644 --- a/docs/reference/source/matching.games.rst +++ b/docs/reference/source/matching.games.rst @@ -36,14 +36,6 @@ matching.games.student\_allocation module :undoc-members: :show-inheritance: -matching.games.util module --------------------------- - -.. automodule:: matching.games.util - :members: - :undoc-members: - :show-inheritance: - Module contents --------------- diff --git a/docs/reference/source/matching.rst b/docs/reference/source/matching.rst index 297843f..3794ab5 100644 --- a/docs/reference/source/matching.rst +++ b/docs/reference/source/matching.rst @@ -6,12 +6,21 @@ Subpackages .. toctree:: + matching.algorithms matching.games matching.players Submodules ---------- +matching.exceptions module +-------------------------- + +.. automodule:: matching.exceptions + :members: + :undoc-members: + :show-inheritance: + matching.game module -------------------- diff --git a/docs/tutorials/project_allocation/main.ipynb b/docs/tutorials/project_allocation/main.ipynb index ca965f6..7408e42 100644 --- a/docs/tutorials/project_allocation/main.ipynb +++ b/docs/tutorials/project_allocation/main.ipynb @@ -1054,7 +1054,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 27, @@ -1172,7 +1172,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 30, @@ -1235,7 +1235,7 @@ "for project, project_students in matching.items():\n", " for student in project_students:\n", " inverted_matching[student.name] = project.name\n", - " student_preference_of_matching.append(student.pref_names.index(project.name))" + " student_preference_of_matching.append(student._pref_names.index(project.name))" ] }, { @@ -1547,7 +1547,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 36, diff --git a/src/matching/__init__.py b/src/matching/__init__.py index e336986..8464834 100644 --- a/src/matching/__init__.py +++ b/src/matching/__init__.py @@ -2,14 +2,23 @@ import sys -from .game import BaseGame -from .matching import Matching -from .player import Player -from .version import __version__ - if not sys.warnoptions: import warnings warnings.simplefilter("always") -__all__ = ["BaseGame", "Matching", "Player", "__version__"] +from .base import BaseGame, BaseMatching, BasePlayer +from .matchings import MultipleMatching, SingleMatching +from .players import Player +from .version import __version__ + +__all__ = [ + "BaseGame", + "BaseMatching", + "BasePlayer", + "Matching", + "MultipleMatching", + "Player", + "SingleMatching", + "__version__", +] diff --git a/src/matching/algorithms/__init__.py b/src/matching/algorithms/__init__.py new file mode 100644 index 0000000..af947cf --- /dev/null +++ b/src/matching/algorithms/__init__.py @@ -0,0 +1,13 @@ +""" Top-level imports for the `matching.algorithms` subpackage. """ + +from .hospital_resident import hospital_resident +from .stable_marriage import stable_marriage +from .stable_roommates import stable_roommates +from .student_allocation import student_allocation + +__all__ = [ + "hospital_resident", + "stable_marriage", + "stable_roommates", + "student_allocation", +] diff --git a/src/matching/algorithms/hospital_resident.py b/src/matching/algorithms/hospital_resident.py new file mode 100644 index 0000000..5e40281 --- /dev/null +++ b/src/matching/algorithms/hospital_resident.py @@ -0,0 +1,143 @@ +""" Functions for the HR algorithms. """ + +from .util import _delete_pair, _match_pair + + +def _unmatch_pair(resident, hospital): + """ Unmatch a (resident, hospital)-pair. """ + + resident._unmatch() + hospital._unmatch(resident) + + +def _check_available(hospital): + """ Check whether a hospital is willing and able to take an applicant. """ + + return len(hospital.matching) < hospital.capacity and set( + hospital.prefs + ).difference(hospital.matching) + + +def hospital_resident(residents, hospitals, optimal="resident"): + """Solve an instance of HR using an adapted Gale-Shapley algorithm + :cite:`Rot84`. A unique, stable and optimal matching is found for the given + set of residents and hospitals. The optimality of the matching is found with + respect to one party and is subsequently the worst stable matching for the + other. + + Parameters + ---------- + residents : list of Player + The residents in the game. Each resident must rank a non-empty subset + of the elements of ``hospitals``. + hospitals : list of Hospital + The hospitals in the game. Each hospital must rank all the residents + that have ranked them. + optimal : str, optional + Which party the matching should be optimised for. Must be one of + ``"resident"`` and ``"hospital"``. Defaults to the former. + + Returns + ------- + matching : Matching + A dictionary-like object where the keys are the members of + ``hospitals``, and the values are their matches ranked by preference. + """ + + if optimal == "resident": + return resident_optimal(residents, hospitals) + if optimal == "hospital": + return hospital_optimal(hospitals) + + +def resident_optimal(residents, hospitals): + """Solve the instance of HR to be resident-optimal. The algorithm is as + follows: + + 0. Set all residents to be unmatched, and all hospitals to be totally + unsubscribed. + + 1. Take any unmatched resident with a non-empty preference list, + :math:`r`, and consider their most preferred hospital, :math:`h`. Match + them to one another. + + 2. If, as a result of this new matching, :math:`h` is now + over-subscribed, find the worst resident currently assigned to + :math:`h`, :math:`r'`. Set :math:`r'` to be unmatched and remove them + from :math:`h`'s matching. Otherwise, go to 3. + + 3. If :math:`h` is at capacity (fully subscribed) then find their worst + current match :math:`r'`. Then, for each successor, :math:`s`, to + :math:`r'` in the preference list of :math:`h`, delete the pair + :math:`(s, h)` from the game. Otherwise, go to 4. + + 4. Go to 1 until there are no such residents left, then end. + """ + + free_residents = residents[:] + while free_residents: + + resident = free_residents.pop() + hospital = resident.get_favourite() + + if len(hospital.matching) == hospital.capacity: + worst = hospital.get_worst_match() + _unmatch_pair(worst, hospital) + free_residents.append(worst) + + _match_pair(resident, hospital) + + if len(hospital.matching) == hospital.capacity: + successors = hospital.get_successors() + for successor in successors: + _delete_pair(hospital, successor) + if not successor.prefs: + free_residents.remove(successor) + + return {r: r.matching for r in hospitals} + + +def hospital_optimal(hospitals): + """Solve the instance of HR to be hospital-optimal. The algorithm is as + follows: + + 0. Set all residents to be unmatched, and all hospitals to be totally + unsubscribed. + + 1. Take any hospital, :math:`h`, that is under-subscribed and whose + preference list contains any resident they are not currently assigned + to, and consider their most preferred such resident, :math:`r`. + + 2. If :math:`r` is currently matched, say to :math:`h'`, then unmatch + them from one another. In any case, match :math:`r` to :math:`h` and go + to 3. + + 3. For each successor, :math:`s`, to :math:`h` in the preference list of + :math:`r`, delete the pair :math:`(r, s)` from the game. + + 4. Go to 1 until there are no such hospitals left, then end. + """ + + free_hospitals = hospitals[:] + while free_hospitals: + + hospital = free_hospitals.pop() + resident = hospital.get_favourite() + + if resident.matching: + current_match = resident.matching + _unmatch_pair(resident, current_match) + if current_match not in free_hospitals: + free_hospitals.append(current_match) + + _match_pair(resident, hospital) + if _check_available(hospital): + free_hospitals.append(hospital) + + successors = resident.get_successors() + for successor in successors: + _delete_pair(resident, successor) + if not _check_available(successor) and successor in free_hospitals: + free_hospitals.remove(successor) + + return {r: r.matching for r in hospitals} diff --git a/src/matching/algorithms/stable_marriage.py b/src/matching/algorithms/stable_marriage.py new file mode 100644 index 0000000..3cfbd90 --- /dev/null +++ b/src/matching/algorithms/stable_marriage.py @@ -0,0 +1,60 @@ +""" Functions for the SM algorithms. """ + +from .util import _delete_pair, _match_pair + + +def _unmatch_pair(suitor, reviewer): + """ Unmatch a (suitor, reviewer) pair. """ + + suitor._unmatch() + reviewer._unmatch() + + +def stable_marriage(suitors, reviewers, optimal="suitor"): + """An extended version of the original Gale-Shapley algorithm which makes + use of the inherent structures of SM instances. A unique, stable and optimal + matching is found for any valid set of suitors and reviewers. The optimality + of the matching is with respect to one party and is subsequently the worst + stable matching for the other. + + Parameters + ---------- + suitors : list of Player + The suitors in the game. Each must rank all of those in ``reviewers``. + reviewers : list of Player + The reviewers in the game. Each must rank all of those in ``suitors``. + optimal : str, optional + Which party the matching should be optimised for. Must be one of + ``"suitor"`` and ``"reviewer"``. Defaults to the former. + + Returns + ------- + matching : Matching + A dictionary-like object where the keys are given by the members of + ``suitors``, and the values are their match in ``reviewers``. + """ + + if optimal.lower() == "reviewer": + suitors, reviewers = reviewers, suitors + + free_suitors = suitors[:] + while free_suitors: + + suitor = free_suitors.pop() + reviewer = suitor.get_favourite() + + if reviewer.matching: + current_match = reviewer.matching + _unmatch_pair(current_match, reviewer) + free_suitors.append(current_match) + + _match_pair(suitor, reviewer) + + successors = reviewer.get_successors() + for successor in successors: + _delete_pair(successor, reviewer) + + if optimal.lower() == "reviewer": + suitors, reviewers = reviewers, suitors + + return {s: s.matching for s in suitors} diff --git a/src/matching/algorithms/stable_roommates.py b/src/matching/algorithms/stable_roommates.py new file mode 100644 index 0000000..3db20e2 --- /dev/null +++ b/src/matching/algorithms/stable_roommates.py @@ -0,0 +1,150 @@ +""" Functions for the SR algorithm. """ +import warnings + +from matching.exceptions import NoStableMatchingWarning + +from .util import _delete_pair + + +def first_phase(players): + """Conduct the first phase of the algorithm where one-way proposals are + made, and unpreferable pairs are forgotten.""" + + free_players = players[:] + while free_players: + + player = free_players.pop() + favourite = player.get_favourite() + + current = favourite.matching + if current is not None: + favourite._unmatch() + free_players.append(current) + + favourite._match(player) + + for successor in favourite.get_successors(): + _delete_pair(successor, favourite) + if not successor.prefs and successor in free_players: + free_players.remove(successor) + + return players + + +def locate_all_or_nothing_cycle(player): + """Locate a cycle of (least-preferable, second-choice) pairs to be removed + from the game.""" + + lasts = [player] + seconds = [] + while True: + second_best = player.prefs[1] + their_worst = second_best.prefs[-1] + + seconds.append(second_best) + lasts.append(their_worst) + + player = their_worst + + if lasts.count(player) > 1: + break + + idx = lasts.index(player) + cycle = list(zip(lasts[idx + 1 :], seconds[idx:])) + + return cycle + + +def get_pairs_to_delete(cycle): + """Based on an all-or-nothing cycle :math:`(x_1, y_1), \\ldots, (x_n, y_n)`, + for each :math:`i = 1, \\ldots, n`, one must delete from the game all pairs + :math:`(y_i, z)` such that :math:`y_i` prefers :math:`x_{i-1}` to :math:`z` + where subscripts are taken modulo :math:`n`. + + This is an important point that is omitted from the original paper, but may + be found in :cite:`GI89` (Section 4.2.3). + + The essential difference between this statement and that in :cite:`Irv85` is + the removal of unpreferable pairs, identified using an all-or-nothing cycle, + in addition to those contained in the cycle. Without doing so, tails of + cycles can be removed rather than whole cycles, leaving some conflicting + pairs in the game.""" + + pairs = [] + for i, (_, right) in enumerate(cycle): + + left = cycle[(i - 1) % len(cycle)][0] + successors = right.prefs[right.prefs.index(left) + 1 :] + for successor in successors: + pair = (right, successor) + if pair not in pairs and pair[::-1] not in pairs: + pairs.append((right, successor)) + + return pairs + + +def second_phase(players): + """Conduct the second phase of the algorithm where all-or-nothing cycles + (rotations) are located and removed from the game.""" + + player = next(p for p in players if len(p.prefs) > 1) + while True: + + cycle = locate_all_or_nothing_cycle(player) + pairs = get_pairs_to_delete(cycle) + for player, other in pairs: + _delete_pair(player, other) + + if any(p.prefs == [] for p in players): + warnings.warn( + NoStableMatchingWarning( + "The following players have emptied their preference list: " + f"{[p for p in players if not p.prefs]}" + ) + ) + break + + try: + player = next(p for p in players if len(p.prefs) > 1) + except StopIteration: + break + + for player in players: + player._unmatch() + if player.prefs: + player._match(player.get_favourite()) + + return players + + +def stable_roommates(players): + """Irving's algorithm :cite:`Irv85` that finds stable solutions to + instances of SR if one exists. Otherwise, an incomplete matching is found. + + Parameters + ---------- + players : list of Player + The players in the game. Each must rank all other players. + + Returns + ------- + matching : dict + A dictionary of matches where the keys and values are given by the + members of ``players``. + """ + + players = first_phase(players) + + if any(p.prefs == [] for p in players): + warnings.warn( + NoStableMatchingWarning( + "The following players have been rejected by all others, " + "emptying their preference list: " + f"{[p for p in players if not p.prefs]}" + ) + ) + + if any(len(p.prefs) > 1 for p in players): + players = second_phase(players) + + return {player: player.matching for player in players} diff --git a/src/matching/algorithms/student_allocation.py b/src/matching/algorithms/student_allocation.py new file mode 100644 index 0000000..5c67904 --- /dev/null +++ b/src/matching/algorithms/student_allocation.py @@ -0,0 +1,171 @@ +""" Functions for the SA algorithm. """ + +from .util import _delete_pair, _match_pair + + +def unmatch_pair(student, project): + """ Unmatch a student-project pair. """ + + student._unmatch() + project._unmatch(student) + + +def student_allocation(students, projects, supervisors, optimal="student"): + """Solve an instance of SA by treating it as a bi-level HR. A unique, + stable and optimal matching is found for the given set of students, projects + and supervisors. The optimality of the matching is found with respect to one + party and is subsequently the worst stable matching for the other. + + Parameters + ---------- + students : list of Player + The students in the game. Each student must rank a subset of the + elements of ``projects``. + projects : list of Project + The projects in the game. Each project is offered by a supervisor that + governs its preferences. + supervisor : list of Supervisor + The supervisors in the game. Each supervisor offers a unique subset of + ``projects`` and ranks all the students that have ranked at least one of + these projects. + optimal : str, optional + Which party the matching should be optimised for. Must be one of + ``"student"`` and ``"supervisor"``. Defaults to the former. + + Returns + ======= + matching : Matching + A dictionary-like object where the keys are the members of ``projects`` + and their student matches are the values. + """ + + if optimal == "student": + return student_optimal(students, projects) + if optimal == "supervisor": + return supervisor_optimal(projects, supervisors) + + +def student_optimal(students, projects): + """Solve the instance of SA to be student-optimal. The algorithm is as + follows: + + 0. Set all students to be unassigned, and every project (and supervisor) + to be totally unsubscribed. + + 1. Take any student, :math:`s`, that is unassigned and has a non-empty + preference list, and consider their most preferred project, :math:`p`. + Let :math:`f` denote the supervisor that offers :math:`p`. Assign + :math:`s` to be matched to :math:`p` (and thus :math:`f`). + + 2. If :math:`p` is now over-subscribed, find its worst current match, + :math:`s'`. Unmatch :math:`p` and :math:`s'`. Else if :math:`f` is + over-subscribed, find their worst current match, :math:`s''`, and the + project they are currently subscribed to, :math:`p'`. Unmatch :math:`p'` + and :math:`s''`. + + 3. If :math:`p` is now at capacity, find their worst current match, + :math:`s'`. For each successor, :math:`t`, to :math:`s'` in the + preference list of :math:`p`, delete the pair :math:`(p, t)` from the + game. + + 4. If :math:`f` is at capacity, find their worst current match, + :math:`s'`. For each successor, :math:`t`, to :math:`s'` in the + preference list of :math:`f`, for each project, :math:`p'`, offered by + :math:`f` that :math:`t` finds acceptable, delete the pair + :math:`(p', t)` from the game. + + 5. Go to 1 until there are no such students left, then end. + """ + + free_students = students[:] + while free_students: + + student = free_students.pop() + project = student.get_favourite() + supervisor = project.supervisor + + _match_pair(student, project) + + if len(project.matching) > project.capacity: + worst = project.get_worst_match() + unmatch_pair(worst, project) + free_students.append(worst) + + elif len(supervisor.matching) > supervisor.capacity: + worst = supervisor.get_worst_match() + worst_project = worst.matching + unmatch_pair(worst, worst_project) + free_students.append(worst) + + if len(project.matching) == project.capacity: + successors = project.get_successors() + for successor in successors: + _delete_pair(project, successor) + if not successor.prefs: + free_students.remove(successor) + + if len(supervisor.matching) == supervisor.capacity: + successors = supervisor.get_successors() + for successor in successors: + + supervisor_projects = [ + project + for project in supervisor.projects + if project in successor.prefs + ] + + for project in supervisor_projects: + _delete_pair(project, successor) + if not successor.prefs: + free_students.remove(successor) + + return {p: p.matching for p in projects} + + +def supervisor_optimal(projects, supervisors): + """Solve the instance of SA to be supervisor-optimal. The algorithm is as + follows: + + 0. Set all students to be unassigned, and every project (and supervisor) + to be totally unsubscribed. + + 1. Take any supervisor member, :math:`f`, that is under-subscribed and + whose preference list contains at least one student that is not + currently matched to at least one acceptable (though currently + under-subscribed) project offered by :math:`f`. Consider the + supervisor's most preferred such student, :math:`s`, and that student's + most preferred such project, :math:`p`. + + 2. If :math:`s` is matched to some other project, :math:`p'`, then + unmatch them. In any case, match :math:`s` and :math:`p` (and thus + :math:`f`). + + 3. For each successor, :math:`p'`, to :math:`p` in the preference list + of :math:`s`, delete the pair :math:`(p', s)` from the game. + + 4. Go to 1 until there are no such supervisors, then end. + """ + + free_supervisors = supervisors[:] + while free_supervisors: + + supervisor = free_supervisors.pop() + student, project = supervisor.get_favourite() + + if student.matching: + curr_match = student.matching + unmatch_pair(student, curr_match) + + _match_pair(student, project) + + successors = student.get_successors() + for successor in successors: + _delete_pair(student, successor) + + free_supervisors = [ + supervisor + for supervisor in supervisors + if supervisor.get_favourite() is not None + ] + + return {p: p.matching for p in projects} diff --git a/src/matching/algorithms/util.py b/src/matching/algorithms/util.py new file mode 100644 index 0000000..c98be5e --- /dev/null +++ b/src/matching/algorithms/util.py @@ -0,0 +1,16 @@ +""" Useful functions for the running of the various core algorithms. """ + + +def _delete_pair(player, other): + """Make a player forget another (and vice versa), deleting the pair from + further consideration in the game.""" + + player._forget(other) + other._forget(player) + + +def _match_pair(player, other): + """Match the players given by `player` and `other`.""" + + player._match(other) + other._match(player) diff --git a/src/matching/base.py b/src/matching/base.py new file mode 100644 index 0000000..9e66c33 --- /dev/null +++ b/src/matching/base.py @@ -0,0 +1,260 @@ +""" Abstract base classes for inheritance. """ +import abc +import warnings + +from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning + + +class BasePlayer: + """An abstract base class to represent a player within a matching game. + + Parameters + ---------- + name : object + An identifier. This should be unique and descriptive. + + Attributes + ---------- + prefs : List[BasePlayer] + The player's preferences. Defaults to ``None`` and is updated using the + ``set_prefs`` method. + matching : Optional[BasePlayer] + The current match of the player. ``None`` if not currently matched. + _pref_names : Optional[List] + A list of the names in ``prefs``. Updates with ``prefs`` via + ``set_prefs`` method. + _original_prefs : Optional[List[BasePlayer]] + The original set of player preferences. Defaults to ``None`` and does + not update after the first ``set_prefs`` method call. + """ + + def __init__(self, name): + + self.name = name + self.prefs = [] + self.matching = None + + self._pref_names = [] + self._original_prefs = None + + def __repr__(self): + + return str(self.name) + + def _forget(self, other): + """Forget another player by removing them from the player's preference + list.""" + + prefs = self.prefs[:] + prefs.remove(other) + self.prefs = prefs + + def unmatched_message(self): + + return f"{self} is unmatched." + + def not_in_preferences_message(self, other): + + return ( + f"{self} is matched to {other} but they do not appear in their " + f"preference list: {self.prefs}." + ) + + def set_prefs(self, players): + """ Set the player's preferences to be a list of players. """ + + self.prefs = players + self._pref_names = [player.name for player in players] + + if self._original_prefs is None: + self._original_prefs = players[:] + + def prefers(self, player, other): + """Determines whether the player prefers a player over some other + player.""" + + prefs = self._original_prefs + return prefs.index(player) < prefs.index(other) + + @abc.abstractmethod + def _match(self, other): + """A placeholder function for assigning the player to be matched to + some other player.""" + + @abc.abstractmethod + def _unmatch(self, other): + """A placeholder function for unassigning the player from its match + with some other player.""" + + @abc.abstractmethod + def get_favourite(self): + """A placeholder function for getting the player's favourite, feasible + player.""" + + @abc.abstractmethod + def get_successors(self): + """A placeholder function for getting the logically feasible + 'successors' of the player.""" + + @abc.abstractmethod + def check_if_match_is_unacceptable(self): + """A placeholder for chacking the acceptability of the current + match(es) of the player.""" + + +class BaseGame(metaclass=abc.ABCMeta): + """An abstract base class for facilitating various matching games. + + Parameters + ---------- + clean + Defaults to :code:`False`. If :code:`True`, when passing a set of + players to create a game instance, they will be automatically cleaned. + + Attributes + ---------- + matching + After solving the game, a :code:`Matching` object is found here. + Otherwise, :code:`None`. + blocking_pairs + After checking the stability of the game instance, a list of any pairs + that block the stability of the matching is found here. Otherwise, + :code:`None`. + """ + + def __init__(self, clean=False): + + self.matching = None + self.blocking_pairs = None + self.clean = clean + + def _remove_player(self, player, player_party, other_party): + """Remove a player from the game instance as well as any relevant + player preference lists.""" + + party = vars(self)[player_party][:] + party.remove(player) + vars(self)[player_party].remove(player) + for other in vars(self)[other_party]: + if player in other.prefs: + other._forget(player) + + def _check_inputs_player_prefs_unique(self, party): + """Check that each player in :code:`party` has not ranked another + player more than once. If so, and :code:`clean` is :code:`True`, then + take the first instance they appear in the preference list.""" + + for player in vars(self)[party]: + unique_prefs = [] + for other in player.prefs: + if other not in unique_prefs: + unique_prefs.append(other) + else: + warnings.warn( + PreferencesChangedWarning( + f"{player} has ranked {other} multiple times." + ) + ) + + if self.clean: + player.set_prefs(unique_prefs) + + def _check_inputs_player_prefs_all_in_party(self, party, other_party): + """Check that each player in :code:`party` has ranked only players in + :code:`other_party`. If :code:`clean`, then forget any extra + preferences.""" + + players = vars(self)[party] + others = vars(self)[other_party] + for player in players: + + for other in player.prefs: + if other not in others: + warnings.warn( + PreferencesChangedWarning( + f"{player} has ranked a non-{other_party[:-1]}: " + f"{other}." + ) + ) + if self.clean: + player._forget(other) + + def _check_inputs_player_prefs_nonempty(self, party, other_party): + """Make sure that each player in :code:`party` has a nonempty + preference list of players in :code:`other_party`. If :code:`clean`, + remove any such player.""" + + for player in vars(self)[party]: + + if not player.prefs: + warnings.warn( + PlayerExcludedWarning( + f"{player} has an empty preference list." + ) + ) + if self.clean: + self._remove_player(player, party, other_party) + + @abc.abstractmethod + def solve(self): + """ Placeholder for solving the given matching game. """ + + @abc.abstractmethod + def check_stability(self): + """ Placeholder for checking the stability of the current matching. """ + + @abc.abstractmethod + def check_validity(self): + """ Placeholder for checking the validity of the current matching. """ + + +class BaseMatching(dict, metaclass=abc.ABCMeta): + """An abstract base class for the storing and updating of a matching. + + Attributes + ---------- + dictionary : dict or None + If not ``None``, a dictionary mapping a ``Player`` to one of: ``None``, + a single ``Player`` or a list of ``Player`` instances. + """ + + def __init__(self, dictionary=None): + + self._data = {} + if dictionary is not None: + self._data.update(dictionary) + + super().__init__(self._data) + + def __repr__(self): + + return repr(self._data) + + def keys(self): + + return self._data.keys() + + def values(self): + + return self._data.values() + + def __getitem__(self, player): + + return self._data[player] + + @abc.abstractmethod + def __setitem__(self, player, new_match): + """ A placeholder function for how to update the matching. """ + + def _check_player_in_keys(self, player): + """ Raise an error if :code:`player` is not in the dictionary. """ + + if player not in self._data.keys(): + raise ValueError(f"{player} is not a key in this matching.") + + def _check_new_valid_type(self, new, types): + """Raise an error is :code:`new` is not an instance of one of + :code:`types`.""" + + if not isinstance(new, types): + raise ValueError(f"{new} is not one of {types} and is not valid.") diff --git a/src/matching/exceptions.py b/src/matching/exceptions.py index acd5005..bb11ce5 100644 --- a/src/matching/exceptions.py +++ b/src/matching/exceptions.py @@ -13,6 +13,10 @@ def __init__(self, **kwargs): super().__init__(self.message) +class NoStableMatchingWarning(UserWarning): + """ A warning for when a game does not have a complete stable matching. """ + + class PreferencesChangedWarning(UserWarning): """ A warning for when the preferences of a player are invalid. """ diff --git a/src/matching/game.py b/src/matching/game.py deleted file mode 100644 index 136dc53..0000000 --- a/src/matching/game.py +++ /dev/null @@ -1,107 +0,0 @@ -""" The base game class for facilitating and solving matching games. """ -import abc -import warnings - -from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning - - -class BaseGame(metaclass=abc.ABCMeta): - """An abstract base class for facilitating various matching games. - - Attributes - ---------- - matching : None - Initialised to be :code:`None`. After solving the game, - a :code:`Matching` object is found here. - blocking_pairs : None - Initialised to be :code:`None`. After solving and checking the stability - of the game instance, a list of any pairs that block the stability of - the matching. - clean : bool - Defaults to :code:`False`. When passing a set of players to create a - game instance, this allows for the automatic cleaning of the players. - """ - - def __init__(self, clean=False): - - self.matching = None - self.blocking_pairs = None - self.clean = clean - - def _remove_player(self, player, player_party, other_party): - """ Remove a player from the game and any relevant preference lists. """ - - party = vars(self)[player_party][:] - party.remove(player) - vars(self)[player_party].remove(player) - for other in vars(self)[other_party]: - if player in other.prefs: - other.forget(player) - - def _check_inputs_player_prefs_unique(self, party): - """Check that each player in :code:`party` has not ranked another - player more than once. If so, and :code:`clean` is :code:`True`, then - take the first instance they appear in the preference list.""" - - for player in vars(self)[party]: - unique_prefs = [] - for other in player.prefs: - if other not in unique_prefs: - unique_prefs.append(other) - else: - warnings.warn( - PreferencesChangedWarning( - f"{player} has ranked {other} multiple times." - ) - ) - - if self.clean: - player.set_prefs(unique_prefs) - - def _check_inputs_player_prefs_all_in_party(self, party, other_party): - """Check that each player in :code:`party` has ranked only players in - :code:`other_party`. If :code:`clean`, then forget any extra - preferences.""" - - players = vars(self)[party] - others = vars(self)[other_party] - for player in players: - - for other in player.prefs: - if other not in others: - warnings.warn( - PreferencesChangedWarning( - f"{player} has ranked a non-{other_party[:-1]}: " - f"{other}." - ) - ) - if self.clean: - player.forget(other) - - def _check_inputs_player_prefs_nonempty(self, party, other_party): - """Make sure that each player in :code:`party` has a nonempty - preference list of players in :code:`other_party`. If :code:`clean`, - remove any such player.""" - - for player in vars(self)[party]: - - if not player.prefs: - warnings.warn( - PlayerExcludedWarning( - f"{player} has an empty preference list." - ) - ) - if self.clean: - self._remove_player(player, party, other_party) - - @abc.abstractmethod - def solve(self): - """ Placeholder for solving the given matching game. """ - - @abc.abstractmethod - def check_stability(self): - """ Placeholder for checking the stability of the current matching. """ - - @abc.abstractmethod - def check_validity(self): - """ Placeholder for checking the validity of the current matching. """ diff --git a/src/matching/games/__init__.py b/src/matching/games/__init__.py index 4e6f7d4..92b67c8 100644 --- a/src/matching/games/__init__.py +++ b/src/matching/games/__init__.py @@ -1,17 +1,13 @@ -""" Make the games and their algorithms accessible. """ +""" Top-level imports for the `matching.games` subpackage. """ -from .hospital_resident import HospitalResident, hospital_resident -from .stable_marriage import StableMarriage, stable_marriage -from .stable_roommates import StableRoommates, stable_roommates -from .student_allocation import StudentAllocation, student_allocation +from .hospital_resident import HospitalResident +from .stable_marriage import StableMarriage +from .stable_roommates import StableRoommates +from .student_allocation import StudentAllocation __all__ = [ - HospitalResident, - StableMarriage, - StableRoommates, - StudentAllocation, - hospital_resident, - stable_marriage, - stable_roommates, - student_allocation, + "HospitalResident", + "StableMarriage", + "StableRoommates", + "StudentAllocation", ] diff --git a/src/matching/games/hospital_resident.py b/src/matching/games/hospital_resident.py index db134be..97aa6fe 100644 --- a/src/matching/games/hospital_resident.py +++ b/src/matching/games/hospital_resident.py @@ -1,9 +1,10 @@ -""" The HR solver and algorithm. """ +""" The HR game class and supporting functions. """ import copy import warnings -from matching import BaseGame, Matching +from matching import BaseGame, MultipleMatching from matching import Player as Resident +from matching.algorithms import hospital_resident from matching.exceptions import ( MatchingError, PlayerExcludedWarning, @@ -11,8 +12,6 @@ ) from matching.players import Hospital -from .util import delete_pair, match_pair - class HospitalResident(BaseGame): """A class for solving instances of the hospital-resident assignment @@ -85,7 +84,7 @@ def solve(self, optimal="resident"): """Solve the instance of HR using either the resident- or hospital-oriented algorithm. Return the matching.""" - self.matching = Matching( + self.matching = MultipleMatching( hospital_resident(self.residents, self.hospitals, optimal) ) return self.matching @@ -186,7 +185,7 @@ def _check_inputs_player_prefs_all_reciprocated(self, party): ) ) if self.clean: - player.forget(other) + player._forget(other) def _check_inputs_player_reciprocated_all_prefs(self, party, other_party): """Make sure that each player in :code:`party` has ranked all those @@ -207,7 +206,7 @@ def _check_inputs_player_reciprocated_all_prefs(self, party, other_party): ) ) if self.clean: - other.forget(player) + other._forget(player) def _check_inputs_player_capacity(self, party, other_party): """Check that each player in :code:`party` has a capacity of at least @@ -247,147 +246,6 @@ def _check_hospital_unhappy(resident, hospital): ) -def unmatch_pair(resident, hospital): - """ Unmatch a (resident, hospital)-pair. """ - - resident.unmatch() - hospital.unmatch(resident) - - -def hospital_resident(residents, hospitals, optimal="resident"): - """Solve an instance of HR using an adapted Gale-Shapley algorithm - :cite:`Rot84`. A unique, stable and optimal matching is found for the given - set of residents and hospitals. The optimality of the matching is found with - respect to one party and is subsequently the worst stable matching for the - other. - - Parameters - ---------- - residents : list of Player - The residents in the game. Each resident must rank a non-empty subset - of the elements of ``hospitals``. - hospitals : list of Hospital - The hospitals in the game. Each hospital must rank all the residents - that have ranked them. - optimal : str, optional - Which party the matching should be optimised for. Must be one of - ``"resident"`` and ``"hospital"``. Defaults to the former. - - Returns - ------- - matching : Matching - A dictionary-like object where the keys are the members of - ``hospitals``, and the values are their matches ranked by preference. - """ - - if optimal == "resident": - return resident_optimal(residents, hospitals) - if optimal == "hospital": - return hospital_optimal(hospitals) - - -def resident_optimal(residents, hospitals): - """Solve the instance of HR to be resident-optimal. The algorithm is as - follows: - - 0. Set all residents to be unmatched, and all hospitals to be totally - unsubscribed. - - 1. Take any unmatched resident with a non-empty preference list, - :math:`r`, and consider their most preferred hospital, :math:`h`. Match - them to one another. - - 2. If, as a result of this new matching, :math:`h` is now - over-subscribed, find the worst resident currently assigned to - :math:`h`, :math:`r'`. Set :math:`r'` to be unmatched and remove them - from :math:`h`'s matching. Otherwise, go to 3. - - 3. If :math:`h` is at capacity (fully subscribed) then find their worst - current match :math:`r'`. Then, for each successor, :math:`s`, to - :math:`r'` in the preference list of :math:`h`, delete the pair - :math:`(s, h)` from the game. Otherwise, go to 4. - - 4. Go to 1 until there are no such residents left, then end. - """ - - free_residents = residents[:] - while free_residents: - - resident = free_residents.pop() - hospital = resident.get_favourite() - - match_pair(resident, hospital) - - if len(hospital.matching) > hospital.capacity: - worst = hospital.get_worst_match() - unmatch_pair(worst, hospital) - free_residents.append(worst) - - if len(hospital.matching) == hospital.capacity: - successors = hospital.get_successors() - for successor in successors: - delete_pair(hospital, successor) - if not successor.prefs: - free_residents.remove(successor) - - return {r: r.matching for r in hospitals} - - -def hospital_optimal(hospitals): - """Solve the instance of HR to be hospital-optimal. The algorithm is as - follows: - - 0. Set all residents to be unmatched, and all hospitals to be totally - unsubscribed. - - 1. Take any hospital, :math:`h`, that is under-subscribed and whose - preference list contains any resident they are not currently assigned - to, and consider their most preferred such resident, :math:`r`. - - 2. If :math:`r` is currently matched, say to :math:`h'`, then unmatch - them from one another. In any case, match :math:`r` to :math:`h` and go - to 3. - - 3. For each successor, :math:`s`, to :math:`h` in the preference list of - :math:`r`, delete the pair :math:`(r, s)` from the game. - - 4. Go to 1 until there are no such hospitals left, then end. - """ - - free_hospitals = hospitals[:] - while free_hospitals: - - hospital = free_hospitals.pop() - resident = hospital.get_favourite() - - if resident.matching: - curr_match = resident.matching - unmatch_pair(resident, curr_match) - if curr_match not in free_hospitals: - free_hospitals.append(curr_match) - - match_pair(resident, hospital) - if len(hospital.matching) < hospital.capacity and [ - res for res in hospital.prefs if res not in hospital.matching - ]: - free_hospitals.append(hospital) - - successors = resident.get_successors() - for successor in successors: - delete_pair(resident, successor) - if ( - not [ - res - for res in successor.prefs - if res not in successor.matching - ] - and successor in free_hospitals - ): - free_hospitals.remove(successor) - - return {r: r.matching for r in hospitals} - - def _make_players(resident_prefs, hospital_prefs, capacities): """Make a set of residents and hospitals from the dictionaries given, and add their preferences.""" diff --git a/src/matching/games/stable_marriage.py b/src/matching/games/stable_marriage.py index afe7d75..3da0cd3 100644 --- a/src/matching/games/stable_marriage.py +++ b/src/matching/games/stable_marriage.py @@ -1,12 +1,10 @@ -""" The SM solver and algorithm. """ - +""" The SM game class and supporting functions. """ import copy -from matching import BaseGame, Matching, Player +from matching import BaseGame, Player, SingleMatching +from matching.algorithms import stable_marriage from matching.exceptions import MatchingError -from .util import delete_pair, match_pair - class StableMarriage(BaseGame): """A class for solving instances of the stable marriage problem (SM). @@ -53,7 +51,7 @@ def solve(self, optimal="suitor"): """Solve the instance of SM using either the suitor- or reviewer-oriented Gale-Shapley algorithm. Return the matching.""" - self.matching = Matching( + self.matching = SingleMatching( stable_marriage(self.suitors, self.reviewers, optimal) ) return self.matching @@ -161,63 +159,6 @@ def _check_player_ranks(self, player): return True -def unmatch_pair(suitor, reviewer): - """ Unmatch a (suitor, reviewer) pair. """ - - suitor.unmatch() - reviewer.unmatch() - - -def stable_marriage(suitors, reviewers, optimal="suitor"): - """An extended version of the original Gale-Shapley algorithm which makes - use of the inherent structures of SM instances. A unique, stable and optimal - matching is found for any valid set of suitors and reviewers. The optimality - of the matching is with respect to one party and is subsequently the worst - stable matching for the other. - - Parameters - ---------- - suitors : list of Player - The suitors in the game. Each must rank all of those in ``reviewers``. - reviewers : list of Player - The reviewers in the game. Each must rank all of those in ``suitors``. - optimal : str, optional - Which party the matching should be optimised for. Must be one of - ``"suitor"`` and ``"reviewer"``. Defaults to the former. - - Returns - ------- - matching : Matching - A dictionary-like object where the keys are given by the members of - ``suitors``, and the values are their match in ``reviewers``. - """ - - if optimal.lower() == "reviewer": - suitors, reviewers = reviewers, suitors - - free_suitors = [s for s in suitors if not s.matching] - while free_suitors: - - suitor = free_suitors.pop() - reviewer = suitor.get_favourite() - - if reviewer.matching: - curr_match = reviewer.matching - unmatch_pair(curr_match, reviewer) - free_suitors.append(curr_match) - - match_pair(suitor, reviewer) - - successors = reviewer.get_successors() - for successor in successors: - delete_pair(successor, reviewer) - - if optimal.lower() == "reviewer": - suitors, reviewers = reviewers, suitors - - return {s: s.matching for s in suitors} - - def _make_players(suitor_prefs, reviewer_prefs): """Make a set of ``Player`` instances each for suitors and reviewers from the dictionaries given. Add their preferences.""" diff --git a/src/matching/games/stable_roommates.py b/src/matching/games/stable_roommates.py index 582159c..f9c60fa 100644 --- a/src/matching/games/stable_roommates.py +++ b/src/matching/games/stable_roommates.py @@ -1,8 +1,8 @@ -""" The SR solver and algorithm. """ - +""" The SR game class and supporting functions. """ import copy -from matching import BaseGame, Matching, Player +from matching import BaseGame, Player, SingleMatching +from matching.algorithms import stable_roommates from matching.exceptions import MatchingError @@ -42,7 +42,7 @@ def solve(self): """Solve the instance of SR using Irving's algorithm. Return the matching.""" - self.matching = Matching(stable_roommates(self.players)) + self.matching = SingleMatching(stable_roommates(self.players)) return self.matching def check_validity(self): @@ -97,133 +97,6 @@ def check_inputs(self): return True -def forget_pair(player, other): - """ Remove a (player, other) pair from the game. """ - - player.forget(other) - other.forget(player) - - -def forget_successors(players): - """Make each player forget those players that they like less than their - current proposal.""" - - for player in players: - if player.matching: - successors = player.get_successors() - for successor in successors: - forget_pair(player, successor) - - return players - - -def first_phase(players): - """Conduct the first phase of the algorithm where one-way proposals are - made, and unpreferable pairs are forgotten. This phase terminates when - either all players have been proposed to, or if one player has been rejected - by everyone leaving their preference list empty.""" - - proposed_to = set() - for player in players: - proposer = player - while True: - fave = proposer.get_favourite() - if not fave.matching: - fave.match(proposer) - else: - current = fave.matching - if fave.prefers(proposer, current): - fave.match(proposer) - forget_pair(fave, current) - - proposer = current - else: - forget_pair(fave, proposer) - - if fave not in proposed_to or not proposer.prefs: - break - - proposed_to.add(fave) - - players = forget_successors(players) - return players - - -def locate_all_or_nothing_cycle(player): - """Locate a cycle of (least-preferable, second-choice) pairs to be removed - from the game.""" - - lasts = [player] - seconds = [] - while True: - second_best = player.prefs[1] - their_worst = second_best.prefs[-1] - - seconds.append(second_best) - lasts.append(their_worst) - - player = their_worst - if lasts.count(player) > 1: - break - - idx = lasts.index(player) - cycle = zip(lasts[idx + 1 :], seconds[idx:]) - - return cycle - - -def second_phase(players): - """Conduct the second phase of the algorithm where all or nothing cycles - (rotations) are located and removed from the game. These reduced preference - lists form a matching.""" - - for player in players: - player.unmatch() - - player_with_second_preference = next(p for p in players if len(p.prefs) > 1) - while True: - cycle = locate_all_or_nothing_cycle(player_with_second_preference) - for player, other in cycle: - player.forget(other) - other.forget(player) - - try: - player_with_second_preference = next( - p for p in players if len(p.prefs) > 1 - ) - except StopIteration: - break - - for player in players: - if player.prefs: - player.match(player.get_favourite()) - - return players - - -def stable_roommates(players): - """Irving's algorithm :cite:`Irv85` that finds stable solutions to - instances of SR if one exists. Otherwise, an incomplete matching is found. - - Parameters - ---------- - players : list of Player - The players in the game. Each must rank all other players. - - Returns - ------- - matching : dict - A dictionary of matches where the keys and values are given by the - members of ``players``. - """ - - players = first_phase(players) - if any(len(p.prefs) > 1 for p in players): - players = second_phase(players) - - return {player: player.matching for player in players} - - def _make_players(player_prefs): """Make a set of ``Player`` instances from the dictionary given. Add their preferences.""" diff --git a/src/matching/games/student_allocation.py b/src/matching/games/student_allocation.py index b39f3e7..133d658 100644 --- a/src/matching/games/student_allocation.py +++ b/src/matching/games/student_allocation.py @@ -1,9 +1,10 @@ -""" The SA solver and algorithm. """ +""" The SA game class and supporting functions. """ import copy import warnings -from matching import Matching +from matching import MultipleMatching from matching import Player as Student +from matching.algorithms import student_allocation from matching.exceptions import ( CapacityChangedWarning, MatchingError, @@ -12,8 +13,6 @@ from matching.games import HospitalResident from matching.players import Project, Supervisor -from .util import delete_pair, match_pair - class StudentAllocation(HospitalResident): """A class for solving instances of the student-allocation problem (SA) @@ -123,7 +122,7 @@ def solve(self, optimal="student"): """Solve the instance of SA using either the student- or supervisor-optimal algorithm.""" - self.matching = Matching( + self.matching = MultipleMatching( student_allocation( self.students, self.projects, self.supervisors, optimal ) @@ -219,7 +218,7 @@ def _check_inputs_player_prefs_all_reciprocated(self, party): if self.clean: for project in supervisor.projects: - project.forget(student) + project._forget(student) else: super()._check_inputs_player_prefs_all_reciprocated(party) @@ -253,7 +252,7 @@ def _check_inputs_player_reciprocated_all_prefs(self, party, other_party): for project in set(supervisor.projects) & set( student.prefs ): - student.forget(project) + student._forget(project) else: super()._check_inputs_player_reciprocated_all_prefs( @@ -348,174 +347,6 @@ def _check_project_unhappy(project, student): ) -def unmatch_pair(student, project): - """ Unmatch a student-project pair. """ - - student.unmatch() - project.unmatch(student) - - -def student_allocation(students, projects, supervisors, optimal="student"): - """Solve an instance of SA by treating it as a bi-level HR. A unique, - stable and optimal matching is found for the given set of students, projects - and supervisors. The optimality of the matching is found with respect to one - party and is subsequently the worst stable matching for the other. - - Parameters - ---------- - students : list of Player - The students in the game. Each student must rank a subset of the - elements of ``projects``. - projects : list of Project - The projects in the game. Each project is offered by a supervisor that - governs its preferences. - supervisor : list of Supervisor - The supervisors in the game. Each supervisor offers a unique subset of - ``projects`` and ranks all the students that have ranked at least one of - these projects. - optimal : str, optional - Which party the matching should be optimised for. Must be one of - ``"student"`` and ``"supervisor"``. Defaults to the former. - - Returns - ======= - matching : Matching - A dictionary-like object where the keys are the members of ``projects`` - and their student matches are the values. - """ - - if optimal == "student": - return student_optimal(students, projects) - if optimal == "supervisor": - return supervisor_optimal(projects, supervisors) - - -def student_optimal(students, projects): - """Solve the instance of SA to be student-optimal. The algorithm is as - follows: - - 0. Set all students to be unassigned, and every project (and supervisor) - to be totally unsubscribed. - - 1. Take any student, :math:`s`, that is unassigned and has a non-empty - preference list, and consider their most preferred project, :math:`p`. - Let :math:`f` denote the supervisor that offers :math:`p`. Assign - :math:`s` to be matched to :math:`p` (and thus :math:`f`). - - 2. If :math:`p` is now over-subscribed, find its worst current match, - :math:`s'`. Unmatch :math:`p` and :math:`s'`. Else if :math:`f` is - over-subscribed, find their worst current match, :math:`s''`, and the - project they are currently subscribed to, :math:`p'`. Unmatch :math:`p'` - and :math:`s''`. - - 3. If :math:`p` is now at capacity, find their worst current match, - :math:`s'`. For each successor, :math:`t`, to :math:`s'` in the - preference list of :math:`p`, delete the pair :math:`(p, t)` from the - game. - - 4. If :math:`f` is at capacity, find their worst current match, - :math:`s'`. For each successor, :math:`t`, to :math:`s'` in the - preference list of :math:`f`, for each project, :math:`p'`, offered by - :math:`f` that :math:`t` finds acceptable, delete the pair - :math:`(p', t)` from the game. - - 5. Go to 1 until there are no such students left, then end. - """ - - free_students = students[:] - while free_students: - - student = free_students.pop() - project = student.get_favourite() - supervisor = project.supervisor - - match_pair(student, project) - - if len(project.matching) > project.capacity: - worst = project.get_worst_match() - unmatch_pair(worst, project) - free_students.append(worst) - - elif len(supervisor.matching) > supervisor.capacity: - worst = supervisor.get_worst_match() - worst_project = worst.matching - unmatch_pair(worst, worst_project) - free_students.append(worst) - - if len(project.matching) == project.capacity: - successors = project.get_successors() - for successor in successors: - delete_pair(project, successor) - if not successor.prefs: - free_students.remove(successor) - - if len(supervisor.matching) == supervisor.capacity: - successors = supervisor.get_successors() - for successor in successors: - - supervisor_projects = [ - project - for project in supervisor.projects - if project in successor.prefs - ] - - for project in supervisor_projects: - delete_pair(project, successor) - if not successor.prefs: - free_students.remove(successor) - - return {p: p.matching for p in projects} - - -def supervisor_optimal(projects, supervisors): - """Solve the instance of SA to be supervisor-optimal. The algorithm is as - follows: - - 0. Set all students to be unassigned, and every project (and supervisor) - to be totally unsubscribed. - - 1. Take any supervisor member, :math:`f`, that is under-subscribed and - whose preference list contains at least one student that is not - currently matched to at least one acceptable (though currently - under-subscribed) project offered by :math:`f`. Consider the - supervisor's most preferred such student, :math:`s`, and that student's - most preferred such project, :math:`p`. - - 2. If :math:`s` is matched to some other project, :math:`p'`, then - unmatch them. In any case, match :math:`s` and :math:`p` (and thus - :math:`f`). - - 3. For each successor, :math:`p'`, to :math:`p` in the preference list - of :math:`s`, delete the pair :math:`(p', s)` from the game. - - 4. Go to 1 until there are no such supervisors, then end. - """ - - free_supervisors = supervisors[:] - while free_supervisors: - - supervisor = free_supervisors.pop() - student, project = supervisor.get_favourite() - - if student.matching: - curr_match = student.matching - unmatch_pair(student, curr_match) - - match_pair(student, project) - - successors = student.get_successors() - for successor in successors: - delete_pair(student, successor) - - free_supervisors = [ - supervisor - for supervisor in supervisors - if supervisor.get_favourite() is not None - ] - - return {p: p.matching for p in projects} - - def _make_players( student_prefs, supervisor_prefs, diff --git a/src/matching/games/util.py b/src/matching/games/util.py deleted file mode 100644 index 187f8de..0000000 --- a/src/matching/games/util.py +++ /dev/null @@ -1,16 +0,0 @@ -""" Useful functions for the running of the various core algorithms. """ - - -def delete_pair(player, successor): - """Make a player forget one its "successors", effectively deleting the pair - from further further consideration in the game.""" - - player.forget(successor) - successor.forget(player) - - -def match_pair(suitor, reviewer): - """ Match the players given by `suitor` and `reviewer`. """ - - suitor.match(reviewer) - reviewer.match(suitor) diff --git a/src/matching/matching.py b/src/matching/matching.py deleted file mode 100644 index 5027437..0000000 --- a/src/matching/matching.py +++ /dev/null @@ -1,63 +0,0 @@ -""" A dictionary-like object for matchings. """ - -from .player import Player - - -class Matching(dict): - """A class to store, and allow for the easy updating of, matchings found by - a game solver. - - Attributes - ---------- - dictionary : dict or None - If not ``None``, a dictionary mapping a ``Player`` to one of: ``None``, - a single ``Player`` or a list of ``Player`` instances. - """ - - def __init__(self, dictionary=None): - - self.__data = {} - if dictionary is not None: - self.__data.update(dictionary) - - super().__init__(self.__data) - - def __repr__(self): - - return repr(self.__data) - - def __getitem__(self, player): - - return self.__data[player] - - def __setitem__(self, player, new_match): - - if player not in self.__data.keys(): - raise ValueError(f"{player} is not a key in this matching.") - - if isinstance(new_match, Player): - new_match.matching = player - player.matching = new_match - - elif new_match is None: - player.matching = new_match - - elif isinstance(new_match, (list, tuple)) and all( - [isinstance(new, Player) for new in new_match] - ): - player.matching = new_match - for new in new_match: - new.matching = player - - else: - raise ValueError(f"{new_match} is not a valid match.") - - self.__data[player] = new_match - - def keys(self): - - return self.__data.keys() - - def values(self): - - return self.__data.values() diff --git a/src/matching/matchings.py b/src/matching/matchings.py new file mode 100644 index 0000000..ada55e3 --- /dev/null +++ b/src/matching/matchings.py @@ -0,0 +1,59 @@ +""" A collection of dictionary-like objects for storing matchings. """ +from matching import BaseMatching +from matching.players import Player + + +class SingleMatching(BaseMatching): + """A dictionary-like object for storing and updating a matching with + singular matches such as those in an instance of SM or SR. + + Parameters + ---------- + dictionary + The dictionary of matches. Made up of :code:`Player, Optional[Player]` + key, value pairs. + """ + + def __init__(self, dictionary): + + super().__init__(dictionary) + + def __setitem__(self, player, new): + + self._check_player_in_keys(player) + self._check_new_valid_type(new, (type(None), Player)) + + player.matching = new + if isinstance(new, Player): + new.matching = player + + self._data[player] = new + + +class MultipleMatching(BaseMatching): + """A dictionary-like object for storing and updating a matching with + multiple matches such as those in an instance of HR or SA. + + Parameters + ---------- + dictionary + The dictionary of matches. Made up of :code:`Hospital, List[Player]` + key, value pairs. + """ + + def __init__(self, dictionary): + + super().__init__(dictionary) + + def __setitem__(self, player, new): + + self._check_player_in_keys(player) + self._check_new_valid_type(new, (list, tuple)) + for other in new: + self._check_new_valid_type(other, Player) + + player.matching = new + for other in new: + other.matching = player + + self._data[player] = new diff --git a/src/matching/players/__init__.py b/src/matching/players/__init__.py index 3fc4d0d..609980b 100644 --- a/src/matching/players/__init__.py +++ b/src/matching/players/__init__.py @@ -1,7 +1,8 @@ -""" Make the player classes more accessible. """ +""" Top-level imports for the `matching.players` subpackage. """ from .hospital import Hospital +from .player import Player from .project import Project from .supervisor import Supervisor -__all__ = [Hospital, Project, Supervisor] +__all__ = ["Hospital", "Player", "Project", "Supervisor"] diff --git a/src/matching/players/hospital.py b/src/matching/players/hospital.py index f5a131d..0f5d357 100644 --- a/src/matching/players/hospital.py +++ b/src/matching/players/hospital.py @@ -1,9 +1,9 @@ """ The Hospital class for use in instances of HR. """ -from matching import Player +from matching import BasePlayer -class Hospital(Player): +class Hospital(BasePlayer): """A class to represent a hospital in an instance of HR. Also used as a parent class to ``Project`` and ``Supervisor``. @@ -39,6 +39,19 @@ def __init__(self, name, capacity): self._original_capacity = capacity self.matching = [] + def _match(self, resident): + """ Add ``resident`` to the hospital's matching, and then sort it. """ + + self.matching.append(resident) + self.matching.sort(key=self.prefs.index) + + def _unmatch(self, resident): + """ Remove ``resident`` from the hospital's matching. """ + + matching = self.matching[:] + matching.remove(resident) + self.matching = matching + def oversubscribed_message(self): return ( @@ -56,19 +69,6 @@ def get_favourite(self): return None - def match(self, resident): - """ Add ``resident`` to the hospital's matching, and then sort it. """ - - self.matching.append(resident) - self.matching.sort(key=self.prefs.index) - - def unmatch(self, resident): - """ Remove ``resident`` from the hospital's matching. """ - - matching = self.matching[:] - matching.remove(resident) - self.matching = matching - def get_worst_match(self): """Get the player's worst current match. This assumes that the matching is in order of preference.""" diff --git a/src/matching/player.py b/src/matching/players/player.py similarity index 56% rename from src/matching/player.py rename to src/matching/players/player.py index bcd5074..2982186 100644 --- a/src/matching/player.py +++ b/src/matching/players/player.py @@ -1,7 +1,9 @@ """ The base Player class for use in various games. """ +from matching import BasePlayer -class Player: + +class Player(BasePlayer): """A class to represent a player within the matching game. Parameters @@ -23,58 +25,20 @@ class Player: The original set of player preferences. """ - def __init__(self, name): - - self.name = name - self.prefs = None - self.pref_names = None - self.matching = None - self._original_prefs = None - - def __repr__(self): - - return str(self.name) - - def unmatched_message(self): - - return f"{self} is unmatched." - - def not_in_preferences_message(self, other): - - return ( - f"{self} is matched to {other} but they do not appear in their " - f"preference list: {self.prefs}." - ) - - def set_prefs(self, players): - """ Set the player's preferences to be a list of players. """ - - self.prefs = players - self.pref_names = [player.name for player in players] - self._original_prefs = players[:] - - def get_favourite(self): - """ Get the player's favourite player. """ - - return self.prefs[0] - - def match(self, other): + def _match(self, other): """ Assign the player to be matched to some other player. """ self.matching = other - def unmatch(self): + def _unmatch(self): """ Set the player to be unmatched. """ self.matching = None - def forget(self, other): - """Forget another player by removing them from the player's preference - list.""" + def get_favourite(self): + """ Get the player's favourite player. """ - prefs = self.prefs[:] - prefs.remove(other) - self.prefs = prefs + return self.prefs[0] def get_successors(self): """ Get all the successors to the current match of the player. """ @@ -82,13 +46,6 @@ def get_successors(self): idx = self.prefs.index(self.matching) return self.prefs[idx + 1 :] - def prefers(self, player, other): - """Determines whether the player prefers a player over some other - player.""" - - prefs = self._original_prefs - return prefs.index(player) < prefs.index(other) - def check_if_match_is_unacceptable(self, unmatched_okay=False): """Check the acceptability of the current match, with the stipulation that being unmatched is okay (or not).""" diff --git a/src/matching/players/project.py b/src/matching/players/project.py index d886110..24615c6 100644 --- a/src/matching/players/project.py +++ b/src/matching/players/project.py @@ -32,37 +32,37 @@ def __init__(self, name, capacity): super().__init__(name, capacity) self.supervisor = None - def set_supervisor(self, supervisor): - """Set the project's supervisor and add the project to their list - of active projects.""" + def _forget(self, student): + """Remove ``student`` from the preference list of the project and its + supervisor.""" - self.supervisor = supervisor - if self not in supervisor.projects: - supervisor.projects.append(self) + if student in self.prefs: + prefs = self.prefs[:] + prefs.remove(student) + self.prefs = prefs + self.supervisor._forget(student) - def match(self, student): + def _match(self, student): """Match the project to ``student``, and update the project supervisor's matching to include ``student``, too.""" self.matching.append(student) self.matching.sort(key=self.prefs.index) - self.supervisor.match(student) + self.supervisor._match(student) - def unmatch(self, student): + def _unmatch(self, student): """Break the matching between the project and ``student``, and the matching between ``student`` and the project supervisor.""" matching = self.matching[:] matching.remove(student) self.matching = matching - self.supervisor.unmatch(student) + self.supervisor._unmatch(student) - def forget(self, student): - """Remove ``student`` from the preference list of the project and its - supervisor.""" + def set_supervisor(self, supervisor): + """Set the project's supervisor and add the project to their list + of active projects.""" - if student in self.prefs: - prefs = self.prefs[:] - prefs.remove(student) - self.prefs = prefs - self.supervisor.forget(student) + self.supervisor = supervisor + if self not in supervisor.projects: + supervisor.projects.append(self) diff --git a/src/matching/players/supervisor.py b/src/matching/players/supervisor.py index 95f91b4..ebd0a02 100644 --- a/src/matching/players/supervisor.py +++ b/src/matching/players/supervisor.py @@ -33,12 +33,23 @@ def __init__(self, name, capacity): super().__init__(name, capacity) self.projects = [] + def _forget(self, student): + """Only forget ``student`` if it is not ranked by any of the + supervisor's projects.""" + + if student in self.prefs and not any( + [student in project.prefs for project in self.projects] + ): + prefs = self.prefs[:] + prefs.remove(student) + self.prefs = prefs + def set_prefs(self, students): """Set the preference of the supervisor, and pass those on to its projects.""" self.prefs = students - self.pref_names = [student.name for student in students] + self._pref_names = [student.name for student in students] self._original_prefs = students[:] for project in self.projects: @@ -47,17 +58,6 @@ def set_prefs(self, students): ] project.set_prefs(acceptable) - def forget(self, student): - """Only forget ``student`` if it is not ranked by any of the - supervisor's projects.""" - - if student in self.prefs and not any( - [student in project.prefs for project in self.projects] - ): - prefs = self.prefs[:] - prefs.remove(student) - self.prefs = prefs - def get_favourite(self): """Find the supervisor's favourite student that it is not currently matched to, but has a preference of, one of the supervisor's diff --git a/src/matching/version.py b/src/matching/version.py index 7b1e312..e992399 100644 --- a/src/matching/version.py +++ b/src/matching/version.py @@ -1 +1 @@ -__version__ = "1.3.3" +__version__ = "1.4" diff --git a/tests/unit/__init__.py b/tests/base/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to tests/base/__init__.py diff --git a/tests/base/test_game.py b/tests/base/test_game.py new file mode 100644 index 0000000..2c910e1 --- /dev/null +++ b/tests/base/test_game.py @@ -0,0 +1,126 @@ +""" Tests for the BaseGame class. """ +import warnings + +from hypothesis import given +from hypothesis.strategies import booleans + +from matching import BaseGame, Player +from matching.exceptions import PlayerExcludedWarning, PreferencesChangedWarning + +from .util import player_others + + +class DummyGame(BaseGame): + def solve(self): + pass + + def check_stability(self): + pass + + def check_validity(self): + pass + + +@given(clean=booleans()) +def test_init(clean): + """ Make a BaseGame instance and test it has the correct attributes. """ + + game = DummyGame(clean) + + assert isinstance(game, BaseGame) + assert game.matching is None + assert game.blocking_pairs is None + assert game.clean is clean + + +@given(player_others=player_others()) +def test_remove_player(player_others): + """ Test that a player can be removed from a game and its players. """ + + player, others = player_others + + player.set_prefs(others) + for other in others: + other.set_prefs([player]) + + game = DummyGame() + game.players = [player] + game.others = others + + game._remove_player(player, "players", "others") + assert player not in game.players + assert all(player not in other.prefs for other in game.others) + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_unique(player_others, clean): + """ Test that a game can verify its players have unique preferences. """ + + player, others = player_others + + player.set_prefs(others + others[:1]) + + game = DummyGame(clean) + game.players = [player] + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_unique("players") + + message = w[-1].message + assert isinstance(message, PreferencesChangedWarning) + assert str(message).startswith(player.name) + assert others[0].name in str(message) + if clean: + assert player._pref_names == [o.name for o in others] + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_all_in_party(player_others, clean): + """ " Test that a game can verify its players have only got preferences in + the correct party.""" + + player, others = player_others + + outsider = Player("foo") + player.set_prefs([outsider]) + + game = DummyGame(clean) + game.players = [player] + game.others = others + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_all_in_party("players", "others") + + message = w[-1].message + assert isinstance(message, PreferencesChangedWarning) + assert str(message).startswith(player.name) + assert "non-other" in str(message) + assert outsider.name in str(message) + if clean: + assert outsider not in player.prefs + + +@given(player_others=player_others(), clean=booleans()) +def test_check_inputs_player_prefs_nonempty(player_others, clean): + """ " Test that a game can verify its players have got nonempty preference + lists.""" + + player, others = player_others + + player.set_prefs(others) + other = others[0] + + game = DummyGame(clean) + game.players = [player] + game.others = [other] + + with warnings.catch_warnings(record=True) as w: + game._check_inputs_player_prefs_nonempty("others", "players") + + message = w[-1].message + assert isinstance(message, PlayerExcludedWarning) + assert str(message).startswith(other.name) + + if clean: + assert other not in game.others + assert player.prefs == others[1:] diff --git a/tests/base/test_matching.py b/tests/base/test_matching.py new file mode 100644 index 0000000..8b5497e --- /dev/null +++ b/tests/base/test_matching.py @@ -0,0 +1,95 @@ +""" Tests for the BaseMatching class. """ +import pytest +from hypothesis import given +from hypothesis.strategies import dictionaries, text + +from matching import BaseMatching + +DICTIONARIES = given( + dictionary=dictionaries( + keys=text(), + values=text(), + min_size=1, + max_size=3, + ) +) + + +@DICTIONARIES +def test_init(dictionary): + """ Make a matching and check their attributes are correct. """ + + matching = BaseMatching() + assert matching == {} + + matching = BaseMatching(dictionary) + assert matching == dictionary + + +@DICTIONARIES +def test_repr(dictionary): + """ Check that a matching is represented by a normal dictionary. """ + + matching = BaseMatching() + assert repr(matching) == "{}" + + matching = BaseMatching(dictionary) + assert repr(matching) == str(dictionary) + + +@DICTIONARIES +def test_keys(dictionary): + """ Check a matching can have its `keys` accessed. """ + + matching = BaseMatching() + assert list(matching.keys()) == [] + + matching = BaseMatching(dictionary) + assert list(matching.keys()) == list(dictionary.keys()) + + +@DICTIONARIES +def test_values(dictionary): + """ Check a matching can have its `values` accessed. """ + + matching = BaseMatching() + assert list(matching.values()) == [] + + matching = BaseMatching(dictionary) + assert list(matching.values()) == list(dictionary.values()) + + +@DICTIONARIES +def test_getitem(dictionary): + """ Check that you can access items in a matching correctly. """ + + matching = BaseMatching(dictionary) + for (mkey, mval), (dkey, dval) in zip(matching.items(), dictionary.items()): + assert matching[mkey] == mval + assert (mkey, mval) == (dkey, dval) + + +@DICTIONARIES +def test_setitem_check_player_in_keys(dictionary): + """Check that a `ValueError` is raised if trying to add a new item to a + matching.""" + + key = list(dictionary.keys())[0] + matching = BaseMatching(dictionary) + assert matching._check_player_in_keys(key) is None + + with pytest.raises(ValueError): + matching._check_player_in_keys(key + "foo") + + +@DICTIONARIES +def test_setitem_check_new_valid_type(dictionary): + """Check that a `ValueError` is raised if a new match is not one of the + provided types.""" + + val = list(dictionary.values())[0] + matching = BaseMatching(dictionary) + assert matching._check_new_valid_type(val, str) is None + + with pytest.raises(ValueError): + matching._check_new_valid_type(val, float) diff --git a/tests/base/test_player.py b/tests/base/test_player.py new file mode 100644 index 0000000..0d22f31 --- /dev/null +++ b/tests/base/test_player.py @@ -0,0 +1,113 @@ +""" Tests for the BasePlayer class. """ +from hypothesis import given +from hypothesis.strategies import text + +from matching import BasePlayer + +from .util import player_others + + +@given(name=text()) +def test_init(name): + """ Make a Player instance and test that their attributes are correct. """ + + player = BasePlayer(name) + assert player.name == name + assert player.prefs == [] + assert player.matching is None + assert player._pref_names == [] + assert player._original_prefs is None + + +@given(name=text()) +def test_repr(name): + """Test that a Player instance is represented by the string version of + their name.""" + + player = BasePlayer(name) + assert repr(player) == name + + player = BasePlayer(0) + assert repr(player) == str(0) + + +@given(name=text()) +def test_unmatched_message(name): + """Test that a Player instance can return a message saying they are + unmatched. This is could be a lie.""" + + player = BasePlayer(name) + + message = player.unmatched_message() + assert message.startswith(name) + assert "unmatched" in message + + +@given(player_others=player_others()) +def test_not_in_preferences_message(player_others): + """Test that a Player instance can return a message saying they are matched + to another player who does not appear in their preferences. This could be a + lie.""" + + player, others = player_others + + other = others.pop() + player.set_prefs(others) + message = player.not_in_preferences_message(other) + assert message.startswith(player.name) + assert str(player.prefs) in message + assert other.name in message + + +@given(player_others=player_others()) +def test_set_prefs(player_others): + """ Test that a Player instance can set its preferences correctly. """ + + player, others = player_others + + player.set_prefs(others) + assert player.prefs == others + assert player._pref_names == [o.name for o in others] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_keep_original_prefs(player_others): + """Test that a Player instance keeps a record of their original preference + list even when their preferences are updated.""" + + player, others = player_others + + player.set_prefs(others) + player.set_prefs([]) + assert player.prefs == [] + assert player._pref_names == [] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_forget(player_others): + """ Test that a Player instance can forget another player. """ + + player, others = player_others + player.set_prefs(others) + + for i, other in enumerate(others[:-1]): + player._forget(other) + assert player.prefs == others[i + 1 :] + + player._forget(others[-1]) + assert player.prefs == [] + assert player._original_prefs == others + + +@given(player_others=player_others()) +def test_prefers(player_others): + """Test that a Player instance can compare its preference between two + players.""" + + player, others = player_others + + player.set_prefs(others) + for i, other in enumerate(others[:-1]): + assert player.prefers(other, others[i + 1]) diff --git a/tests/base/util.py b/tests/base/util.py new file mode 100644 index 0000000..5998e75 --- /dev/null +++ b/tests/base/util.py @@ -0,0 +1,22 @@ +""" Useful functions for base class tests. """ +from hypothesis.strategies import composite, integers, text + +from matching import BasePlayer + + +@composite +def player_others( + draw, + player_name_from=text(), + other_names_from=text(), + min_size=1, + max_size=10, +): + """A custom strategy for creating a player and a set of other players, all + of whom are `BasePlayer` instances.""" + + size = draw(integers(min_value=min_size, max_value=max_size)) + player = BasePlayer(draw(player_name_from)) + others = [BasePlayer(draw(other_names_from)) for _ in range(size)] + + return player, others diff --git a/tests/hospital_resident/params.py b/tests/hospital_resident/params.py deleted file mode 100644 index c64da66..0000000 --- a/tests/hospital_resident/params.py +++ /dev/null @@ -1,109 +0,0 @@ -""" Toolbox for HR tests. """ - -import itertools as it -from collections import defaultdict - -import numpy as np -from hypothesis import given -from hypothesis.strategies import booleans, integers, lists, sampled_from - -from matching import Player as Resident -from matching.games import HospitalResident -from matching.players import Hospital - - -def make_players(resident_names, hospital_names, capacities): - """ Given some names and capacities, make a set of players for HR. """ - - residents = [Resident(name) for name in resident_names] - hospitals = [ - Hospital(name, capacity) - for name, capacity in zip(hospital_names, capacities) - ] - - possible_prefs = get_possible_prefs(hospitals) - logged_prefs = {} - for resident in residents: - prefs = possible_prefs[np.random.randint(len(possible_prefs))] - resident.set_prefs(prefs) - for hospital in prefs: - try: - logged_prefs[hospital] += [resident] - except KeyError: - logged_prefs[hospital] = [resident] - - for hospital, resids in logged_prefs.items(): - hospital.set_prefs(np.random.permutation(resids).tolist()) - - return residents, [hosp for hosp in hospitals if hosp.prefs is not None] - - -def get_possible_prefs(players): - """Generate the list of all possible non-empty preference lists made from a - list of players.""" - - all_ordered_subsets = { - tuple(set(sub)) for sub in it.product(players, repeat=len(players)) - } - - possible_prefs = [ - list(perm) - for sub in all_ordered_subsets - for perm in it.permutations(sub) - ] - - return possible_prefs - - -def make_game(resident_names, hospital_names, capacities, seed, clean): - """ Make all of the residents and hospitals, and the match itself. """ - - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - game = HospitalResident(residents, hospitals, clean) - - return residents, hospitals, game - - -def make_prefs(resident_names, hospital_names, seed): - """ Make a valid set of preferences given a set of names. """ - - np.random.seed(seed) - resident_prefs, hospital_prefs = defaultdict(list), defaultdict(list) - possible_prefs = get_possible_prefs(hospital_names) - - for resident in resident_names: - prefs = possible_prefs[np.random.randint(len(possible_prefs))] - resident_prefs[resident].extend(prefs) - for hospital in prefs: - hospital_prefs[hospital].append(resident) - - for hospital in hospital_prefs: - np.random.shuffle(hospital_prefs[hospital]) - - return resident_prefs, hospital_prefs - - -HOSPITAL_RESIDENT = given( - resident_names=lists( - elements=sampled_from(["A", "B", "C", "D"]), - min_size=1, - max_size=4, - unique=True, - ), - hospital_names=lists( - elements=sampled_from(["X", "Y", "Z"]), - min_size=1, - max_size=3, - unique=True, - ), - capacities=lists( - elements=integers(min_value=2, max_value=4), - min_size=3, - max_size=3, - ), - seed=integers(min_value=0, max_value=2 ** 32 - 1), - clean=booleans(), -) diff --git a/tests/hospital_resident/test_algorithm.py b/tests/hospital_resident/test_algorithm.py index 0b93a0f..9db60a3 100644 --- a/tests/hospital_resident/test_algorithm.py +++ b/tests/hospital_resident/test_algorithm.py @@ -1,26 +1,44 @@ """ Tests for the Hospital-Resident algorithm. """ - import numpy as np +from hypothesis import given -from matching.games import hospital_resident +from matching.algorithms.hospital_resident import ( + hospital_optimal, + hospital_resident, + resident_optimal, +) -from .params import HOSPITAL_RESIDENT, make_players +from .util import players -@HOSPITAL_RESIDENT -def test_resident_optimal( - resident_names, hospital_names, capacities, seed, clean -): - """Verify that the hospital-resident algorithm produces a valid, - resident-optimal matching for an instance of HR.""" +@given(players_=players()) +def test_hospital_resident(players_): + """Test that the hospital-resident algorithm produces a valid solution + for an instance of HR.""" - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - matching = hospital_resident(residents, hospitals, optimal="resident") + residents, hospitals = players_ + + matching = hospital_resident(residents, hospitals) + assert set(hospitals) == set(matching.keys()) + + matched_residents = {r for rs in matching.values() for r in rs} + for resident in residents: + if resident.matching: + assert resident in matched_residents + else: + assert resident not in matched_residents + + +@given(players_=players()) +def test_resident_optimal(players_): + """Test that the resident-optimal algorithm produces a solution that is + indeed resident-optimal.""" + residents, hospitals = players_ + + matching = resident_optimal(residents, hospitals) assert set(hospitals) == set(matching.keys()) + assert all( [ r in set(residents) @@ -35,19 +53,14 @@ def test_resident_optimal( assert resident.prefs.index(resident.matching) == 0 -@HOSPITAL_RESIDENT -def test_hospital_optimal( - resident_names, hospital_names, capacities, seed, clean -): - """Verify that the hospital-resident algorithm produces a valid, - hospital-optimal matching for an instance of HR.""" +@given(players_=players()) +def test_hospital_optimal(players_): + """Verify that the hospital-optimal algorithm produces a solution that is + indeed hospital-optimal.""" - np.random.seed(seed) - residents, hospitals = make_players( - resident_names, hospital_names, capacities - ) - matching = hospital_resident(residents, hospitals, optimal="hospital") + _, hospitals = players_ + matching = hospital_optimal(hospitals) assert set(hospitals) == set(matching.keys()) for hospital, matches in matching.items(): diff --git a/tests/hospital_resident/test_solver.py b/tests/hospital_resident/test_solver.py index b092f10..dff59b5 100644 --- a/tests/hospital_resident/test_solver.py +++ b/tests/hospital_resident/test_solver.py @@ -2,8 +2,10 @@ import warnings import pytest +from hypothesis import given +from hypothesis.strategies import booleans, sampled_from -from matching import Matching +from matching import MultipleMatching from matching import Player as Resident from matching.exceptions import ( MatchingError, @@ -13,70 +15,60 @@ from matching.games import HospitalResident from matching.players import Hospital -from .params import HOSPITAL_RESIDENT, make_game, make_prefs +from .util import connections, games, players -@HOSPITAL_RESIDENT -def test_init(resident_names, hospital_names, capacities, seed, clean): +@given(players=players(), clean=booleans()) +def test_init(players, clean): """Test that an instance of HospitalResident is created correctly when passed a set of players.""" - residents, hospitals, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) + residents, hospitals = players + + game = HospitalResident(residents, hospitals, clean) for resident, game_resident in zip(residents, game.residents): assert resident.name == game_resident.name - assert resident.pref_names == game_resident.pref_names + assert resident._pref_names == game_resident._pref_names for hospital, game_hospital in zip(hospitals, game.hospitals): assert hospital.name == game_hospital.name - assert hospital.pref_names == game_hospital.pref_names + assert hospital._pref_names == game_hospital._pref_names assert hospital.capacity == game_hospital.capacity assert all([resident.matching is None for resident in game.residents]) assert all([hospital.matching == [] for hospital in game.hospitals]) assert game.matching is None - assert game.clean is clean -@HOSPITAL_RESIDENT -def test_create_from_dictionaries( - resident_names, hospital_names, capacities, seed, clean -): +@given(connections=connections(), clean=booleans()) +def test_create_from_dictionaries(connections, clean): """Test that HospitalResident is created correctly when passed a set of dictionaries for each party.""" - resident_prefs, hospital_prefs = make_prefs( - resident_names, hospital_names, seed - ) + resident_prefs, hospital_prefs, capacities = connections - capacities_ = dict(zip(hospital_names, capacities)) game = HospitalResident.create_from_dictionaries( - resident_prefs, hospital_prefs, capacities_, clean + resident_prefs, hospital_prefs, capacities, clean ) for resident in game.residents: - assert resident.pref_names == resident_prefs[resident.name] + assert resident._pref_names == resident_prefs[resident.name] assert resident.matching is None for hospital in game.hospitals: - assert hospital.pref_names == hospital_prefs[hospital.name] - assert hospital.capacity == capacities_[hospital.name] + assert hospital._pref_names == hospital_prefs[hospital.name] + assert hospital.capacity == capacities[hospital.name] assert hospital.matching == [] assert game.matching is None assert game.clean is clean -@HOSPITAL_RESIDENT -def test_check_inputs(resident_names, hospital_names, capacities, seed, clean): +@given(game=games()) +def test_check_inputs(game): """ Test that inputs to an instance of HR can be verified. """ - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - with warnings.catch_warnings(record=True) as w: game.check_inputs() @@ -85,18 +77,12 @@ def test_check_inputs(resident_names, hospital_names, capacities, seed, clean): assert game.hospitals == game._all_hospitals -@HOSPITAL_RESIDENT -def test_check_inputs_resident_prefs_all_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_resident_prefs_all_hospitals(game): """Test that every resident has only hospitals in its preference list. If not, check that a warning is caught and the player's preferences are changed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] resident.prefs = [Resident("foo")] with warnings.catch_warnings(record=True) as w: @@ -106,22 +92,16 @@ def test_check_inputs_resident_prefs_all_hospitals( assert isinstance(message, PreferencesChangedWarning) assert resident.name in str(message) assert "foo" in str(message) - if clean: + if game.clean: assert resident.prefs == [] -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_residents( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_residents(game): """Test that every hospital has only residents in its preference list. If not, check that a warning is caught and the player's preferences are changed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.prefs = [Resident("foo")] with warnings.catch_warnings(record=True) as w: @@ -131,25 +111,19 @@ def test_check_inputs_hospital_prefs_all_residents( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert "foo" in str(message) - if clean: + if game.clean: assert hospital.prefs == [] -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_reciprocated( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_reciprocated(game): """Test that each hospital has ranked only those residents that have ranked it. If not, check that a warning is caught and the hospital has forgotten any such players.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = hospital.prefs[0] - resident.forget(hospital) + resident._forget(hospital) with warnings.catch_warnings(record=True) as w: game._check_inputs_player_prefs_all_reciprocated("hospitals") @@ -157,25 +131,19 @@ def test_check_inputs_hospital_prefs_all_reciprocated( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert resident.name in str(message) - if clean: + if game.clean: assert resident not in hospital.prefs -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_reciprocated_all_prefs( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_reciprocated_all_prefs(game): """Test that each hospital has ranked all those residents that have ranked it. If not, check that a warning is caught and any such resident has forgotten the hospital.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = hospital.prefs[0] - hospital.forget(resident) + hospital._forget(resident) with warnings.catch_warnings(record=True) as w: game._check_inputs_player_reciprocated_all_prefs( "hospitals", "residents" @@ -185,21 +153,15 @@ def test_check_inputs_hospital_reciprocated_all_prefs( assert isinstance(message, PreferencesChangedWarning) assert hospital.name in str(message) assert resident.name in str(message) - if clean: + if game.clean: assert hospital not in resident.prefs -@HOSPITAL_RESIDENT -def test_check_inputs_resident_prefs_all_nonempty( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_resident_prefs_all_nonempty(game): """Test that every resident has a non-empty preference list. If not, check that a warning is caught and the player has been removed from the game.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] resident.prefs = [] with warnings.catch_warnings(record=True) as w: @@ -208,21 +170,15 @@ def test_check_inputs_resident_prefs_all_nonempty( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert resident.name in str(message) - if clean: + if game.clean: assert resident not in game.residents -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_prefs_all_nonempty( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_prefs_all_nonempty(game): """Test that every hospital has a non-empty preference list. If not, check that a warning is caught and the player has been removed from the game.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.prefs = [] with warnings.catch_warnings(record=True) as w: @@ -231,22 +187,16 @@ def test_check_inputs_hospital_prefs_all_nonempty( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert hospital.name in str(message) - if clean: + if game.clean: assert hospital not in game.hospitals -@HOSPITAL_RESIDENT -def test_check_inputs_hospital_capacity( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_inputs_hospital_capacity(game): """Test that each hospital has enough space to accommodate their largest project, but does not offer a surplus of spaces from their projects. Otherwise, raise an Exception.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] capacity = hospital.capacity hospital.capacity = 0 @@ -257,68 +207,50 @@ def test_check_inputs_hospital_capacity( message = w[-1].message assert isinstance(message, PlayerExcludedWarning) assert hospital.name in str(message) - if clean: + if game.clean: assert hospital not in game.hospitals -@HOSPITAL_RESIDENT -def test_solve(resident_names, hospital_names, capacities, seed, clean): - """Test that HospitalResident can solve games correctly when passed - players.""" - - for optimal in ["resident", "hospital"]: - residents, hospitals, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) +@given(game=games(), optimal=sampled_from(["resident", "hospital"])) +def test_solve(game, optimal): + """ Test that HospitalResident can solve games correctly. """ - matching = game.solve(optimal) - assert isinstance(matching, Matching) + matching = game.solve(optimal) + assert isinstance(matching, MultipleMatching) - hospitals = sorted(hospitals, key=lambda h: h.name) - matching_keys = sorted(matching.keys(), key=lambda k: k.name) - for game_hospital, hospital in zip(matching_keys, hospitals): - assert game_hospital.name == hospital.name - assert game_hospital.pref_names == hospital.pref_names - assert game_hospital.capacity == hospital.capacity + hospitals = sorted(game.hospitals, key=lambda h: h.name) + matching_keys = sorted(matching.keys(), key=lambda k: k.name) + for game_hospital, hospital in zip(matching_keys, hospitals): + assert game_hospital.name == hospital.name + assert game_hospital._pref_names == hospital._pref_names + assert game_hospital.capacity == hospital.capacity - matched_residents = [ - resident for match in matching.values() for resident in match - ] + matched_residents = [ + resident for match in matching.values() for resident in match + ] - assert matched_residents != [] and set(matched_residents).issubset( - set(game.residents) - ) + assert matched_residents != [] and set(matched_residents).issubset( + set(game.residents) + ) - for resident in set(game.residents) - set(matched_residents): - assert resident.matching is None + for resident in set(game.residents) - set(matched_residents): + assert resident.matching is None -@HOSPITAL_RESIDENT -def test_check_validity( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_validity(game): """Test that HospitalResident finds a valid matching when the game is solved.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - game.solve() assert game.check_validity() -@HOSPITAL_RESIDENT -def test_check_for_unacceptable_matches_residents( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_unacceptable_matches_residents(game): """Test that HospitalResident recognises a valid matching requires each resident to have a preference of their match, if they have one.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - resident = game.residents[0] hospital = Hospital(name="foo", capacity=1) resident.matching = hospital @@ -337,17 +269,11 @@ def test_check_for_unacceptable_matches_residents( assert issue == error -@HOSPITAL_RESIDENT -def test_check_for_unacceptable_matches_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_unacceptable_matches_hospitals(game): """Test that HospitalResident recognises a valid matching requires each hospital to have a preference of each of its matches, if any.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] resident = Resident(name="foo") hospital.matching.append(resident) @@ -366,17 +292,11 @@ def test_check_for_unacceptable_matches_hospitals( assert issue == error -@HOSPITAL_RESIDENT -def test_check_for_oversubscribed_hospitals( - resident_names, hospital_names, capacities, seed, clean -): +@given(game=games()) +def test_check_for_oversubscribed_hospitals(game): """Test that HospitalResident recognises a valid matching requires all hospitals to not be oversubscribed.""" - _, _, game = make_game( - resident_names, hospital_names, capacities, seed, clean - ) - hospital = game.hospitals[0] hospital.matching = range(hospital.capacity + 1) diff --git a/tests/hospital_resident/util.py b/tests/hospital_resident/util.py new file mode 100644 index 0000000..c9af988 --- /dev/null +++ b/tests/hospital_resident/util.py @@ -0,0 +1,99 @@ +""" Strategies for HR tests. """ +from hypothesis.strategies import ( + booleans, + composite, + integers, + lists, + sampled_from, + text, +) + +from matching import Player as Resident +from matching.games import HospitalResident +from matching.players import Hospital + + +@composite +def names(draw, taken_from, size): + """ A strategy for getting player names. """ + + names = draw(lists(taken_from, min_size=size, max_size=size, unique=True)) + return names + + +@composite +def connections( + draw, + residents_from=text(), + hospitals_from=text(), + min_residents=1, + max_residents=5, + min_hospitals=1, + max_hospitals=3, +): + """ A custom strategy for making a set of connections between players. """ + + num_residents = draw(integers(min_residents, max_residents)) + num_hospitals = draw(integers(min_hospitals, max_hospitals)) + + resident_names = draw(names(residents_from, num_residents)) + hospital_names = draw(names(hospitals_from, num_hospitals)) + + resident_prefs = {} + hospital_prefs = {h: [] for h in hospital_names} + for resident in resident_names: + hospitals = draw( + lists(sampled_from(hospital_names), min_size=1, unique=True) + ) + resident_prefs[resident] = hospitals + for hospital in hospitals: + hospital_prefs[hospital].append(resident) + + capacities = {} + for hospital, residents in list(hospital_prefs.items()): + if residents: + capacities[hospital] = draw(integers(min_residents, max_residents)) + else: + del hospital_prefs[hospital] + + return resident_prefs, hospital_prefs, capacities + + +@composite +def players(draw, **kwargs): + """ A custom strategy for making a set of residents and hospitals. """ + + resident_prefs, hospital_prefs, capacities = draw(connections(**kwargs)) + + residents = [Resident(name) for name in resident_prefs] + hospitals = [Hospital(name, cap) for name, cap in capacities.items()] + + residents = _get_preferences(residents, hospitals, resident_prefs) + hospitals = _get_preferences(hospitals, residents, hospital_prefs) + + return residents, hospitals + + +def _get_preferences(party, others, preferences): + """ Get and assign preference instances. """ + + for player in party: + names = preferences[player.name] + prefs = [] + for name in names: + for other in others: + if other.name == name: + prefs.append(other) + break + + player.set_prefs(prefs) + + return party + + +@composite +def games(draw, clean=booleans(), **kwargs): + """ A custom strategy for making a game instance. """ + + residents, hospitals = draw(players(**kwargs)) + return HospitalResident(residents, hospitals, clean) diff --git a/tests/players/test_hospital.py b/tests/players/test_hospital.py index 1d52058..011f39b 100644 --- a/tests/players/test_hospital.py +++ b/tests/players/test_hospital.py @@ -18,11 +18,11 @@ def test_init(name, capacity): assert hospital.name == name assert hospital.capacity == capacity - assert hospital._original_capacity == capacity - assert hospital.prefs is None - assert hospital.pref_names is None - assert hospital._original_prefs is None + assert hospital.prefs == [] assert hospital.matching == [] + assert hospital._pref_names == [] + assert hospital._original_prefs is None + assert hospital._original_capacity == capacity @given(name=text(), capacity=capacity, pref_names=pref_names) @@ -48,10 +48,10 @@ def test_match(name, capacity, pref_names): hospital.set_prefs(others) for i, other in enumerate(others[:-1]): - hospital.match(other) + hospital._match(other) assert hospital.matching == others[: i + 1] - hospital.match(others[-1]) + hospital._match(others[-1]) assert hospital.matching == others @@ -64,10 +64,10 @@ def test_unmatch(name, capacity, pref_names): hospital.matching = others for i, other in enumerate(others[:-1]): - hospital.unmatch(other) + hospital._unmatch(other) assert hospital.matching == others[i + 1 :] - hospital.unmatch(others[-1]) + hospital._unmatch(others[-1]) assert hospital.matching == [] @@ -87,7 +87,8 @@ def test_get_worst_match(name, capacity, pref_names): @given(name=text(), capacity=capacity, pref_names=pref_names) def test_get_successors(name, capacity, pref_names): - """Check that a hospital can get the successors to its worst current match.""" + """Check that a hospital can get the successors to its worst current match. + If no such successors exist, check for an empty list.""" hospital = Hospital(name, capacity) others = [Resident(other) for other in pref_names] diff --git a/tests/players/test_player.py b/tests/players/test_player.py index 76817d7..0fa1d4f 100644 --- a/tests/players/test_player.py +++ b/tests/players/test_player.py @@ -6,39 +6,6 @@ from matching import Player -@given(name=text()) -def test_init(name): - """ Make an instance of Player and check their attributes are correct. """ - - player = Player(name) - - assert player.name == name - assert player.prefs is None - assert player._original_prefs is None - assert player.matching is None - - -@given(name=text()) -def test_repr(name): - """ Verify that a Player instance is represented by their name. """ - - player = Player(name) - - assert repr(player) == name - - -@given(name=text(), pref_names=lists(text(), min_size=1)) -def test_set_prefs(name, pref_names): - """ Verify a Player can set its preferences correctly. """ - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - assert player.prefs == others - assert player._original_prefs == others - - @given(name=text(), pref_names=lists(text(), min_size=1)) def test_get_favourite(name, pref_names): """ Check the correct player is returned as the favourite of a player. """ @@ -58,7 +25,7 @@ def test_match(name, pref_names): player = Player(name) other = Player(pref_names[0]) - player.match(other) + player._match(other) assert player.matching == other @@ -70,27 +37,10 @@ def test_unmatch(name, pref_names): other = Player(pref_names[0]) player.matching = other - player.unmatch() + player._unmatch() assert player.matching is None -@given(name=text(), pref_names=lists(text(), min_size=1)) -def test_forget(name, pref_names): - """ Test that a player can forget somebody. """ - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - for i, other in enumerate(others[:-1]): - player.forget(other) - assert player.prefs == others[i + 1 :] - - player.forget(others[-1]) - assert player.prefs == [] - assert player._original_prefs == others - - @given(name=text(), pref_names=lists(text(), min_size=1)) def test_get_successors(name, pref_names): """Test that the correct successors to another player in a player's @@ -108,19 +58,6 @@ def test_get_successors(name, pref_names): assert player.get_successors() == [] -@given(name=text(), pref_names=lists(text(), min_size=1, unique=True)) -def test_prefers(name, pref_names): - """Test that a comparison of preference between two other players can be - found for a player.""" - - player = Player(name) - others = [Player(other) for other in pref_names] - - player.set_prefs(others) - for i, other in enumerate(others[:-1]): - assert player.prefers(other, others[i + 1]) - - @given(name=text(), pref_names=lists(text(), min_size=1, unique=True)) def test_check_if_match_unacceptable(name, pref_names): """ Test that the acceptability of a match is caught correctly. """ @@ -132,7 +69,7 @@ def test_check_if_match_unacceptable(name, pref_names): assert player.check_if_match_is_unacceptable() == message player.set_prefs(others[:-1]) - player.match(others[-1]) + player._match(others[-1]) message = player.not_in_preferences_message(others[-1]) assert player.check_if_match_is_unacceptable() == message diff --git a/tests/players/test_project.py b/tests/players/test_project.py index da058bf..55bed14 100644 --- a/tests/players/test_project.py +++ b/tests/players/test_project.py @@ -16,10 +16,10 @@ def test_init(name, capacity): assert project.name == name assert project.capacity == capacity assert project.supervisor is None - assert project.prefs is None - assert project.pref_names is None - assert project._original_prefs is None + assert project.prefs == [] assert project.matching == [] + assert project._pref_names == [] + assert project._original_prefs is None @given(name=text(), capacity=integers()) @@ -48,11 +48,11 @@ def test_match(name, capacity, pref_names): project.prefs = students supervisor.prefs = students for i, student in enumerate(students[:-1]): - project.match(student) + project._match(student) assert project.matching == students[: i + 1] assert supervisor.matching == students[: i + 1] - project.match(students[-1]) + project._match(students[-1]) assert project.matching == students assert supervisor.matching == students @@ -70,10 +70,10 @@ def test_unmatch(name, capacity, pref_names): project.matching = students supervisor.matching = students for i, student in enumerate(students[:-1]): - project.unmatch(student) + project._unmatch(student) assert project.matching == students[i + 1 :] assert supervisor.matching == students[i + 1 :] - project.unmatch(students[-1]) + project._unmatch(students[-1]) assert project.matching == [] assert supervisor.matching == [] diff --git a/tests/players/test_supervisor.py b/tests/players/test_supervisor.py index 2cdc970..0eddf5f 100644 --- a/tests/players/test_supervisor.py +++ b/tests/players/test_supervisor.py @@ -16,10 +16,10 @@ def test_init(name, capacity): assert supervisor.name == name assert supervisor.capacity == capacity assert supervisor.projects == [] - assert supervisor.prefs is None - assert supervisor.pref_names is None - assert supervisor._original_prefs is None + assert supervisor.prefs == [] assert supervisor.matching == [] + assert supervisor._pref_names == [] + assert supervisor._original_prefs is None @given(name=text(), capacity=integers(), pref_names=lists(text(), min_size=1)) @@ -32,15 +32,16 @@ def test_set_prefs(name, capacity, pref_names): students = [] for sname in pref_names: student = Student(sname) - student.prefs = projects + student.set_prefs(projects) students.append(student) supervisor.projects = projects supervisor.set_prefs(students) assert supervisor.prefs == students - assert supervisor.pref_names == pref_names + assert supervisor._pref_names == pref_names assert supervisor._original_prefs == students + for project in supervisor.projects: assert project.prefs == students - assert project.pref_names == pref_names + assert project._pref_names == pref_names assert project._original_prefs == students diff --git a/tests/stable_marriage/test_algorithm.py b/tests/stable_marriage/test_algorithm.py index 2ca7c5d..12aada0 100644 --- a/tests/stable_marriage/test_algorithm.py +++ b/tests/stable_marriage/test_algorithm.py @@ -1,13 +1,13 @@ """ Integration tests for the Stable Marriage Problem algorithm. """ -from matching.games import stable_marriage +from matching.algorithms import stable_marriage from .params import STABLE_MARRIAGE, make_players @STABLE_MARRIAGE def test_suitor_optimal(player_names, seed): - """Verify that the suitor-oriented Gale-Shapley algorithm produces a valid, + """Verify that the suitor-optimal algorithm produces a valid, suitor-optimal matching for an instance of SM.""" suitors, reviewers = make_players(player_names, seed) @@ -26,8 +26,8 @@ def test_suitor_optimal(player_names, seed): @STABLE_MARRIAGE def test_reviewer_optimal(player_names, seed): - """Verify that the reviewer-oriented Gale-Shapley algorithm produces a - valid, reviewer-optimal matching for an instance of SM.""" + """Verify that the reviewer-optimal algorithm produces a valid, + reviewer-optimal matching for an instance of SM.""" suitors, reviewers = make_players(player_names, seed) matching = stable_marriage(suitors, reviewers, optimal="reviewer") diff --git a/tests/stable_marriage/test_examples.py b/tests/stable_marriage/test_examples.py index b0e0f73..257cf75 100644 --- a/tests/stable_marriage/test_examples.py +++ b/tests/stable_marriage/test_examples.py @@ -1,7 +1,7 @@ """ A collection of example tests. """ from matching import Player -from matching.games import stable_marriage +from matching.algorithms import stable_marriage def test_pride_and_prejudice(): diff --git a/tests/stable_marriage/test_solver.py b/tests/stable_marriage/test_solver.py index 7fee872..c9040e1 100644 --- a/tests/stable_marriage/test_solver.py +++ b/tests/stable_marriage/test_solver.py @@ -1,7 +1,7 @@ """ Unit tests for the SM solver. """ import pytest -from matching import Matching, Player +from matching import Player, SingleMatching from matching.exceptions import MatchingError from matching.games import StableMarriage @@ -20,7 +20,7 @@ def test_init(player_names, seed): suitors + reviewers, game.suitors + game.reviewers ): assert player.name == game_player.name - assert player.pref_names == game_player.pref_names + assert player._pref_names == game_player._pref_names assert all( [player.matching is None for player in game.suitors + game.reviewers] @@ -37,11 +37,11 @@ def test_create_from_dictionaries(player_names, seed): game = StableMarriage.create_from_dictionaries(suitor_prefs, reviewer_prefs) for suitor in game.suitors: - assert suitor_prefs[suitor.name] == suitor.pref_names + assert suitor_prefs[suitor.name] == suitor._pref_names assert suitor.matching is None for reviewer in game.reviewers: - assert reviewer_prefs[reviewer.name] == reviewer.pref_names + assert reviewer_prefs[reviewer.name] == reviewer._pref_names assert reviewer.matching is None assert game.matching is None @@ -82,14 +82,15 @@ def test_inputs_player_ranks(player_names, seed): @STABLE_MARRIAGE def test_solve(player_names, seed): - """Test that StableMarriage can solve games correctly when passed players.""" + """Test that StableMarriage can solve games correctly when passed a set of + players.""" for optimal in ["suitor", "reviewer"]: suitors, reviewers = make_players(player_names, seed) game = StableMarriage(suitors, reviewers) matching = game.solve(optimal) - assert isinstance(matching, Matching) + assert isinstance(matching, SingleMatching) suitors = sorted(suitors, key=lambda s: s.name) reviewers = sorted(reviewers, key=lambda r: r.name) @@ -99,11 +100,11 @@ def test_solve(player_names, seed): for game_suitor, suitor in zip(matching_keys, suitors): assert game_suitor.name == suitor.name - assert game_suitor.pref_names == suitor.pref_names + assert game_suitor._pref_names == suitor._pref_names for game_reviewer, reviewer in zip(matching_values, reviewers): assert game_reviewer.name == reviewer.name - assert game_reviewer.pref_names == reviewer.pref_names + assert game_reviewer._pref_names == reviewer._pref_names @STABLE_MARRIAGE diff --git a/tests/stable_roommates/params.py b/tests/stable_roommates/params.py deleted file mode 100644 index e5927d4..0000000 --- a/tests/stable_roommates/params.py +++ /dev/null @@ -1,44 +0,0 @@ -""" Hypothesis decorators for SR tests. """ - -import numpy as np -from hypothesis import given -from hypothesis.strategies import integers, lists, sampled_from - -from matching import Player - - -def make_players(player_names, seed): - """ Given some names, make a valid set of players. """ - - np.random.seed(seed) - players = [Player(name) for name in player_names] - - for player in players: - player.set_prefs( - np.random.permutation([p for p in players if p != player]).tolist() - ) - - return players - - -def make_prefs(player_names, seed): - """ Given some names, make a valid set of preferences for the players. """ - - np.random.seed(seed) - player_prefs = { - name: np.random.permutation( - [p for p in player_names if p != name] - ).tolist() - for name in player_names - } - - return player_prefs - - -PLAYER_NAMES = lists( - sampled_from(["A", "B", "C", "D"]), min_size=4, max_size=4, unique=True -) - -STABLE_ROOMMATES = given( - player_names=PLAYER_NAMES, seed=integers(min_value=0, max_value=2 ** 32 - 1) -) diff --git a/tests/stable_roommates/test_algorithm.py b/tests/stable_roommates/test_algorithm.py index 4283c9e..1d2231c 100644 --- a/tests/stable_roommates/test_algorithm.py +++ b/tests/stable_roommates/test_algorithm.py @@ -1,73 +1,112 @@ """ Integration and unit tests for the SR algorithm. """ -from matching.games.stable_roommates import ( +import warnings + +from hypothesis import assume, given + +from matching.algorithms.stable_roommates import ( first_phase, + get_pairs_to_delete, locate_all_or_nothing_cycle, second_phase, stable_roommates, ) +from matching.exceptions import NoStableMatchingWarning -from .params import STABLE_ROOMMATES, make_players +from .util import players -@STABLE_ROOMMATES -def test_first_phase(player_names, seed): +@given(players=players()) +def test_first_phase(players): """Verify that the first phase of the algorithm produces a valid set of reduced preference players.""" - players = make_players(player_names, seed) players = first_phase(players) + player_matched = {player: player.matching is not None for player in players} + assert sum(player_matched.values()) >= len(players) - 1 + for player in players: if player.matching is None: assert player.prefs == [] else: assert player.matching in player.prefs - assert {p.name for p in player.prefs}.issubset(player.pref_names) + assert {p.name for p in player.prefs}.issubset(player._pref_names) -@STABLE_ROOMMATES -def test_locate_all_or_nothing_cycle(player_names, seed): +@given(players=players()) +def test_locate_all_or_nothing_cycle(players): """Verify that a cycle of (least-preferred, second-choice) players can be identified from a set of players.""" - players = make_players(player_names, seed) player = players[-1] cycle = locate_all_or_nothing_cycle(player) + assert isinstance(cycle, list) for last, second in cycle: assert second.prefs.index(last) == len(second.prefs) - 1 -@STABLE_ROOMMATES -def test_second_phase(player_names, seed): +@given(players=players()) +def test_get_pairs_to_delete(players): + """Verify that all necessary pairs are identified to remove a cycle from the + game.""" + + assert get_pairs_to_delete([]) == [] + + players = first_phase(players) + assume(any(len(p.prefs) > 1 for p in players)) + + player = next(p for p in players if len(p.prefs) > 1) + cycle = locate_all_or_nothing_cycle(player) + + pairs = get_pairs_to_delete(cycle) + + for pair in cycle: + assert pair in pairs or pair[::-1] in pairs + + for i, (_, right) in enumerate(cycle): + left = cycle[(i - 1) % len(cycle)][0] + others = right.prefs[right.prefs.index(left) + 1 :] + for other in others: + assert (right, other) in pairs or (other, right) in pairs + + +@given(players=players()) +def test_second_phase(players): """Verify that the second phase of the algorithm produces a valid set of players with appropriate matches.""" - players = make_players(player_names, seed) - try: + players = first_phase(players) + assume(any(len(p.prefs) > 1 for p in players)) + + with warnings.catch_warnings(record=True) as w: players = second_phase(players) - for player in players: - if player.prefs: - assert player.prefs == [player.matching] - else: - assert player.matching is None - except (IndexError, ValueError): - pass + for player in players: + if player.prefs: + assert player.prefs[0] == player.matching + else: + message = w[-1].message + assert isinstance(message, NoStableMatchingWarning) + assert str(player.name) in str(message) + assert player.matching is None -@STABLE_ROOMMATES -def test_stable_roommates(player_names, seed): + +@given(players=players()) +def test_stable_roommates(players): """ Verify that the algorithm can terminate with a valid matching. """ - players = make_players(player_names, seed) - matching = stable_roommates(players) + with warnings.catch_warnings(record=True) as w: + matching = stable_roommates(players) - if None in matching.values(): - assert all(val is None for val in matching.values()) + assert isinstance(matching, dict) - else: - for player, other in matching.items(): - assert player.prefs == [other] - assert other.matching == player + for player, match in matching.items(): + if match is None: + message = w[-1].message + assert str(player) in str(message) + assert not player.prefs + else: + assert match == player.prefs[0] diff --git a/tests/stable_roommates/test_examples.py b/tests/stable_roommates/test_examples.py index 57b0ca7..5532a3f 100644 --- a/tests/stable_roommates/test_examples.py +++ b/tests/stable_roommates/test_examples.py @@ -1,10 +1,16 @@ """ A collection of example tests. """ +import warnings + +from hypothesis import given +from hypothesis.strategies import permutations from matching import Player -from matching.games import stable_roommates +from matching.algorithms import stable_roommates +from matching.exceptions import NoStableMatchingWarning +from matching.games.stable_roommates import _make_players -def test_original_paper(): +def test_original_paper_stable(): """Verify that the matching found is consistent with the example in the original paper.""" @@ -22,6 +28,64 @@ def test_original_paper(): assert matching == {a: f, b: c, c: b, d: e, e: d, f: a} +@given(last_player_prefs=permutations([1, 2, 3])) +def test_gale_shapley_no_stable_matching(last_player_prefs): + """Verify that the example from [GS62] throws up a warning that there is no + stable matching.""" + + preferences = { + 1: [2, 3, 4], + 2: [3, 1, 4], + 3: [1, 2, 4], + 4: last_player_prefs, + } + + players = _make_players(preferences) + + with warnings.catch_warnings(record=True) as w: + stable_roommates(players) + + message = w[-1].message + assert isinstance(message, NoStableMatchingWarning) + assert "4" in str(message) + + +def test_large_example_from_book(): + """Verify that the matching found is consistent with the example of size ten + in [GI89] (Section 4.2.3).""" + + preferences = { + 1: [8, 2, 9, 3, 6, 4, 5, 7, 10], + 2: [4, 3, 8, 9, 5, 1, 10, 6, 7], + 3: [5, 6, 8, 2, 1, 7, 10, 4, 9], + 4: [10, 7, 9, 3, 1, 6, 2, 5, 8], + 5: [7, 4, 10, 8, 2, 6, 3, 1, 9], + 6: [2, 8, 7, 3, 4, 10, 1, 5, 9], + 7: [2, 1, 8, 3, 5, 10, 4, 6, 9], + 8: [10, 4, 2, 5, 6, 7, 1, 3, 9], + 9: [6, 7, 2, 5, 10, 3, 4, 8, 1], + 10: [3, 1, 6, 5, 2, 9, 8, 4, 7], + } + + players = _make_players(preferences) + one, two, three, four, five, six, seven, eight, nine, ten = players + + matching = stable_roommates(players) + + assert matching == { + one: seven, + two: eight, + three: six, + four: nine, + five: ten, + six: three, + seven: one, + eight: two, + nine: four, + ten: five, + } + + def test_example_in_issue_64(): """Verify that the matching found is consistent with the example provided in #64.""" @@ -65,7 +129,7 @@ def test_examples_in_issue_124(): assert matching == {a: b, b: a, c: d, d: c} for player in players: - player.unmatch() + player._unmatch() a.set_prefs([b, c, d]) b.set_prefs([a, c, d]) diff --git a/tests/stable_roommates/test_solver.py b/tests/stable_roommates/test_solver.py index 6754402..cd4aed0 100644 --- a/tests/stable_roommates/test_solver.py +++ b/tests/stable_roommates/test_solver.py @@ -1,87 +1,91 @@ """ Unit tests for the SR solver. """ +import warnings + import pytest +from hypothesis import given -from matching import Matching -from matching.exceptions import MatchingError +from matching import Player, SingleMatching +from matching.exceptions import MatchingError, NoStableMatchingWarning from matching.games import StableRoommates -from .params import STABLE_ROOMMATES, make_players, make_prefs +from .util import connections, games, players -@STABLE_ROOMMATES -def test_init(player_names, seed): - """Test that the StableRoommates solver takes a set of preformed players - correctly.""" +@given(players=players()) +def test_init(players): + """Test that the StableRoommates solver has the correct attributes at + instantiation.""" - players = make_players(player_names, seed) game = StableRoommates(players) for player, game_player in zip(players, game.players): assert player.name == game_player.name - assert player.pref_names == game_player.pref_names + assert player._pref_names == game_player._pref_names - assert all([player.matching is None for player in game.players]) assert game.matching is None -@STABLE_ROOMMATES -def test_create_from_dictionary(player_names, seed): +@given(preferences=connections()) +def test_create_from_dictionary(preferences): """Test that StableRoommates solver can take a preference dictionary correctly.""" - player_prefs = make_prefs(player_names, seed) - game = StableRoommates.create_from_dictionary(player_prefs) + game = StableRoommates.create_from_dictionary(preferences) for player in game.players: - assert player_prefs[player.name] == player.pref_names + assert preferences[player.name] == player._pref_names assert player.matching is None assert game.matching is None -@STABLE_ROOMMATES -def test_check_inputs(player_names, seed): +@given(players=players()) +def test_check_inputs(players): """Test StableRoommates raises a ValueError when a player has not ranked all other players.""" - players = make_players(player_names, seed) players[0].prefs = players[0].prefs[:-1] with pytest.raises(Exception): StableRoommates(players) -@STABLE_ROOMMATES -def test_solve(player_names, seed): - """Test that StableRoommates can solve games correctly when passed players.""" +@given(game=games()) +def test_solve(game): + """Test that StableRoommates can solve games correctly.""" - players = make_players(player_names, seed) - game = StableRoommates(players) + with warnings.catch_warnings(record=True) as w: + matching = game.solve() - matching = game.solve() - assert isinstance(matching, Matching) + assert isinstance(matching, SingleMatching) - players = sorted(players, key=lambda p: p.name) + players = sorted(game.players, key=lambda p: p.name) matching_keys = sorted(matching.keys(), key=lambda k: k.name) for game_player, player in zip(matching_keys, players): assert game_player.name == player.name - assert game_player.pref_names == player.pref_names + assert game_player._pref_names == player._pref_names - for match in matching.values(): - assert match is None or match in game.players + for player, match in matching.items(): + if match is None: + message = w[-1].message + assert str(player) in str(message) + assert player.prefs == [] + else: + assert match in game.players -@STABLE_ROOMMATES -def test_check_validity(player_names, seed): +@given(game=games()) +def test_check_validity(game): """Test that StableRoommates can raise a ValueError if any players are left unmatched.""" - players = make_players(player_names, seed) - game = StableRoommates(players) + with warnings.catch_warnings(record=True) as w: + matching = game.solve() - matching = game.solve() if None in matching.values(): + message = w[-1].message + assert isinstance(message, NoStableMatchingWarning) with pytest.raises(MatchingError): game.check_validity() @@ -92,8 +96,6 @@ def test_check_validity(player_names, seed): def test_stability(): """Test that StableRoommates can recognise whether a matching is stable.""" - from matching import Player - players = [Player("A"), Player("B"), Player("C"), Player("D")] a, b, c, d = players diff --git a/tests/stable_roommates/util.py b/tests/stable_roommates/util.py new file mode 100644 index 0000000..929aa3b --- /dev/null +++ b/tests/stable_roommates/util.py @@ -0,0 +1,59 @@ +""" Strategies for SR tests. """ + +from hypothesis.strategies import composite, integers, lists, permutations + +from matching import Player +from matching.games import StableRoommates + + +@composite +def connections(draw, players_from=integers(), min_players=4, max_players=10): + """ A strategy for making a set of connections between players. """ + + num_players = draw(integers(min_players, max_players)) + + players = draw( + lists( + players_from, + min_size=num_players, + max_size=num_players, + unique=True, + ) + ) + + preferences = {} + for player in players: + others = [p for p in players if p != player] + prefs = draw(permutations(others)) + preferences[player] = prefs + + return preferences + + +@composite +def players(draw, **kwargs): + """ A strategy for making a set of players. """ + + preferences = draw(connections(**kwargs)) + + players_ = [Player(name) for name in preferences] + for player in players_: + names = preferences[player.name] + prefs = [] + for name in names: + for other in players_: + if other.name == name: + prefs.append(other) + break + + player.set_prefs(prefs) + + return players_ + + +@composite +def games(draw, **kwargs): + """ A strategy for making an instance of SR. """ + + players_ = draw(players(**kwargs)) + return StableRoommates(players_) diff --git a/tests/student_allocation/test_algorithm.py b/tests/student_allocation/test_algorithm.py index d869457..18f2406 100644 --- a/tests/student_allocation/test_algorithm.py +++ b/tests/student_allocation/test_algorithm.py @@ -1,26 +1,50 @@ """ Tests for the Student Allocation algorithm. """ - import numpy as np -from matching.games import student_allocation +from matching.algorithms.student_allocation import ( + student_allocation, + student_optimal, + supervisor_optimal, +) from .params import STUDENT_ALLOCATION, make_players @STUDENT_ALLOCATION -def test_student_optimal( +def test_student_allocation( student_names, project_names, supervisor_names, capacities, seed, clean ): - """Verify that the student allocation algorithm produces a valid, - student-optimal solution to an instance of SA.""" + """Verify that the student allocation algorithm produces a valid solution to + an instance of SA.""" np.random.seed(seed) students, projects, supervisors = make_players( student_names, project_names, supervisor_names, capacities ) - matching = student_allocation( - students, projects, supervisors, optimal="student" + matching = student_allocation(students, projects, supervisors) + + assert set(projects) == set(matching.keys()) + + matched_students = {s for ss in matching.values() for s in ss} + for student in students: + if student.matching: + assert student in matched_students + else: + assert student not in matched_students + + +@STUDENT_ALLOCATION +def test_student_optimal( + student_names, project_names, supervisor_names, capacities, seed, clean +): + """Verify that the student-optimal algorithm produces a solution that is + indeed student-optimal.""" + + np.random.seed(seed) + students, projects, _ = make_players( + student_names, project_names, supervisor_names, capacities ) + matching = student_optimal(students, projects) assert set(projects) == set(matching.keys()) assert all( @@ -41,16 +65,14 @@ def test_student_optimal( def test_supervisor_optimal( student_names, project_names, supervisor_names, capacities, seed, clean ): - """Verify that the student allocation algorithm produces a valid, - supervisor-optimal solution to an instance of SA.""" + """Verify that the supervisor-optimal algorithm produces a solution that is + indeed supervisor-optimal.""" np.random.seed(seed) students, projects, supervisors = make_players( student_names, project_names, supervisor_names, capacities ) - matching = student_allocation( - students, projects, supervisors, optimal="supervisor" - ) + matching = supervisor_optimal(projects, supervisors) assert set(projects) == set(matching.keys()) assert all( diff --git a/tests/student_allocation/test_examples.py b/tests/student_allocation/test_examples.py index 37cfbd6..ac368bd 100644 --- a/tests/student_allocation/test_examples.py +++ b/tests/student_allocation/test_examples.py @@ -1,6 +1,7 @@ """ A collection of example tests. """ -from matching.games import StudentAllocation, student_allocation +from matching.algorithms import student_allocation +from matching.games import StudentAllocation def test_example_in_docs(): diff --git a/tests/student_allocation/test_solver.py b/tests/student_allocation/test_solver.py index 0200ea9..268572f 100644 --- a/tests/student_allocation/test_solver.py +++ b/tests/student_allocation/test_solver.py @@ -3,7 +3,7 @@ import pytest -from matching import Matching +from matching import MultipleMatching from matching import Player as Student from matching.exceptions import ( CapacityChangedWarning, @@ -29,17 +29,17 @@ def test_init( for student, game_student in zip(students, game.students): assert student.name == game_student.name - assert student.pref_names == game_student.pref_names + assert student._pref_names == game_student._pref_names for project, game_project in zip(projects, game.projects): assert project.name == game_project.name - assert project.pref_names == game_project.pref_names + assert project._pref_names == game_project._pref_names assert project.capacity == game_project.capacity assert project.supervisor.name == game_project.supervisor.name for supervisor, game_supervisor in zip(supervisors, game.supervisors): assert supervisor.name == game_supervisor.name - assert supervisor.pref_names == game_supervisor.pref_names + assert supervisor._pref_names == game_supervisor._pref_names assert supervisor.capacity == game_supervisor.capacity supervisor_projects = [p.name for p in supervisor.projects] @@ -70,7 +70,7 @@ def test_create_from_dictionaries( ) for student in game.students: - assert student.pref_names == stud_prefs[student.name] + assert student._pref_names == stud_prefs[student.name] assert student.matching is None for project in game.projects: @@ -78,7 +78,7 @@ def test_create_from_dictionaries( assert project.matching == [] for supervisor in game.supervisors: - assert supervisor.pref_names == sup_prefs[supervisor.name] + assert supervisor._pref_names == sup_prefs[supervisor.name] assert supervisor.matching == [] assert game.matching is None @@ -153,7 +153,7 @@ def test_check_inputs_project_prefs_all_reciprocated( project = game.projects[0] student = project.prefs[0] - student.forget(project) + student._forget(project) with warnings.catch_warnings(record=True) as w: game._check_inputs_player_prefs_all_reciprocated("projects") @@ -182,7 +182,7 @@ def test_check_inputs_supervisor_prefs_all_reciprocated( projects = supervisor.projects for project in student.prefs: if project in projects: - student.forget(project) + student._forget(project) with warnings.catch_warnings(record=True) as w: game._check_inputs_player_prefs_all_reciprocated("supervisors") @@ -212,7 +212,7 @@ def test_check_inputs_project_reciprocated_all_prefs( project = game.projects[0] student = project.prefs[0] - project.forget(student) + project._forget(student) with warnings.catch_warnings(record=True) as w: game._check_inputs_player_reciprocated_all_prefs("projects", "students") @@ -328,13 +328,13 @@ def test_solve( ) matching = game.solve(optimal) - assert isinstance(matching, Matching) + assert isinstance(matching, MultipleMatching) projects = sorted(projects, key=lambda p: p.name) matching_keys = sorted(matching.keys(), key=lambda k: k.name) for game_project, project in zip(matching_keys, projects): assert game_project.name == project.name - assert game_project.pref_names == project.pref_names + assert game_project._pref_names == project._pref_names assert game_project.capacity == project.capacity assert game_project.supervisor.name == project.supervisor.name diff --git a/tests/unit/test_util.py b/tests/test_algorithms_util.py similarity index 82% rename from tests/unit/test_util.py rename to tests/test_algorithms_util.py index 9ad827d..4792ac4 100644 --- a/tests/unit/test_util.py +++ b/tests/test_algorithms_util.py @@ -1,10 +1,10 @@ -""" Unit tests for the solvers.util functions. """ +""" Unit tests for the `matching.algorithms.util` functions. """ from hypothesis import given from hypothesis.strategies import lists, text from matching import Player -from matching.games.util import delete_pair, match_pair +from matching.algorithms.util import _delete_pair, _match_pair @given(name=text(), pref_names=lists(text(), min_size=1)) @@ -18,7 +18,7 @@ def test_delete_pair(name, pref_names): other.set_prefs([player]) player.set_prefs(others) - delete_pair(player, other) + _delete_pair(player, other) assert player.prefs == [o for o in others if o != other] assert other.prefs == [] @@ -34,6 +34,6 @@ def test_match_pair(name, pref_names): other.set_prefs([player]) player.set_prefs(others) - match_pair(player, other) + _match_pair(player, other) assert player.matching == other assert other.matching == player diff --git a/tests/test_matchings.py b/tests/test_matchings.py new file mode 100644 index 0000000..351468a --- /dev/null +++ b/tests/test_matchings.py @@ -0,0 +1,96 @@ +""" Tests for the matching classes. """ +from hypothesis import given +from hypothesis.strategies import composite, integers, lists, sampled_from, text + +from matching import MultipleMatching, SingleMatching +from matching.players import Hospital, Player + + +@composite +def singles(draw, names_from=text(), min_size=2, max_size=5): + """A custom strategy for generating a matching for `SingleMatching` out of + Player instances.""" + + size = draw(integers(min_value=min_size, max_value=max_size)) + players = [Player(draw(names_from)) for _ in range(size)] + + midpoint = size // 2 + keys, values = players[:midpoint], players[midpoint:] + dictionary = dict(zip(keys, values)) + + return dictionary + + +@composite +def multiples( + draw, + host_names_from=text(), + player_names_from=text(), + min_hosts=2, + max_hosts=5, + min_players=10, + max_players=20, +): + """A custom strategy for generating a matching for `MultipleMatching` out + of `Hospital` and lists of `Player` instances.""" + + num_hosts = draw(integers(min_value=min_hosts, max_value=max_hosts)) + num_players = draw(integers(min_value=min_players, max_value=max_players)) + + hosts = [ + Hospital(draw(host_names_from), max_players) for _ in range(num_hosts) + ] + players = [Player(draw(player_names_from)) for _ in range(num_players)] + + dictionary = {} + for host in hosts: + matches = draw(lists(sampled_from(players), min_size=0, unique=True)) + dictionary[host] = matches + + return dictionary + + +@given(dictionary=singles()) +def test_single_setitem_none(dictionary): + """Test that a player key in a `SingleMatching` instance can have its + value set to `None`.""" + + matching = SingleMatching(dictionary) + key = list(dictionary.keys())[0] + + matching[key] = None + assert matching[key] is None + assert key.matching is None + + +@given(dictionary=singles()) +def test_single_setitem_player(dictionary): + """Test that a player key in a `SingleMatching` instance can have its + value set to another player.""" + + matching = SingleMatching(dictionary) + key = list(dictionary.keys())[0] + val = list(dictionary.values())[-1] + + matching[key] = val + assert matching[key] == val + assert key.matching == val + assert val.matching == key + + +@given(dictionary=multiples()) +def test_multiple_setitem(dictionary): + """Test that a host player key in a `MultipleMatching` instance can have + its value set to a sublist of the matching's values.""" + + matching = MultipleMatching(dictionary) + host = list(dictionary.keys())[0] + players = list( + {player for players in dictionary.values() for player in players} + )[:-1] + + matching[host] = players + assert matching[host] == players + assert host.matching == players + for player in players: + assert player.matching == host diff --git a/tests/unit/test_game.py b/tests/unit/test_game.py deleted file mode 100644 index 8a77929..0000000 --- a/tests/unit/test_game.py +++ /dev/null @@ -1,83 +0,0 @@ -""" Tests for the BaseGame class. """ -import warnings - -import pytest -from hypothesis import given -from hypothesis.strategies import booleans, lists, text - -from matching import BaseGame, Player -from matching.exceptions import PreferencesChangedWarning - - -class DummyGame(BaseGame): - def solve(self): - raise NotImplementedError() - - def check_stability(self): - raise NotImplementedError() - - def check_validity(self): - raise NotImplementedError() - - -def test_init(): - """ Test the default parameters makes a valid instance of BaseGame. """ - - match = DummyGame() - - assert isinstance(match, BaseGame) - assert match.matching is None - assert match.blocking_pairs is None - - -@given( - name=text(), - other_names=lists(text(), min_size=1, unique=True), - clean=booleans(), -) -def test_check_inputs_player_prefs_unique(name, other_names, clean): - """ Test that a game can verify its players have unique preferences. """ - - player = Player(name) - others = [Player(other) for other in other_names] - player.set_prefs(others + others[:1]) - - game = DummyGame(clean) - game.players = [player] - - with warnings.catch_warnings(record=True) as w: - game._check_inputs_player_prefs_unique("players") - - message = w[-1].message - assert isinstance(message, PreferencesChangedWarning) - assert str(message).startswith(name) - assert others[0].name in str(message) - if clean: - assert player.pref_names == other_names - - -def test_no_solve(): - """Verify BaseGame raises a NotImplementedError when calling the `solve` - method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.solve() - - -def test_no_check_stability(): - """Verify BaseGame raises a NotImplementedError when calling the - `check_stability` method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.check_stability() - - -def test_no_check_validity(): - """Verify BaseGame raises a NotImplementError when calling the - `check_validity` method.""" - - with pytest.raises(NotImplementedError): - match = DummyGame() - match.check_validity() diff --git a/tests/unit/test_matching.py b/tests/unit/test_matching.py deleted file mode 100644 index 964722c..0000000 --- a/tests/unit/test_matching.py +++ /dev/null @@ -1,126 +0,0 @@ -""" Unit tests for the Matching class. """ - -import pytest - -from matching import Matching, Player - -suitors = [Player("A"), Player("B"), Player("C")] -reviewers = [Player(1), Player(2), Player(3)] - -suitors[0].set_prefs(reviewers) -suitors[1].set_prefs([reviewers[1], reviewers[0], reviewers[2]]) -suitors[2].set_prefs([reviewers[0], reviewers[2], reviewers[1]]) - -reviewers[0].set_prefs([suitors[1], suitors[0], suitors[2]]) -reviewers[1].set_prefs([suitors[1], suitors[0], suitors[2]]) -reviewers[2].set_prefs(suitors) - -dictionary = dict(zip(suitors, reviewers)) - - -def test_init(): - """Make an instance of the Matching class and check their attributes are - correct.""" - - matching = Matching() - assert matching == {} - - matching = Matching(dictionary) - assert matching == dictionary - - -def test_repr(): - """ Check that a Matching is represented by a normal dictionary. """ - - matching = Matching() - assert repr(matching) == "{}" - - matching = Matching(dictionary) - assert repr(matching) == str(dictionary) - - -def test_keys(): - """ Check a Matching can have its `keys` accessed. """ - - matching = Matching() - assert list(matching.keys()) == [] - - matching = Matching(dictionary) - assert list(matching.keys()) == suitors - - -def test_values(): - """ Check a Matching can have its `values` accessed. """ - - matching = Matching() - assert list(matching.values()) == [] - - matching = Matching(dictionary) - assert list(matching.values()) == reviewers - - -def test_getitem(): - """ Check that you can access items in a Matching correctly. """ - - matching = Matching(dictionary) - for key, val in matching.items(): - assert matching[key] == val - - -def test_setitem_key_error(): - """Check that a ValueError is raised if trying to add a new item to a - Matching.""" - - matching = Matching(dictionary) - - with pytest.raises(ValueError): - matching["foo"] = "bar" - - -def test_setitem_single(): - """Check that a key in Matching can have its value changed to another - Player instance.""" - - matching = Matching(dictionary) - suitor, reviewer = suitors[0], reviewers[-1] - - matching[suitor] = reviewer - assert matching[suitor] == reviewer - assert suitor.matching == reviewer - assert reviewer.matching == suitor - - -def test_setitem_none(): - """ Check can set item in Matching to be None. """ - - matching = Matching(dictionary) - suitor = suitors[0] - - matching[suitor] = None - assert matching[suitor] is None - assert suitor.matching is None - - -def test_setitem_multiple(): - """ Check can set item in Matching to be a group of Player instances. """ - - matching = Matching(dictionary) - suitor = suitors[0] - new_match = reviewers[:-1] - - matching[suitor] = new_match - assert set(matching[suitor]) == set(new_match) - for rev in new_match: - assert rev.matching == suitor - - -def test_setitem_val_error(): - """Check that a ValueError is raised if trying to set an item with some - illegal new matching.""" - - matching = Matching(dictionary) - suitor = suitors[0] - new_match = [1, 2, 3] - - with pytest.raises(ValueError): - matching[suitor] = new_match