stable-matching 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ #
7
+ # Implements Phase I of Irving's (1985) Stable Roommates algorithm.
8
+ #
9
+ # See:
10
+ # - https://en.wikipedia.org/wiki/Stable_roommates_problem
11
+ # - https://www.youtube.com/watch?v=9Lo7TFAkohEaccepted_proposal?
12
+ #
13
+ # In this round each member who has not had their proposal accepted "proposes"
14
+ # to their top remaining preference
15
+ #
16
+ # Each recipient of a proposal can take one of 3 actions -
17
+ #
18
+ # 1. The recipient has not received a previous proposal and immediately accepts
19
+ #
20
+ # 2. The recipient prefers this new proposal over an existing one.
21
+ # The recipient "rejects" it's initial proposl and accepts this new one
22
+ #
23
+ # 3. The recipient prefers the existing proposal over the new one.
24
+ # The recipient "rejects" the new proposal
25
+ #
26
+ # In the above situations, every rejection is mutual - if `i` removes `j` from
27
+ # its preference list, then `j` must also remove `i` from its list
28
+ #
29
+ # This cycle continues until one of two things happens
30
+ #
31
+ # A. Every member has had their proposal accepted
32
+ # -> Move on to Phase II
33
+ # B. At least one member has exhausted their preference list
34
+ # -> No solution exists
35
+ #
36
+ #
37
+ # === EXAMPLE
38
+ #
39
+ # Take the following preference lists
40
+ #
41
+ # A => [B, D, F, C, E],
42
+ # B => [D, E, F, A, C],
43
+ # C => [D, E, F, A, B],
44
+ # D => [F, C, A, E, B],
45
+ # E => [F, C, D, B, A],
46
+ # F => [A, B, D, C, E]
47
+ #
48
+ # We always start with the first unmatched user. Initially this is "A".
49
+ # The sequence of events are -
50
+ #
51
+ # 'A' proposes to 'B'
52
+ # 'B' accepts 'A'
53
+ # 'B' proposes to 'D'
54
+ # 'D' accepts 'B'
55
+ # 'C' proposes to 'D'
56
+ # 'D' accepts 'C', rejects 'B'
57
+ # 'B' proposes to 'E'
58
+ # 'E' accepts 'B'
59
+ # 'D' proposes to 'F'
60
+ # 'F' accepts 'D'
61
+ # 'E' proposes to 'F'
62
+ # 'F' rejects
63
+ # 'E' proposes to 'C'
64
+ # 'C' accepts 'E'
65
+ # 'F' proposes to 'A'
66
+ # 'A' accepts 'F'
67
+ #
68
+ # The result of this phase is shown below.
69
+ # A "-" indicates a proposal made and a "+" indicates a proposal accepted.
70
+ # Rejected members are removed.
71
+ #
72
+ # A => [-B, D, +F, C, E],
73
+ # B => [-E, F, +A, C],
74
+ # C => [-D, +E, F, A, B],
75
+ # D => [-F, +C, A, E],
76
+ # E => [-C, D, +B, A],
77
+ # F => [-A, B, +D, C]
78
+
79
+ require_relative "../phase_runner"
80
+
81
+ class StableMatching
82
+ class Roommate
83
+ class PhaseIRunner < StableMatching::PhaseRunner
84
+ def initialize(preference_table, opts = {})
85
+ @preference_table = preference_table
86
+ @logger = opts.fetch(:logger)
87
+ end
88
+
89
+ def run
90
+ while @preference_table.unmatched.any?
91
+ ensure_table_is_stable!
92
+
93
+ member = @preference_table.unmatched.first
94
+ top_choice = member.first_preference
95
+
96
+ simulate_proposal(member, top_choice)
97
+ end
98
+
99
+ # Check once more since final iteration may have left the table unstable
100
+ ensure_table_is_stable!
101
+ end
102
+
103
+ private
104
+
105
+ def ensure_table_is_stable!
106
+ return true if @preference_table.stable?
107
+ raise StableMatching::NoStableSolutionError, "No stable match found!"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,105 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ # Implements Phase II of Irving's (1985) Stable Roommates algorithm.
7
+ #
8
+ # See:
9
+ # - https://en.wikipedia.org/wiki/Stable_roommates_problem
10
+ # - https://www.youtube.com/watch?v=9Lo7TFAkohEaccepted_proposal?
11
+ #
12
+ # In this phase, each member that has accepted a proposal will remove those they
13
+ # prefer less than their current proposer.
14
+ #
15
+ # At the end of one iteration, one of two states are possible -
16
+ #
17
+ # A. At least one member has exhausted their preference list
18
+ # -> No solution exists
19
+ #
20
+ # B. Some members have multiple preferences remaining
21
+ # -> Proceed to Phase III
22
+ #
23
+ # C. All members have one preference remaining
24
+ # -> Solution has been found, no need to run Phase III
25
+ #
26
+ #
27
+ # NOTE: For the ease of implementation, step (C) is implemented as the first
28
+ # check in Phase III, although it is logically part of this step.
29
+ #
30
+ #
31
+ # === EXAMPLE
32
+ #
33
+ # Assume the output of Phase I is
34
+ #
35
+ # A => [-B, D, +F, C, E],
36
+ # B => [-E, F, +A, C],
37
+ # C => [-D, +E, F, A, B],
38
+ # D => [-F, +C, A, E],
39
+ # E => [-C, D, +B, A],
40
+ # F => [-A, B, +D, C]
41
+ #
42
+ # Where a "-" indicates a proposal made and a "+" indicates a proposal accepted.
43
+ #
44
+ # Rejections for Phase II would occur as follows. Note that all rejections are
45
+ # mutual - if `i` removes `j` from its preference list, then `j` must also
46
+ # remove `i` from its list
47
+ #
48
+ # A accepted by B. B rejecting members less preferred than A: ["C"]
49
+ # B accepted by E. E rejecting members less preferred than B: ["A"]
50
+ # C accepted by D. D rejecting members less preferred than C: ["A", "E"]
51
+ # D accepted by F. F rejecting members less preferred than D: ["C"]
52
+ # E accepted by C. C rejecting members less preferred than E: ["A"]
53
+ # F accepted by A. A rejecting members less preferred than F: []
54
+ #
55
+ # The output of this phase is a further reduced table is as follows
56
+ #
57
+ # A => [B, F],
58
+ # B => [E, F, A],
59
+ # C => [D, E],
60
+ # D => [F, C],
61
+ # E => [C, B],
62
+ # F => [A, B, D]
63
+ #
64
+ # Since at least one member has multiple preferences remaining, we proceed to
65
+ # Phase III
66
+ #
67
+
68
+ require_relative "../phase_runner"
69
+
70
+ class StableMatching
71
+ class Roommate
72
+ class PhaseIIRunner < StableMatching::PhaseRunner
73
+ def initialize(preference_table, opts = {})
74
+ @preference_table = preference_table
75
+ @logger = opts.fetch(:logger)
76
+ end
77
+
78
+ def run
79
+ @preference_table.members.each do |proposer|
80
+ acceptor, rejections =
81
+ determine_acceptor_and_their_rejections(proposer)
82
+
83
+ @logger.debug(
84
+ "#{proposer.name} accepted by #{acceptor.name}. "\
85
+ "#{acceptor.name} rejecting members less preferred than "\
86
+ "#{proposer.name}: #{rejections.map(&:name)}"
87
+ )
88
+
89
+ rejections.each { |rejected| acceptor.reject!(rejected) }
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def determine_acceptor_and_their_rejections(proposer)
96
+ acceptor = proposer.current_acceptor
97
+ current_proposer_index = acceptor.preference_list.index(proposer)
98
+
99
+ rejections = acceptor.preference_list[current_proposer_index + 1..-1]
100
+
101
+ [acceptor, rejections]
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,159 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ # Implements Phase II of Irving's (1985) Stable Roommates algorithm.
7
+ #
8
+ # See:
9
+ # - https://en.wikipedia.org/wiki/Stable_roommates_problem
10
+ # - https://www.youtube.com/watch?v=9Lo7TFAkohEaccepted_proposal?
11
+ #
12
+ # In this last phase we attempt to find any preference cycles and reject them.
13
+ #
14
+ # This is done by building a pair of members (Xi, Yi) as follows
15
+ #
16
+ # - Xi is the first member with at least 2 preferences
17
+ # - Yi is null
18
+ # - Yi+1 is the 2nd preference of Xi
19
+ # - Xi+1 is the last preference of Yi+1
20
+ #
21
+ # Continue calculating pairs (Xi, Yi) until Xi repeats values. At this point a
22
+ # cycle has been found.
23
+ #
24
+ # Mutually reject every pair (Xi, Yi)
25
+ #
26
+ # After this one of 3 possiblities exists -
27
+ #
28
+ # A. At least one member has exhausted their preference list
29
+ # -> No solution exists
30
+ #
31
+ # B. Some members have multiple preferences remaining
32
+ # -> Repeat the above process to eliminate further cycles
33
+ #
34
+ # C. All members have one preference remaining
35
+ # -> Solution has been found
36
+ #
37
+ # === EXAMPLE
38
+ #
39
+ # Assume the reduced output of Phase II is
40
+ #
41
+ # A => [B, F],
42
+ # B => [E, F, A],
43
+ # C => [D, E],
44
+ # D => [F, C],
45
+ # E => [C, B],
46
+ # F => [A, B, D]
47
+ #
48
+ # Start with "A" since it is the first member with at least two preferences.
49
+ #
50
+ # Build (Xi, Yi) pairs as follows
51
+ #
52
+ # i 1 2 3 4
53
+ # -----+---+---+---+----
54
+ # x: | A | D | E | A
55
+ # y: | - | F | C | B
56
+ #
57
+ # Where -
58
+ #
59
+ # 'F' is the 2nd preference of 'A'
60
+ # 'D' is the last preference of 'F'
61
+ # 'C' is the 2nd preference of 'D'
62
+ # etc...
63
+ #
64
+ # As soon as we see "A" again, we stop since we have found a cycle.
65
+ #
66
+ # Now we will mutually reject the following pairs, as definied by the inner
67
+ # pairings
68
+ #
69
+ # - (D, F)
70
+ # - (E, C)
71
+ # - (A, B)
72
+ #
73
+ # At this point, no preference list is exhausted and some have more than one
74
+ # preference remaining. We need to find and eliminate more preference cycles.
75
+ #
76
+ # i 1 2
77
+ # -----+---+---
78
+ # x: | B | B
79
+ # y: | - | F
80
+ #
81
+ # Now we will mutually reject
82
+ #
83
+ # - (F, B)
84
+ #
85
+ # This gives us the stable solution below, since each roommate has exactly one
86
+ # preference remaining
87
+ #
88
+ # A => [F],
89
+ # B => [E],
90
+ # C => [D],
91
+ # D => [C],
92
+ # E => [B],
93
+ # F => [A]
94
+
95
+ require_relative "../phase_runner"
96
+
97
+ class StableMatching
98
+ class Roommate
99
+ class PhaseIIIRunner < StableMatching::PhaseRunner
100
+ def initialize(preference_table, opts = {})
101
+ @preference_table = preference_table
102
+ @logger = opts.fetch(:logger)
103
+ end
104
+
105
+ def run
106
+ # The output of previous phase may have resulted in a complete
107
+ # table, in which case this step doesn't need to be run
108
+ return @preference_table if @preference_table.complete?
109
+
110
+ while table_is_stable? && !@preference_table.complete?
111
+ x = [@preference_table.members_with_multiple_preferences.first]
112
+ y = [nil]
113
+
114
+ until any_repeats?(x)
115
+ # require 'pry'; binding.pry if x.last.second_preference.nil?
116
+ y << x.last.second_preference
117
+ x << y.last.last_preference
118
+ end
119
+
120
+ detect_and_reject_cycles(x, y)
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def any_repeats?(arr)
127
+ arr.uniq.count != arr.count
128
+ end
129
+
130
+ def detect_and_reject_cycles(x, y)
131
+ x, y = retrieve_cycle(x, y)
132
+
133
+ msg = "Found cycle: "
134
+ y.each_with_index { |_, i| msg << "(#{x[i].name}, #{y[i].name})" }
135
+ @logger.debug(msg)
136
+
137
+ x.each_with_index do |r1, i|
138
+ r2 = y[i]
139
+ @logger.debug("Mutually rejecting '#{r1.name}', '#{r2.name}'")
140
+ r2.reject!(r1)
141
+ end
142
+ end
143
+
144
+ def retrieve_cycle(x, y)
145
+ repeated_member = x.detect { |i| x.count(i) > 1 }
146
+
147
+ first_index = 1
148
+ last_index = x.count - x.reverse.index(repeated_member) - 1
149
+
150
+ [x[first_index..last_index], y[first_index..last_index]]
151
+ end
152
+
153
+ def table_is_stable?
154
+ return true if @preference_table.stable?
155
+ raise StableMatching::NoStableSolutionError, "No stable match found!"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,20 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ require_relative "../preference_table"
7
+
8
+ class StableMatching
9
+ class Roommate
10
+ class PreferenceTable < StableMatching::PreferenceTable
11
+ def stable?
12
+ members.all? { |member| !member.preference_list.empty? }
13
+ end
14
+
15
+ def members_with_multiple_preferences
16
+ members.select { |member| member.preference_list.count > 1 }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ require_relative "../validator"
7
+
8
+ class StableMatching
9
+ class Roommate
10
+ class Validator < StableMatching::Validator
11
+ # rubocop:disable Metrics/CyclomaticComplexity
12
+ def validate!
13
+ case
14
+ when !hash_of_arrays? then handle_not_hash_of_arrays
15
+ when empty? then handle_empty
16
+ when !strings_or_integers? then handle_not_strings_or_integers
17
+ when !even_sized? then handle_not_even_sized
18
+ when !symmetrical? then handle_not_symmetrical
19
+ end
20
+
21
+ raise ::StableMatching::InvalidPreferences, @error if @error
22
+ end
23
+ # rubocop:enable Metrics/CyclomaticComplexity
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ class StableMatching
7
+ class Error < StandardError; end
8
+
9
+ class NoStableSolutionError < Error; end
10
+ class InvalidPreferences < Error; end
11
+ end
@@ -0,0 +1,93 @@
1
+ # Provides a ruby implementation of several common matching algorithms
2
+ #
3
+ # Author:: Abhishek Chandrasekhar (mailto:me@abhchand.me)
4
+ # License:: MIT
5
+
6
+ class StableMatching
7
+ class Validator
8
+ def self.validate!(preference_table)
9
+ new(preference_table).validate!
10
+ end
11
+
12
+ def initialize(preference_table)
13
+ @preference_table = preference_table
14
+ end
15
+
16
+ # @abstract Subclass is expected to implement #validate!
17
+ # @!method validate!
18
+ # Validate the structure and content of the `preference_table`
19
+
20
+ private
21
+
22
+ def hash_of_arrays?
23
+ return false unless @preference_table.is_a?(Hash)
24
+ @preference_table.values.each { |p| return false unless p.is_a?(Array) }
25
+
26
+ true
27
+ end
28
+
29
+ def handle_not_hash_of_arrays
30
+ @error = "Expecting a preference table hash of arrays"
31
+ end
32
+
33
+ def empty?
34
+ return true if @preference_table.empty?
35
+
36
+ @preference_table.each_value.any?(&:empty?)
37
+ end
38
+
39
+ def handle_empty
40
+ @error = "Preferences table can not empty"
41
+ end
42
+
43
+ def strings_or_integers?
44
+ @preference_table.each do |key, array|
45
+ @member_klass ||= key.class
46
+
47
+ return false unless valid_member?(key)
48
+ array.each { |value| return false unless valid_member?(value) }
49
+ end
50
+
51
+ true
52
+ end
53
+
54
+ def handle_not_strings_or_integers
55
+ @error = "All keys must be String or Integer"
56
+ end
57
+
58
+ def even_sized?
59
+ @preference_table.keys.size.even?
60
+ end
61
+
62
+ def handle_not_even_sized
63
+ @error = "Preference table must have an even number of keys"
64
+ end
65
+
66
+ def symmetrical?
67
+ @preference_table.each do |name, preference_list|
68
+ expected_members = @preference_table.keys - [name]
69
+ actual_members = preference_list
70
+
71
+ next if expected_members.sort == actual_members.sort
72
+
73
+ @name = name
74
+ @extra = actual_members - expected_members
75
+ @missing = expected_members - actual_members
76
+ return false
77
+ end
78
+
79
+ true
80
+ end
81
+
82
+ def handle_not_symmetrical
83
+ @error = "Entry #{@name} has invalid preferences. "\
84
+ "The extra members are: #{@extra}. "\
85
+ "The missing members are: #{@missing}"
86
+ end
87
+
88
+ def valid_member?(member)
89
+ (member.is_a?(String) || member.is_a?(Integer)) &&
90
+ member.class == @member_klass
91
+ end
92
+ end
93
+ end