Skip to content

Core API

gale_shapley_algorithm

gale-shapley-algorithm: A Python implementation of the Gale-Shapley algorithm.

Modules:

  • algorithm

    Algorithm module.

  • matching

    Convenience function for creating matchings.

  • numeric

    Numpy-backed primitives for large-scale stable-matching work.

  • person

    Person module.

  • result

    Result types for the Gale-Shapley algorithm.

  • stability

    Stability analysis for matchings.

Classes:

  • Algorithm

    Gale-Shapley Algorithm class.

  • MatchingResult

    Result of running the Gale-Shapley algorithm.

  • Person

    Person class, base class for Proposer and Responder.

  • Proposer

    Proposer class, subclass of Person.

  • Responder

    Responder class, subclass of Person.

  • StabilityResult

    Result of a stability check on a matching.

Functions:

Algorithm dataclass

Algorithm(proposers: list[Proposer], responders: list[Responder], round: int = 0)

Gale-Shapley Algorithm class.

Uses slots for memory efficiency.

Methods:

  • execute

    Run the algorithm and return structured results.

  • format_all_preferences

    Format preferences of all proposers and responders as a string.

  • format_matches

    Format all matches as a string.

  • proposers_propose

    Makes all unmatched proposers propose to their next choice.

  • responders_respond

    Makes all responders that are awaiting to respond respond.

  • terminate

    Returns True if all proposers are matched, False otherwise.

Attributes:

awaiting_to_respond_responders property

awaiting_to_respond_responders: list[Responder]

Returns responders that are awaiting to respond.

persons property

persons: list[Proposer | Responder]

Returns all proposers and responders.

unmatched_proposers property

unmatched_proposers: list[Proposer]

Returns unmatched proposers, excludes self matches.

execute

execute() -> MatchingResult

Run the algorithm and return structured results.

Returns:

  • MatchingResult

    MatchingResult with rounds, matches, unmatched, self_matches, all_matched.

Source code in src/gale_shapley_algorithm/algorithm.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def execute(self) -> MatchingResult:
    """Run the algorithm and return structured results.

    Returns:
        MatchingResult with rounds, matches, unmatched, self_matches, all_matched.
    """
    while not self.terminate():
        self.proposers_propose()
        self.responders_respond()
        self.round += 1

    # Change None to self matches for unmatched responders
    for responder in self.responders:
        if not responder.is_matched:
            responder.match = responder

    matches: dict[str, str] = {}
    unmatched: list[str] = []
    self_matches: list[str] = []

    for proposer in self.proposers:
        match proposer.match:
            case None:
                unmatched.append(proposer.name)
            case m if m is proposer:
                self_matches.append(proposer.name)
            case m:
                matches[proposer.name] = m.name

    for responder in self.responders:
        match responder.match:
            case m if m is responder:
                self_matches.append(responder.name)
            case None:
                unmatched.append(responder.name)

    return MatchingResult(
        rounds=self.round,
        matches=matches,
        unmatched=unmatched,
        self_matches=self_matches,
        all_matched=len(unmatched) == 0 and len(self_matches) == 0,
    )

format_all_preferences

format_all_preferences(compact: bool = True) -> str

Format preferences of all proposers and responders as a string.

Parameters:

  • compact

    (bool, default: True ) –

    If True formats all in one table. Defaults to True.

Source code in src/gale_shapley_algorithm/algorithm.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def format_all_preferences(self, compact: bool = True) -> str:
    """Format preferences of all proposers and responders as a string.

    Args:
        compact: If True formats all in one table. Defaults to True.
    """
    lines: list[str] = []
    if compact:
        lines.append("Preferences in compact format, only showing acceptables:")
        header: Final[list[str]] = [p.name for p in self.persons]
        first_column: Final[list[str]] = [
            f"{i}." for i in range(1, max(len(self.proposers), len(self.responders)) + 2)
        ]
        data: list[list[str]] = []
        for i in range(len(first_column)):
            data.append(
                [
                    (
                        person.preferences[i].name
                        if bool(person.preferences)
                        and i < len(person.preferences)
                        and person.is_acceptable(person.preferences[i])
                        else ""
                    )
                    for person in self.persons
                ]
            )
        format_row: Final[str] = "{:8}" * (len(header) + 1)
        lines.append(format_row.format("", *header))
        lines.append(format_row.format("", *["-" * len(h) for h in header]))
        for pref, row in zip(first_column, data, strict=False):
            lines.append(format_row.format(pref, *row))
    else:
        lines.append("Preferences for each person separately:")
        for person in self.persons:
            lines.append(person.format_preferences())
            if person != self.persons[-1]:
                lines.append("")

    return "\n".join(lines)

format_matches

format_matches() -> str

Format all matches as a string.

Source code in src/gale_shapley_algorithm/algorithm.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def format_matches(self) -> str:
    """Format all matches as a string."""
    lines: list[str] = ["Matching:"]
    for proposer in self.proposers:
        match proposer.match:
            case None:
                lines.append(f"{proposer.name} is unmatched.")
            case m if m is proposer:
                lines.append(f"{proposer.name} is matched to self.")
            case m:
                lines.append(f"{proposer.name} is matched to {m.name}.")

    for responder in self.responders:
        match responder.match:
            case m if m is responder:
                lines.append(f"{responder.name} is matched to self.")
            case None:
                lines.append(f"{responder.name} is unmatched.")

    return "\n".join(lines)

proposers_propose

proposers_propose() -> None

Makes all unmatched proposers propose to their next choice.

Source code in src/gale_shapley_algorithm/algorithm.py
36
37
38
39
def proposers_propose(self) -> None:
    """Makes all unmatched proposers propose to their next choice."""
    for proposer in self.unmatched_proposers:
        proposer.propose()

responders_respond

responders_respond() -> None

Makes all responders that are awaiting to respond respond.

Source code in src/gale_shapley_algorithm/algorithm.py
41
42
43
44
def responders_respond(self) -> None:
    """Makes all responders that are awaiting to respond respond."""
    for responder in self.awaiting_to_respond_responders:
        responder.respond()

