stable-matching 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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