terminate

terminate() -> bool

Returns True if all proposers are matched, False otherwise.

Source code in src/gale_shapley_algorithm/algorithm.py
46
47
48
def terminate(self) -> bool:
    """Returns True if all proposers are matched, False otherwise."""
    return all(proposer.is_matched for proposer in self.proposers)

MatchingResult dataclass

MatchingResult(rounds: int, matches: dict[str, str], unmatched: list[str], self_matches: list[str], all_matched: bool)

Result of running the Gale-Shapley algorithm.

Person

Person(name: str, side: str)

Person class, base class for Proposer and Responder.

Methods:

  • format_preferences

    Format the preferences of the person as a string, * indicates acceptable.

  • is_acceptable

    Check if person is acceptable (ranked at or above self in preferences).

Attributes:

  • is_matched (bool) –

    Returns True if the person is matched to someone or self, False if match is None.

Source code in src/gale_shapley_algorithm/person.py
13
14
15
16
17
def __init__(self, name: str, side: str) -> None:
    self.name = name
    self.side = side
    self.preferences: tuple[Proposer | Responder, ...] = ()
    self.match: Proposer | Responder | None = None

is_matched property

is_matched: bool

Returns True if the person is matched to someone or self, False if match is None.

format_preferences

format_preferences() -> str

Format the preferences of the person as a string, * indicates acceptable.

Source code in src/gale_shapley_algorithm/person.py
42
43
44
45
46
47
48
49
50
def format_preferences(self) -> str:
    """Format the preferences of the person as a string, * indicates acceptable."""
    lines = [f"{self.name} has the following preferences, * indicates acceptable:"]
    offset_one: int = len(str(len(self.preferences)))
    offset_two: int = max(len(person.name) for person in self.preferences)
    for i, person in enumerate(self.preferences, start=1):
        acceptable = "*" if self.is_acceptable(person) else ""
        lines.append(f"{i}.{'':{offset_one - len(str(i)) + 1}}{person.name:<{offset_two + 1}}{acceptable}")
    return "\n".join(lines)

is_acceptable

is_acceptable(person: Proposer | Responder) -> bool

Check if person is acceptable (ranked at or above self in preferences).

Parameters:

Raises:

Returns:

  • bool

    True if person is acceptable, False otherwise.

Source code in src/gale_shapley_algorithm/person.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def is_acceptable(self, person: Proposer | Responder) -> bool:
    """Check if person is acceptable (ranked at or above self in preferences).

    Args:
        person: The person to check acceptability for.

    Raises:
        ValueError: If person is not in preferences.

    Returns:
        True if person is acceptable, False otherwise.
    """
    if person in self.preferences and self in self.preferences:
        return self.preferences.index(person) <= self.preferences.index(self)
    raise ValueError(f"Either {self} or {person} is not in preferences.")

Proposer

Proposer(name: str, side: str)

Bases: Person

Proposer class, subclass of Person.

Methods:

  • format_preferences

    Format the preferences of the person as a string, * indicates acceptable.

  • is_acceptable

    Check if person is acceptable (ranked at or above self in preferences).

  • propose

    Propose to the next acceptable responder. If self is next, set match to self.

Attributes:

Source code in src/gale_shapley_algorithm/person.py
61
62
63
def __init__(self, name: str, side: str) -> None:
    super().__init__(name, side)
    self.last_proposal: Responder | Proposer | None = None

acceptable_to_propose property

acceptable_to_propose: tuple[Responder | Proposer, ...]

Returns a tuple of acceptable responders to propose to.

is_matched property

is_matched: bool

Returns True if the person is matched to someone or self, False if match is None.

next_proposal property

next_proposal: Responder | Proposer

Returns the next acceptable responder to propose to, or self if exhausted.

format_preferences

format_preferences() -> str

Format the preferences of the person as a string, * indicates acceptable.

Source code in src/gale_shapley_algorithm/person.py
42
43
44
45
46
47
48
49
50
def format_preferences(self) -> str:
    """Format the preferences of the person as a string, * indicates acceptable."""
    lines = [f"{self.name} has the following preferences, * indicates acceptable:"]
    offset_one: int = len(str(len(self.preferences)))
    offset_two: int = max(len(person.name) for person in self.preferences)
    for i, person in enumerate(self.preferences, start=1):
        acceptable = "*" if self.is_acceptable(person) else ""
        lines.append(f"{i}.{'':{offset_one - len(str(i)) + 1}}{person.name:<{offset_two + 1}}{acceptable}")
    return "\n".join(lines)

is_acceptable

is_acceptable(person: Proposer | Responder) -> bool

Check if person is acceptable (ranked at or above self in preferences).

Parameters:

Raises:

Returns:

  • bool

    True if person is acceptable, False otherwise.

Source code in src/gale_shapley_algorithm/person.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def is_acceptable(self, person: Proposer | Responder) -> bool:
    """Check if person is acceptable (ranked at or above self in preferences).

    Args:
        person: The person to check acceptability for.

    Raises:
        ValueError: If person is not in preferences.

    Returns:
        True if person is acceptable, False otherwise.
    """
    if person in self.preferences and self in self.preferences:
        return self.preferences.index(person) <= self.preferences.index(self)
    raise ValueError(f"Either {self} or {person} is not in preferences.")

propose

propose() -> None

Propose to the next acceptable responder. If self is next, set match to self.

Source code in src/gale_shapley_algorithm/person.py
82
83
84
85
86
87
88
89
def propose(self) -> None:
    """Propose to the next acceptable responder. If self is next, set match to self."""
    match self.next_proposal:
        case Proposer():  # meaning self is next
            self.match = self
        case responder:
            responder.current_proposals.append(self)
    self.last_proposal = self.next_proposal

Responder

Responder(name: str, side: str)

Bases: Person

Responder class, subclass of Person.

Methods:

  • format_preferences

    Format the preferences of the person as a string, * indicates acceptable.

  • is_acceptable

    Check if person is acceptable (ranked at or above self in preferences).

  • respond

    Respond to proposals and clear the current_proposals.

Attributes:

Source code in src/gale_shapley_algorithm/person.py
95
96
97
def __init__(self, name: str, side: str) -> None:
    super().__init__(name, side)
    self.current_proposals: list[Proposer] = []

acceptable_proposals property

acceptable_proposals: list[Proposer]

Returns a list of acceptable proposals among the current proposals.

awaiting_to_respond property

awaiting_to_respond: bool

Returns True if current_proposals is not empty.

is_matched property

is_matched: bool

Returns True if the person is matched to someone or self, False if match is None.

format_preferences

format_preferences() -> str

Format the preferences of the person as a string, * indicates acceptable.

Source code in src/gale_shapley_algorithm/person.py
42
43
44
45
46
47
48
49
50
def format_preferences(self) -> str:
    """Format the preferences of the person as a string, * indicates acceptable."""
    lines = [f"{self.name} has the following preferences, * indicates acceptable:"]
    offset_one: int = len(str(len(self.preferences)))
    offset_two: int = max(len(person.name) for person in self.preferences)
    for i, person in enumerate(self.preferences, start=1):
        acceptable = "*" if self.is_acceptable(person) else ""
        lines.append(f"{i}.{'':{offset_one - len(str(i)) + 1}}{person.name:<{offset_two + 1}}{acceptable}")
    return "\n".join(lines)

is_acceptable

is_acceptable(person: Proposer | Responder) -> bool

Check if person is acceptable (ranked at or above self in preferences).

Parameters:

Raises:

Returns:

  • bool

    True if person is acceptable, False otherwise.

Source code in src/gale_shapley_algorithm/person.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def is_acceptable(self, person: Proposer | Responder) -> bool:
    """Check if person is acceptable (ranked at or above self in preferences).

    Args:
        person: The person to check acceptability for.

    Raises:
        ValueError: If person is not in preferences.

    Returns:
        True if person is acceptable, False otherwise.
    """
    if person in self.preferences and self in self.preferences:
        return self.preferences.index(person) <= self.preferences.index(self)
    raise ValueError(f"Either {self} or {person} is not in preferences.")

respond

respond() -> None

Respond to proposals and clear the current_proposals.

Source code in src/gale_shapley_algorithm/person.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def respond(self) -> None:
    """Respond to proposals and clear the current_proposals."""
    if bool(self.acceptable_proposals):
        match self.match:
            case Proposer() as current_match:
                new_match = self._most_preferred(self.acceptable_proposals + [current_match])
                if new_match != current_match:
                    current_match.match = None
                    self.match = new_match
                    new_match.match = self
            case _:
                new_match = self._most_preferred(self.acceptable_proposals)
                self.match = new_match
                new_match.match = self
    self.current_proposals = []

StabilityResult dataclass

StabilityResult(is_stable: bool, is_individually_rational: bool, blocking_pairs: list[tuple[str, str]])

Result of a stability check on a matching.

check_stability

check_stability(algorithm: Algorithm) -> StabilityResult

Check the stability of an algorithm's matching.

Parameters:

  • algorithm

    (Algorithm) –

    An Algorithm instance that has been executed.

Returns:

  • StabilityResult

    StabilityResult with is_stable, is_individually_rational, and blocking_pairs.

Source code in src/gale_shapley_algorithm/stability.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def check_stability(algorithm: Algorithm) -> StabilityResult:
    """Check the stability of an algorithm's matching.

    Args:
        algorithm: An Algorithm instance that has been executed.

    Returns:
        StabilityResult with is_stable, is_individually_rational, and blocking_pairs.
    """
    ir = is_individually_rational(algorithm.proposers, algorithm.responders)
    bp = find_blocking_pairs(algorithm.proposers, algorithm.responders)
    return StabilityResult(
        is_stable=ir and len(bp) == 0,
        is_individually_rational=ir,
        blocking_pairs=bp,
    )

create_matching

Create a matching from preference dictionaries.

This is the simplest way to use the library. Provide preference rankings for each side and get back a MatchingResult.

Persons not listed in a preference list are considered unacceptable. Each person is automatically added to their own preference list at the end (making everyone they listed acceptable).

Parameters:

  • proposer_preferences

    (dict[str, list[str]]) –

    Mapping of proposer names to ordered list of responder names.

  • responder_preferences

    (dict[str, list[str]]) –

    Mapping of responder names to ordered list of proposer names.

Returns:

Example

result = create_matching( ... proposer_preferences={ ... "alice": ["bob", "charlie"], ... "dave": ["charlie", "bob"], ... }, ... responder_preferences={ ... "bob": ["alice", "dave"], ... "charlie": ["dave", "alice"], ... }, ... ) result.matches

Source code in src/gale_shapley_algorithm/matching.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def create_matching(
    proposer_preferences: dict[str, list[str]],
    responder_preferences: dict[str, list[str]],
) -> MatchingResult:
    """Create a matching from preference dictionaries.

    This is the simplest way to use the library. Provide preference rankings
    for each side and get back a MatchingResult.

    Persons not listed in a preference list are considered unacceptable.
    Each person is automatically added to their own preference list at the end
    (making everyone they listed acceptable).

    Args:
        proposer_preferences: Mapping of proposer names to ordered list of responder names.
        responder_preferences: Mapping of responder names to ordered list of proposer names.

    Returns:
        MatchingResult with the matching outcome.

    Example:
        >>> result = create_matching(
        ...     proposer_preferences={
        ...         "alice": ["bob", "charlie"],
        ...         "dave": ["charlie", "bob"],
        ...     },
        ...     responder_preferences={
        ...         "bob": ["alice", "dave"],
        ...         "charlie": ["dave", "alice"],
        ...     },
        ... )
        >>> result.matches
        {'alice': 'bob', 'dave': 'charlie'}
    """
    algorithm = _build_algorithm(proposer_preferences, responder_preferences)
    return algorithm.execute()

find_blocking_pairs

find_blocking_pairs(proposers: list[Proposer], responders: list[Responder]) -> list[tuple[str, str]]

Find all blocking pairs in a matching.

A blocking pair (p, r) exists when proposer p and responder r both prefer each other over their current matches.

Parameters:

Returns:

  • list[tuple[str, str]]

    List of (proposer_name, responder_name) blocking pairs.

Source code in src/gale_shapley_algorithm/stability.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def find_blocking_pairs(proposers: list[Proposer], responders: list[Responder]) -> list[tuple[str, str]]:  # noqa: ARG001
    """Find all blocking pairs in a matching.

    A blocking pair (p, r) exists when proposer p and responder r both prefer
    each other over their current matches.

    Args:
        proposers: List of proposers in the matching.
        responders: List of responders in the matching.

    Returns:
        List of (proposer_name, responder_name) blocking pairs.
    """
    blocking: list[tuple[str, str]] = []
    for proposer in proposers:
        if not (bool(proposer.preferences) and proposer.is_matched):
            continue

        better_than_match = proposer.preferences[: proposer.preferences.index(proposer.match)]
        for responder in better_than_match:
            if not isinstance(responder, Responder):
                continue

            match responder.is_matched:
                case False:
                    blocking.append((proposer.name, responder.name))
                case True if (
                    bool(responder.preferences)
                    and all(item in responder.preferences for item in [proposer, responder.match])
                    and responder.preferences.index(proposer) < responder.preferences.index(responder.match)
                ):
                    blocking.append((proposer.name, responder.name))

    return blocking

is_individually_rational

is_individually_rational(proposers: list[Proposer], responders: list[Responder]) -> bool

Check if the matching is individually rational.

A matching is individually rational if every matched person finds their match acceptable (ranked at or above themselves).

Parameters:

Returns:

  • bool

    True if individually rational, False otherwise.

Source code in src/gale_shapley_algorithm/stability.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def is_individually_rational(proposers: list[Proposer], responders: list[Responder]) -> bool:
    """Check if the matching is individually rational.

    A matching is individually rational if every matched person finds their
    match acceptable (ranked at or above themselves).

    Args:
        proposers: List of proposers in the matching.
        responders: List of responders in the matching.

    Returns:
        True if individually rational, False otherwise.
    """
    persons = proposers + responders
    return all(person.match is None or person.is_acceptable(person.match) for person in persons)

numeric subpackage

Numpy-backed primitives for large-scale / numerical work. Install with the numeric extra:

pip install "gale-shapley-algorithm[numeric]"

Gale-Shapley on rank matrices

gs

Numpy Gale-Shapley deferred acceptance on rank matrices.

Type Aliases:

  • Selector

    Picks which free proposer goes next.

Classes:

  • GSStats

    Result of a traced Gale-Shapley run.

Functions:

  • fifo_selector

    Return the first index — pops in arrival order (FIFO).

  • gale_shapley

    Run proposer-optimal deferred acceptance.

  • gale_shapley_traced

    Run proposer-optimal deferred acceptance and return per-proposer statistics.

  • lifo_selector

    Return the last index — equivalent to list.pop() (LIFO).

  • men_optimal_gs

    Return the men-optimal stable matching (conventionally men propose, women dispose).

  • men_optimal_traced

    Men-optimal stable matching with per-man proposal stats.

  • random_selector

    Build a uniform-random selector backed by a numpy Generator.

  • women_optimal_gs

    Return the women-optimal stable matching, still in men-indexed form match[m] = w.

  • women_optimal_traced

    Women-optimal stable matching with per-woman proposal stats.

Selector

Selector = Callable[[Sequence[int]], int]

Picks which free proposer goes next.

Receives the current free list of proposer ids and returns an index into that list (0..len(free)-1; negative indices supported per list.pop semantics). The default is :func:lifo_selector, which matches the historical behavior of :func:gale_shapley (free.pop()).

GSStats dataclass

GSStats(match: NDArray[int16], proposals: int, proposals_per_proposer: NDArray[int16])

Result of a traced Gale-Shapley run.

eq=False because :attr:match and :attr:proposals_per_proposer are numpy arrays whose __eq__ returns an array, not a bool — so the dataclass-generated __eq__ would raise. Compare fields directly.

Attributes:

  • match (NDArray[int16]) –

    shape (n,), dtype int16. Proposer-indexed: match[p] is the responder paired with proposer p. The wrapper :func:women_optimal_traced re-indexes this to be men-indexed; see its docstring.

  • proposals (int) –

    total number of proposal events. Equals proposals_per_proposer.sum().

  • proposals_per_proposer (NDArray[int16]) –

    shape (n,), dtype int16. proposals_per_proposer[p] is the number of proposals made by proposer p, which also equals the rank (1-indexed) of p's final match in p's preference list.

fifo_selector

fifo_selector(free: Sequence[int]) -> int

Return the first index — pops in arrival order (FIFO).

Source code in src/gale_shapley_algorithm/numeric/gs.py
37
38
39
40
def fifo_selector(free: Sequence[int]) -> int:
    """Return the first index — pops in arrival order (FIFO)."""
    del free
    return 0

gale_shapley

gale_shapley(proposer_rank: NDArray[integer], responder_rank: NDArray[integer]) -> NDArray[int16]

Run proposer-optimal deferred acceptance.

Parameters:

  • proposer_rank

    (NDArray[integer]) –

    (n, n) array where proposer_rank[i, j] is the 1-indexed position of responder j in proposer i's preference list. Each row must be a permutation of 1..n.

  • responder_rank

    (NDArray[integer]) –

    (n, n) array with the symmetric convention.

Returns:

  • NDArray[int16]

    match of shape (n,), dtype int16, where match[i] is

  • NDArray[int16]

    the responder paired with proposer i.

Raises:

  • ValueError

    if the two arrays don't have the same square shape, or any row is not a permutation of 1..n.

Source code in src/gale_shapley_algorithm/numeric/gs.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def gale_shapley(proposer_rank: NDArray[np.integer], responder_rank: NDArray[np.integer]) -> NDArray[np.int16]:
    """Run proposer-optimal deferred acceptance.

    Args:
        proposer_rank: ``(n, n)`` array where ``proposer_rank[i, j]`` is
            the 1-indexed position of responder ``j`` in proposer ``i``'s
            preference list. Each row must be a permutation of ``1..n``.
        responder_rank: ``(n, n)`` array with the symmetric convention.

    Returns:
        ``match`` of shape ``(n,)``, dtype ``int16``, where ``match[i]`` is
        the responder paired with proposer ``i``.

    Raises:
        ValueError: if the two arrays don't have the same square shape, or
            any row is not a permutation of ``1..n``.
    """
    return gale_shapley_traced(proposer_rank, responder_rank).match

gale_shapley_traced

gale_shapley_traced(proposer_rank: NDArray[integer], responder_rank: NDArray[integer], *, selector: Selector = lifo_selector) -> GSStats

Run proposer-optimal deferred acceptance and return per-proposer statistics.

Mechanically identical to :func:gale_shapley (sequential McVitie-Wilson on the free-proposer pool), but exposes the proposal count and a pluggable proposer-selection rule. The final matching is invariant under selector choice (Knuth); the proposal count is not.

Parameters:

  • proposer_rank

    (NDArray[integer]) –

    (n, n) array where proposer_rank[i, j] is the 1-indexed position of responder j in proposer i's preference list. Each row must be a permutation of 1..n.

  • responder_rank

    (NDArray[integer]) –

    (n, n) array with the symmetric convention.

  • selector

    (Selector, default: lifo_selector ) –

    picks which free proposer acts next; defaults to :func:lifo_selector. See :data:Selector.

Returns:

  • GSStats

    class:GSStats with the matching, total proposal count, and

  • GSStats

    per-proposer counts.

Raises:

  • ValueError

    if the two arrays don't have the same square shape, or any row is not a permutation of 1..n.

  • IndexError

    if selector returns an index outside the free list.

Source code in src/gale_shapley_algorithm/numeric/gs.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def gale_shapley_traced(
    proposer_rank: NDArray[np.integer],
    responder_rank: NDArray[np.integer],
    *,
    selector: Selector = lifo_selector,
) -> GSStats:
    """Run proposer-optimal deferred acceptance and return per-proposer statistics.

    Mechanically identical to :func:`gale_shapley` (sequential McVitie-Wilson
    on the free-proposer pool), but exposes the proposal count and a
    pluggable proposer-selection rule. The final matching is invariant under
    selector choice (Knuth); the proposal count is not.

    Args:
        proposer_rank: ``(n, n)`` array where ``proposer_rank[i, j]`` is
            the 1-indexed position of responder ``j`` in proposer ``i``'s
            preference list. Each row must be a permutation of ``1..n``.
        responder_rank: ``(n, n)`` array with the symmetric convention.
        selector: picks which free proposer acts next; defaults to
            :func:`lifo_selector`. See :data:`Selector`.

    Returns:
        :class:`GSStats` with the matching, total proposal count, and
        per-proposer counts.

    Raises:
        ValueError: if the two arrays don't have the same square shape, or
            any row is not a permutation of ``1..n``.
        IndexError: if ``selector`` returns an index outside the free list.
    """
    _validate_rank_matrices(proposer_rank, responder_rank)
    n = proposer_rank.shape[0]
    next_proposal = np.zeros(n, dtype=np.int16)
    responder_match = np.full(n, -1, dtype=np.int16)
    proposer_pref = np.argsort(proposer_rank, axis=1).astype(np.int16)
    free: list[int] = list(range(n))
    while free:
        p = free.pop(selector(free))
        r = int(proposer_pref[p, next_proposal[p]])
        next_proposal[p] += 1
        current = int(responder_match[r])
        if current == -1:
            responder_match[r] = p
        elif responder_rank[r, p] < responder_rank[r, current]:
            responder_match[r] = p
            free.append(current)
        else:
            free.append(p)
    return GSStats(
        match=np.argsort(responder_match).astype(np.int16),
        proposals=int(next_proposal.sum()),
        proposals_per_proposer=next_proposal.copy(),
    )

lifo_selector

lifo_selector(free: Sequence[int]) -> int

Return the last index — equivalent to list.pop() (LIFO).

Source code in src/gale_shapley_algorithm/numeric/gs.py
31
32
33
34
def lifo_selector(free: Sequence[int]) -> int:
    """Return the last index — equivalent to ``list.pop()`` (LIFO)."""
    del free
    return -1

men_optimal_gs

men_optimal_gs(men_rank: NDArray[integer], women_rank: NDArray[integer]) -> NDArray[int16]

Return the men-optimal stable matching (conventionally men propose, women dispose).

Source code in src/gale_shapley_algorithm/numeric/gs.py
197
198
199
def men_optimal_gs(men_rank: NDArray[np.integer], women_rank: NDArray[np.integer]) -> NDArray[np.int16]:
    """Return the men-optimal stable matching (conventionally men propose, women dispose)."""
    return gale_shapley(men_rank, women_rank)

men_optimal_traced

men_optimal_traced(men_rank: NDArray[integer], women_rank: NDArray[integer], *, selector: Selector = lifo_selector) -> GSStats

Men-optimal stable matching with per-man proposal stats.

Equivalent to gale_shapley_traced(men_rank, women_rank, selector=...); provided for symmetry with :func:men_optimal_gs.

Parameters:

  • men_rank

    (NDArray[integer]) –

    (n, n) rank matrix for men (proposers).

  • women_rank

    (NDArray[integer]) –

    (n, n) rank matrix for women (responders).

  • selector

    (Selector, default: lifo_selector ) –

    see :data:Selector.

Returns:

  • GSStats

    class:GSStats where match and proposals_per_proposer are

  • GSStats

    both men-indexed.

Source code in src/gale_shapley_algorithm/numeric/gs.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def men_optimal_traced(
    men_rank: NDArray[np.integer],
    women_rank: NDArray[np.integer],
    *,
    selector: Selector = lifo_selector,
) -> GSStats:
    """Men-optimal stable matching with per-man proposal stats.

    Equivalent to ``gale_shapley_traced(men_rank, women_rank, selector=...)``;
    provided for symmetry with :func:`men_optimal_gs`.

    Args:
        men_rank: ``(n, n)`` rank matrix for men (proposers).
        women_rank: ``(n, n)`` rank matrix for women (responders).
        selector: see :data:`Selector`.

    Returns:
        :class:`GSStats` where ``match`` and ``proposals_per_proposer`` are
        both men-indexed.
    """
    return gale_shapley_traced(men_rank, women_rank, selector=selector)

random_selector

random_selector(rng: Generator) -> Selector

Build a uniform-random selector backed by a numpy Generator.

Roth-Vande Vate-style: pick a free proposer uniformly at random each step. Final matching is invariant to selector choice (Knuth's order independence) but the proposal count is not — random order is a useful baseline against LIFO/FIFO for amortized analysis.

Parameters:

  • rng

    (Generator) –

    a numpy Generator (use np.random.default_rng(seed) for determinism).

Returns:

  • A ( Selector ) –

    data:Selector closure that draws rng.integers(len(free)) per call.

Source code in src/gale_shapley_algorithm/numeric/gs.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def random_selector(rng: np.random.Generator) -> Selector:
    """Build a uniform-random selector backed by a numpy ``Generator``.

    Roth-Vande Vate-style: pick a free proposer uniformly at random each step.
    Final matching is invariant to selector choice (Knuth's order independence)
    but the proposal count is not — random order is a useful baseline against
    LIFO/FIFO for amortized analysis.

    Args:
        rng: a numpy ``Generator`` (use ``np.random.default_rng(seed)`` for
            determinism).

    Returns:
        A :data:`Selector` closure that draws ``rng.integers(len(free))`` per call.
    """

    def _selector(free: Sequence[int]) -> int:
        return int(rng.integers(len(free)))

    return _selector

women_optimal_gs

women_optimal_gs(men_rank: NDArray[integer], women_rank: NDArray[integer]) -> NDArray[int16]

Return the women-optimal stable matching, still in men-indexed form match[m] = w.

Source code in src/gale_shapley_algorithm/numeric/gs.py
202
203
204
def women_optimal_gs(men_rank: NDArray[np.integer], women_rank: NDArray[np.integer]) -> NDArray[np.int16]:
    """Return the women-optimal stable matching, still in men-indexed form ``match[m] = w``."""
    return np.argsort(gale_shapley(women_rank, men_rank)).astype(np.int16)

women_optimal_traced

women_optimal_traced(men_rank: NDArray[integer], women_rank: NDArray[integer], *, selector: Selector = lifo_selector) -> GSStats

Women-optimal stable matching with per-woman proposal stats.

Runs :func:gale_shapley_traced with women as the proposing side. The returned match is re-indexed to men-indexed form for consistency with :func:women_optimal_gs. proposals_per_proposer remains women-indexed, since women are the proposers in this run.

Parameters:

  • men_rank

    (NDArray[integer]) –

    (n, n) rank matrix for men.

  • women_rank

    (NDArray[integer]) –

    (n, n) rank matrix for women.

  • selector

    (Selector, default: lifo_selector ) –

    see :data:Selector.

Returns:

  • GSStats

    class:GSStats with match[m] = w (men-indexed) and

  • GSStats

    proposals_per_proposer[w] = number of proposals woman w made

  • GSStats

    (women-indexed). proposals is the unambiguous total.

Source code in src/gale_shapley_algorithm/numeric/gs.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def women_optimal_traced(
    men_rank: NDArray[np.integer],
    women_rank: NDArray[np.integer],
    *,
    selector: Selector = lifo_selector,
) -> GSStats:
    """Women-optimal stable matching with per-woman proposal stats.

    Runs :func:`gale_shapley_traced` with women as the proposing side. The
    returned ``match`` is re-indexed to men-indexed form for consistency with
    :func:`women_optimal_gs`. ``proposals_per_proposer`` remains
    **women-indexed**, since women are the proposers in this run.

    Args:
        men_rank: ``(n, n)`` rank matrix for men.
        women_rank: ``(n, n)`` rank matrix for women.
        selector: see :data:`Selector`.

    Returns:
        :class:`GSStats` with ``match[m] = w`` (men-indexed) and
        ``proposals_per_proposer[w] = number of proposals woman w made``
        (women-indexed). ``proposals`` is the unambiguous total.
    """
    stats = gale_shapley_traced(women_rank, men_rank, selector=selector)
    return GSStats(
        match=np.argsort(stats.match).astype(np.int16),
        proposals=stats.proposals,
        proposals_per_proposer=stats.proposals_per_proposer,
    )

Stability checks

stability

Vectorized stability checks for rank-matrix matchings.

Functions:

  • find_blocking_pairs

    Return every (proposer, responder) pair that blocks match.

  • is_stable

    Return True iff match is a perfect matching with no blocking pair.

  • is_stable_batch

    Vectorized stability check across a batch of matchings.

find_blocking_pairs

find_blocking_pairs(proposer_rank: NDArray[integer], responder_rank: NDArray[integer], match: NDArray[integer]) -> list[tuple[int, int]]

Return every (proposer, responder) pair that blocks match.

A blocking pair (p, r) is one where p strictly prefers r to his current partner and r strictly prefers p to her current partner.

Parameters:

  • proposer_rank

    (NDArray[integer]) –

    (n, n) rank matrix for the proposing side.

  • responder_rank

    (NDArray[integer]) –

    (n, n) rank matrix for the responding side.

  • match

    (NDArray[integer]) –

    (n,) men-indexed matching.

Returns:

  • list[tuple[int, int]]

    List of 0-indexed (proposer, responder) blocking pairs.

Source code in src/gale_shapley_algorithm/numeric/stability.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def find_blocking_pairs(
    proposer_rank: NDArray[np.integer],
    responder_rank: NDArray[np.integer],
    match: NDArray[np.integer],
) -> list[tuple[int, int]]:
    """Return every ``(proposer, responder)`` pair that blocks ``match``.

    A blocking pair ``(p, r)`` is one where ``p`` strictly prefers ``r`` to
    his current partner and ``r`` strictly prefers ``p`` to her current
    partner.

    Args:
        proposer_rank: ``(n, n)`` rank matrix for the proposing side.
        responder_rank: ``(n, n)`` rank matrix for the responding side.
        match: ``(n,)`` men-indexed matching.

    Returns:
        List of 0-indexed ``(proposer, responder)`` blocking pairs.
    """
    n = proposer_rank.shape[0]
    partner_of_responder = np.argsort(match).astype(np.int16)
    blocking: list[tuple[int, int]] = []
    for p in range(n):
        current_r = int(match[p])
        for r in range(n):
            if r == current_r:
                continue
            if (
                proposer_rank[p, r] < proposer_rank[p, current_r]
                and responder_rank[r, p] < responder_rank[r, partner_of_responder[r]]
            ):
                blocking.append((p, r))
    return blocking

is_stable

is_stable(proposer_rank: NDArray[integer], responder_rank: NDArray[integer], match: NDArray[integer]) -> bool

Return True iff match is a perfect matching with no blocking pair.

Source code in src/gale_shapley_algorithm/numeric/stability.py
53
54
55
56
57
58
59
60
61
62
63
64
def is_stable(
    proposer_rank: NDArray[np.integer],
    responder_rank: NDArray[np.integer],
    match: NDArray[np.integer],
) -> bool:
    """Return ``True`` iff ``match`` is a perfect matching with no blocking pair."""
    n = proposer_rank.shape[0]
    if match.shape[0] != n:
        return False
    if np.unique(match).shape[0] != n:
        return False
    return len(find_blocking_pairs(proposer_rank, responder_rank, match)) == 0

is_stable_batch

is_stable_batch(proposer_rank: NDArray[integer], responder_rank: NDArray[integer], matchings: NDArray[integer]) -> NDArray[bool_]

Vectorized stability check across a batch of matchings.

Memory use is O(K * n**2) where K is the batch size. For K = n! (full permutation enumeration) the tensor grows as O(n**2 * n!), ~12 MB at n=8 and ~360 MB at n=10 — caller's responsibility to keep K bounded.

Parameters:

  • proposer_rank

    (NDArray[integer]) –

    (n, n) rank matrix.

  • responder_rank

    (NDArray[integer]) –

    (n, n) rank matrix.

  • matchings

    (NDArray[integer]) –

    (K, n) array of candidate men-indexed matchings.

Returns:

  • NDArray[bool_]

    (K,) bool array.

Source code in src/gale_shapley_algorithm/numeric/stability.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def is_stable_batch(
    proposer_rank: NDArray[np.integer],
    responder_rank: NDArray[np.integer],
    matchings: NDArray[np.integer],
) -> NDArray[np.bool_]:
    """Vectorized stability check across a batch of matchings.

    Memory use is O(K * n**2) where K is the batch size. For K = n! (full
    permutation enumeration) the tensor grows as O(n**2 * n!), ~12 MB at
    n=8 and ~360 MB at n=10 — caller's responsibility to keep K bounded.

    Args:
        proposer_rank: ``(n, n)`` rank matrix.
        responder_rank: ``(n, n)`` rank matrix.
        matchings: ``(K, n)`` array of candidate men-indexed matchings.

    Returns:
        ``(K,)`` bool array.
    """
    arange_n = np.arange(proposer_rank.shape[0], dtype=np.int16)
    partner_rank_proposer = proposer_rank[arange_n[np.newaxis, :], matchings]
    partner_of_responder = np.argsort(matchings, axis=1)
    partner_rank_responder = responder_rank[arange_n[np.newaxis, :], partner_of_responder]

    block_p = proposer_rank[np.newaxis, :, :] < partner_rank_proposer[:, :, np.newaxis]
    block_r = responder_rank.T[np.newaxis, :, :] < partner_rank_responder[:, np.newaxis, :]
    has_block = np.any(block_p & block_r, axis=(1, 2))
    return ~has_block

Stable-matching lattice enumeration

lattice

Enumerate the full stable-matching lattice of an SMP instance.

Two enumeration strategies are provided:

rotation (default, Gusfield-Irving 1989): navigates the lattice by exposing and eliminating rotations. Runs in O(n**2 + |L| * n**2) where |L| is the number of stable matchings. Scales past n=50 comfortably; the only asymptotic cost is |L| itself, which is empirically small for uniform-random preferences but can be exponential in adversarial instances.

brute: vectorized batched brute force over all n! permutations. Simple, memory-bounded via batching, but capped at max_n (default 10) because n! grows fast. Useful as a correctness oracle for testing the rotation implementation, and for instances where you want the deterministic n!-enumeration order.

Also exports two lower-level primitives that expose the rotation structure itself: :func:exposed_rotations returns the rotations currently applicable at a given stable matching, and :func:apply_rotation takes one step down the lattice. These are the building blocks for any algorithm that walks the stable-matching lattice (lattice-navigation search, RL agents that learn a rotation policy, incremental maintenance under preference changes, etc.).

References

Gusfield, D. (1987). "Three Fast Algorithms for Four Problems in Stable Marriage." SIAM J. Comput. 16(1). Irving, R. W. and Leather, P. (1986). "The Complexity of Counting Stable Marriages." SIAM J. Comput. 15(3). Gusfield, D. and Irving, R. W. (1989). The Stable Marriage Problem: Structure and Algorithms. MIT Press.

Functions:

apply_rotation

apply_rotation(matching: NDArray[integer], rotation: NDArray[integer]) -> NDArray[int16]

Apply a rotation to a stable matching, producing the next matching.

Parameters:

  • matching

    (NDArray[integer]) –

    (n,) men-indexed stable matching.

  • rotation

    (NDArray[integer]) –

    (r, 2) array from :func:exposed_rotations.

Returns:

  • NDArray[int16]

    A fresh (n,) int16 matching. Input arrays are not mutated.

Source code in src/gale_shapley_algorithm/numeric/lattice.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def apply_rotation(
    matching: NDArray[np.integer],
    rotation: NDArray[np.integer],
) -> NDArray[np.int16]:
    """Apply a rotation to a stable matching, producing the next matching.

    Args:
        matching: ``(n,)`` men-indexed stable matching.
        rotation: ``(r, 2)`` array from :func:`exposed_rotations`.

    Returns:
        A fresh ``(n,)`` int16 matching. Input arrays are not mutated.
    """
    new_matching = matching.astype(np.int16, copy=True)
    for i in range(int(rotation.shape[0])):
        new_matching[int(rotation[i, 0])] = int(rotation[i, 1])
    return new_matching

enumerate_stable_matchings

enumerate_stable_matchings(men_rank: NDArray[integer], women_rank: NDArray[integer], method: Literal['rotation', 'brute'] = 'rotation', max_n: int = _DEFAULT_MAX_N_BRUTE, batch_size: int = _DEFAULT_BATCH_SIZE) -> NDArray[int16]

Return every stable matching of the instance.

Parameters:

  • men_rank

    (NDArray[integer]) –

    (n, n) men's rank matrix.

  • women_rank

    (NDArray[integer]) –

    (n, n) women's rank matrix.

  • method

    (Literal['rotation', 'brute'], default: 'rotation' ) –

    "rotation" (default) uses the Gusfield-Irving rotation algorithm and scales to large n; "brute" uses vectorized permutation enumeration and is capped at max_n.

  • max_n

    (int, default: _DEFAULT_MAX_N_BRUTE ) –

    only consulted when method="brute".

  • batch_size

    (int, default: _DEFAULT_BATCH_SIZE ) –

    only consulted when method="brute".

Returns:

  • NDArray[int16]

    (|L|, n) int16 array of men-indexed stable matchings.

Raises:

  • ValueError

    if the rank matrices are not same-shape square permutation matrices, or method is unknown, or brute-force is requested with n > max_n.

Source code in src/gale_shapley_algorithm/numeric/lattice.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def enumerate_stable_matchings(
    men_rank: NDArray[np.integer],
    women_rank: NDArray[np.integer],
    method: Literal["rotation", "brute"] = "rotation",
    max_n: int = _DEFAULT_MAX_N_BRUTE,
    batch_size: int = _DEFAULT_BATCH_SIZE,
) -> NDArray[np.int16]:
    """Return every stable matching of the instance.

    Args:
        men_rank: ``(n, n)`` men's rank matrix.
        women_rank: ``(n, n)`` women's rank matrix.
        method: ``"rotation"`` (default) uses the Gusfield-Irving rotation
            algorithm and scales to large n; ``"brute"`` uses vectorized
            permutation enumeration and is capped at ``max_n``.
        max_n: only consulted when ``method="brute"``.
        batch_size: only consulted when ``method="brute"``.

    Returns:
        ``(|L|, n)`` int16 array of men-indexed stable matchings.

    Raises:
        ValueError: if the rank matrices are not same-shape square permutation
            matrices, or ``method`` is unknown, or brute-force is requested
            with ``n > max_n``.
    """
    _validate_rank_matrices(men_rank, women_rank)
    if method == "rotation":
        return _enumerate_via_rotations(men_rank, women_rank)
    if method == "brute":
        return _enumerate_via_brute_force(men_rank, women_rank, max_n=max_n, batch_size=batch_size)
    raise ValueError(f"Unknown method {method!r}. Expected 'rotation' or 'brute'.")

exposed_rotations

exposed_rotations(men_rank: NDArray[integer], women_rank: NDArray[integer], matching: NDArray[integer]) -> list[NDArray[int16]]

Return the list of rotations currently exposed at matching.

Each rotation is an (r, 2) int16 array; row i is [m_i, w_{i+1}], where w_{i+1} is the partner m_i acquires if the rotation is eliminated. Applying the rotation amounts to assigning new_matching[m_i] = w_{i+1} cyclically.

At the men-optimal matching every rotation exposed here corresponds to a distinct one-step move down the lattice; at the women-optimal matching no rotations are exposed (you have reached the infimum).

Parameters:

  • men_rank

    (NDArray[integer]) –

    (n, n) men's rank matrix.

  • women_rank

    (NDArray[integer]) –

    (n, n) women's rank matrix.

  • matching

    (NDArray[integer]) –

    (n,) men-indexed stable matching.

Returns:

  • list[NDArray[int16]]

    List of rotations; may be empty if no rotation is exposed.

Raises:

  • ValueError

    if the rank matrices are not same-shape square permutation matrices.

Source code in src/gale_shapley_algorithm/numeric/lattice.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def exposed_rotations(
    men_rank: NDArray[np.integer],
    women_rank: NDArray[np.integer],
    matching: NDArray[np.integer],
) -> list[NDArray[np.int16]]:
    """Return the list of rotations currently exposed at ``matching``.

    Each rotation is an ``(r, 2)`` int16 array; row ``i`` is ``[m_i, w_{i+1}]``,
    where ``w_{i+1}`` is the partner ``m_i`` acquires if the rotation is eliminated.
    Applying the rotation amounts to assigning ``new_matching[m_i] = w_{i+1}``
    cyclically.

    At the men-optimal matching every rotation exposed here corresponds to a
    distinct one-step move down the lattice; at the women-optimal matching no
    rotations are exposed (you have reached the infimum).

    Args:
        men_rank: ``(n, n)`` men's rank matrix.
        women_rank: ``(n, n)`` women's rank matrix.
        matching: ``(n,)`` men-indexed stable matching.

    Returns:
        List of rotations; may be empty if no rotation is exposed.

    Raises:
        ValueError: if the rank matrices are not same-shape square permutation
            matrices.
    """
    _validate_rank_matrices(men_rank, women_rank)
    next_target, next_man = _compute_rotation_pointers(matching, men_rank, women_rank)
    cycles = _find_cycles(next_man)
    rotations: list[NDArray[np.int16]] = []
    for cycle in cycles:
        rot = np.empty((len(cycle), 2), dtype=np.int16)
        for i, m in enumerate(cycle):
            rot[i, 0] = m
            rot[i, 1] = next_target[m]
        rotations.append(rot)
    return rotations