stable_match 0.1.1 → 0.2.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.
- checksums.yaml +15 -0
- data/Gemfile +1 -4
- data/README.md +46 -7
- data/Rakefile +35 -40
- data/examples/example_1.rb +12 -12
- data/lib/stable_match.rb +3 -6
- data/lib/stable_match/candidate.rb +74 -104
- data/lib/stable_match/runner.rb +81 -98
- data/lib/stable_match/test.rb +49 -0
- data/lib/stable_match/util.rb +1 -0
- data/lib/stable_match/util/initialize_with_defaults.rb +27 -0
- data/lib/stable_match/version.rb +1 -1
- data/stable_match.gemspec +7 -15
- data/test/functional/nrmp_test.rb +18 -16
- data/test/functional/stable_marriage_test.rb +75 -0
- data/test/test_helper.rb +22 -7
- data/test/unit/lib/stable_match/candidate_test.rb +3 -41
- data/test/unit/lib/stable_match/runner_test.rb +11 -34
- data/test/unit/lib/stable_match/util/intialize_with_defaults_test.rb +49 -0
- data/test/unit/lib/stable_match_test.rb +2 -2
- metadata +26 -56
- data/test/support/minitest.rb +0 -51
data/lib/stable_match/runner.rb
CHANGED
@@ -1,39 +1,37 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'stable_match/util/initialize_with_defaults'
|
3
|
+
|
1
4
|
module StableMatch
|
2
5
|
class Runner
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
+
include Util::InitializeWithDefaults
|
7
|
+
|
8
|
+
# Whether the sets have been built into candidate sets
|
9
|
+
attr_accessor :built
|
6
10
|
alias_method :built? , :built
|
7
11
|
|
8
|
-
|
9
|
-
|
10
|
-
fattr( :candidates ){ [] }
|
12
|
+
# Container for all the candidates
|
13
|
+
attr_accessor :candidates
|
11
14
|
|
12
|
-
|
13
|
-
|
14
|
-
fattr( :checked ){ false }
|
15
|
+
# Whether the sets have been checked for consistency
|
16
|
+
attr_accessor :checked
|
15
17
|
alias_method :checked? , :checked
|
16
18
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
## Runner::run
|
34
|
-
#
|
35
|
-
# Class-level factory method to construct, check, build and run a Runner instance
|
36
|
-
#
|
19
|
+
# The first set to use in the matching
|
20
|
+
attr_accessor \
|
21
|
+
:candidate_set1, # for Candidates
|
22
|
+
:set1 # raw data
|
23
|
+
|
24
|
+
# The second set to use in the matching
|
25
|
+
attr_accessor \
|
26
|
+
:candidate_set2, # for Candidates
|
27
|
+
:set2 # raw data
|
28
|
+
|
29
|
+
# The way in which the run loop executes
|
30
|
+
#
|
31
|
+
# Should be either: symmetric OR asymmetric
|
32
|
+
attr_accessor :strategy
|
33
|
+
|
34
|
+
# Class-level factory method to construct, check, build and run a Runner instance
|
37
35
|
def self.run( *args )
|
38
36
|
runner = new *args
|
39
37
|
runner.check!
|
@@ -41,52 +39,61 @@ module StableMatch
|
|
41
39
|
runner.run
|
42
40
|
end
|
43
41
|
|
44
|
-
def initialize(
|
45
|
-
|
46
|
-
@
|
47
|
-
@
|
42
|
+
def initialize( set1 , set2 , strategy = :symmetric )
|
43
|
+
@set1 = set1
|
44
|
+
@set2 = set2
|
45
|
+
@strategy = strategy
|
46
|
+
end
|
48
47
|
|
49
|
-
|
48
|
+
def initialize_defaults!
|
49
|
+
@built ||= false
|
50
|
+
@candidates ||= []
|
51
|
+
@checked ||= false
|
52
|
+
@candidate_set1 ||= {}
|
53
|
+
@candidate_set2 ||= {}
|
50
54
|
end
|
51
55
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
# Also, track a master array of `candidates`
|
56
|
-
# Mark itself as `built`
|
57
|
-
#
|
56
|
+
# Convert `set1` and `set2` into `candidate_set1` and `candidate_set2`
|
57
|
+
# Also, track a master array of `candidates`
|
58
|
+
# Mark itself as `built`
|
58
59
|
def build!
|
59
60
|
set1.each do | target , options |
|
60
|
-
candidate =
|
61
|
+
candidate =
|
62
|
+
Candidate.new \
|
63
|
+
target,
|
64
|
+
*( options.first.is_a?( Array ) ? options : [ options ] )
|
65
|
+
|
61
66
|
candidates.push candidate
|
62
67
|
candidate_set1[ target ] = candidate
|
63
68
|
end
|
64
69
|
|
65
70
|
set2.each do | target , options |
|
66
|
-
candidate =
|
71
|
+
candidate =
|
72
|
+
Candidate.new \
|
73
|
+
target,
|
74
|
+
*( options.first.is_a?( Array ) ? options : [ options ] )
|
75
|
+
|
67
76
|
candidates.push candidate
|
68
77
|
candidate_set2[ target ] = candidate
|
69
78
|
end
|
70
79
|
|
71
80
|
candidate_set1.each do | target , candidate |
|
72
|
-
candidate.preferences
|
81
|
+
candidate.preferences =
|
82
|
+
candidate.raw_preferences.map { | preference_target | candidate_set2[ preference_target ] }
|
73
83
|
end
|
74
84
|
|
75
85
|
candidate_set2.each do | target , candidate |
|
76
|
-
candidate.preferences
|
86
|
+
candidate.preferences =
|
87
|
+
candidate.raw_preferences.map { | preference_target | candidate_set1[ preference_target ] }
|
77
88
|
end
|
78
89
|
|
79
|
-
|
80
|
-
#
|
90
|
+
# We've built the candidates
|
81
91
|
self.built = true
|
82
92
|
end
|
83
93
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
# Meant to be run before being built into candidate sets
|
88
|
-
# Mark itself as `checked`
|
89
|
-
#
|
94
|
+
# Run basic checks against each raw set
|
95
|
+
# Meant to be run before being built into candidate sets
|
96
|
+
# Mark itself as `checked`
|
90
97
|
def check!
|
91
98
|
error = proc { | message | raise ArgumentError.new( message ) }
|
92
99
|
set1_keys = set1.keys
|
@@ -94,62 +101,41 @@ module StableMatch
|
|
94
101
|
set1_size = set1.size
|
95
102
|
set2_size = set2.size
|
96
103
|
|
97
|
-
|
98
|
-
#
|
104
|
+
# Check set1
|
99
105
|
set1.each do | target , options |
|
100
106
|
message = "Preferences for #{ target.inspect } in `set1` do not match availabilities in `set2`!"
|
107
|
+
options = options.first if options.first.is_a?( Array )
|
101
108
|
error[ message ] unless \
|
102
|
-
|
103
|
-
|
104
|
-
( options[ :preferences ].inject( true ){ | memo , preference | memo && set2_keys.include?( preference ) } )
|
109
|
+
# Anything there is a preference for is in the other set
|
110
|
+
( options.all? { | preference | set2_keys.include?( preference ) } )
|
105
111
|
end
|
106
112
|
|
107
|
-
|
108
|
-
#
|
113
|
+
# Check set2 the same way
|
109
114
|
set2.each do | target , options |
|
110
115
|
message = "Preferences for #{ target.inspect } in `set2` do not match availabilities in `set1`!"
|
116
|
+
options = options.first if options.first.is_a?( Array )
|
111
117
|
error[ message ] unless \
|
112
|
-
|
118
|
+
# Anything there is a preference for is in the other set
|
119
|
+
( options.all? { | preference | set1_keys.include?( preference ) } )
|
113
120
|
end
|
114
121
|
|
115
|
-
|
116
|
-
#
|
122
|
+
# We've run the check
|
117
123
|
self.checked = true
|
118
124
|
end
|
119
125
|
|
120
126
|
def inspect
|
121
|
-
|
122
|
-
|
123
|
-
inspection = proc do | set |
|
124
|
-
set.keys.inject( Hash.new ) do | hash , key |
|
125
|
-
candidate = set[ key ]
|
126
|
-
preferences = candidate.preferences
|
127
|
-
|
128
|
-
hash.update(
|
129
|
-
key => {
|
130
|
-
'matches' => candidate.matches.map( &:target ),
|
131
|
-
'preferences' => candidate.preferences.map( &:target ),
|
132
|
-
'proposals' => candidate.proposals.map( &:target )
|
133
|
-
}
|
134
|
-
)
|
135
|
-
end
|
136
|
-
end
|
137
|
-
|
138
|
-
{
|
139
|
-
'strategy' => strategy.to_s,
|
140
|
-
'candidate_set1' => inspection[ candidate_set1 ],
|
141
|
-
'candidate_set2' => inspection[ candidate_set2 ]
|
142
|
-
}.to_yaml
|
127
|
+
candidates.map( &:inspect ).join "\n\n"
|
143
128
|
end
|
144
129
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
130
|
+
alias_method \
|
131
|
+
:pretty_inspect,
|
132
|
+
:inspect
|
133
|
+
|
134
|
+
# This method respects the runner's strategy!
|
135
|
+
#
|
136
|
+
# List the remaining candidates that:
|
137
|
+
# -> have remaining slots available for matches AND
|
138
|
+
# -> have not already proposed to all of their preferences
|
153
139
|
def remaining_candidates
|
154
140
|
case strategy.to_sym
|
155
141
|
when :symmetric
|
@@ -159,12 +145,9 @@ module StableMatch
|
|
159
145
|
end
|
160
146
|
end
|
161
147
|
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
# -> a candidate has proposed to all of their preferences
|
166
|
-
# -> a candidate has no more `matching_positions` to be filled
|
167
|
-
#
|
148
|
+
# While there are remaining candidates, ask each one to propose to all of their preferences until:
|
149
|
+
# -> a candidate has proposed to all of their preferences
|
150
|
+
# -> a candidate has no more `matching_positions` to be filled
|
168
151
|
def run
|
169
152
|
while ( rcs = remaining_candidates ).any?
|
170
153
|
rcs.each do | candidate |
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'stable_match'
|
2
|
+
require 'minitest/test'
|
3
|
+
|
4
|
+
module StableMatch
|
5
|
+
class Test < Minitest::Test
|
6
|
+
alias_method '__assert__' , 'assert'
|
7
|
+
|
8
|
+
# See: https://github.com/ahoward/testing.rb/blob/08dd643239a23543409ecb5fee100181f1621794/lib/testing.rb#L82-107
|
9
|
+
#
|
10
|
+
# Override assert to take a few different kinds of options
|
11
|
+
# Most notable argument type is a block that:
|
12
|
+
# -> asserts no exceptions were raised
|
13
|
+
# -> asserts the result of the block is truthy
|
14
|
+
# -> returns the result of the block
|
15
|
+
def assert( *args , &block )
|
16
|
+
if args.size == 1 and args.first.is_a?(Hash)
|
17
|
+
options = args.first
|
18
|
+
expected = getopt(:expected, options){ missing }
|
19
|
+
actual = getopt(:actual, options){ missing }
|
20
|
+
if expected == missing and actual == missing
|
21
|
+
actual , expected , *_ = options.to_a.flatten
|
22
|
+
end
|
23
|
+
expected = expected.call() if expected.respond_to?(:call)
|
24
|
+
actual = actual.call() if actual.respond_to?(:call)
|
25
|
+
assert_equal expected , actual
|
26
|
+
end
|
27
|
+
|
28
|
+
if block
|
29
|
+
label = "assert(#{ args.join(' ') })"
|
30
|
+
result = nil
|
31
|
+
raised = false
|
32
|
+
result = begin
|
33
|
+
block.call
|
34
|
+
rescue Object => e
|
35
|
+
raised = e
|
36
|
+
false
|
37
|
+
end
|
38
|
+
__assert__ !raised , ( raised.message rescue label )
|
39
|
+
__assert__ result , label
|
40
|
+
result
|
41
|
+
else
|
42
|
+
result = args.shift
|
43
|
+
label = "assert(#{ args.join(' ') })"
|
44
|
+
__assert__ result , label
|
45
|
+
result
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'stable_match/util/initialize_with_defaults'
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module StableMatch
|
2
|
+
module Util
|
3
|
+
module InitializeWithDefaults
|
4
|
+
def self.included( klass )
|
5
|
+
klass.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def new( *args , &block )
|
10
|
+
object = allocate
|
11
|
+
|
12
|
+
object.initialize_defaults!
|
13
|
+
|
14
|
+
object.send \
|
15
|
+
:initialize,
|
16
|
+
*args,
|
17
|
+
&block
|
18
|
+
|
19
|
+
object
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize_defaults!
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/stable_match/version.rb
CHANGED
data/stable_match.gemspec
CHANGED
@@ -2,27 +2,19 @@
|
|
2
2
|
require File.expand_path('../lib/stable_match/version', __FILE__)
|
3
3
|
|
4
4
|
Gem::Specification.new do | gem |
|
5
|
-
gem.authors = [
|
6
|
-
gem.email = [
|
5
|
+
gem.authors = [ 'Ryan Cook' , 'Ara Howard' ]
|
6
|
+
gem.email = [ 'cookrn@gmail.com' , 'ara.t.howard@gmail.com' ]
|
7
7
|
gem.description = %q{A generic implementation of the stable match algorightm.}
|
8
8
|
gem.summary = %q{stable_match v0.1.0}
|
9
|
-
gem.homepage =
|
9
|
+
gem.homepage = 'https://github.com/cookrn/stable_match'
|
10
10
|
|
11
11
|
gem.files = `git ls-files`.split($\)
|
12
12
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
13
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
-
gem.name =
|
15
|
-
gem.require_paths = [
|
14
|
+
gem.name = 'stable_match'
|
15
|
+
gem.require_paths = [ 'lib' ]
|
16
16
|
gem.version = StableMatch::VERSION
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
gem.add_dependency "fattr" , "~> 2.2.1"
|
21
|
-
gem.add_dependency "map" , "~> 5.5.0"
|
22
|
-
|
23
|
-
## Development Dependencies
|
24
|
-
#
|
25
|
-
gem.add_development_dependency "minitest" , "~> 2.12.1"
|
26
|
-
gem.add_development_dependency "rake" , "~> 0.9.2.2"
|
27
|
-
gem.add_development_dependency "rego" , "~> 1.0.0"
|
18
|
+
gem.add_development_dependency 'minitest'
|
19
|
+
gem.add_development_dependency 'rake'
|
28
20
|
end
|
@@ -1,23 +1,23 @@
|
|
1
|
-
require
|
1
|
+
require 'test_helper'
|
2
2
|
|
3
|
-
class
|
3
|
+
class NrmpTest < StableMatch::Test
|
4
4
|
def test_case
|
5
5
|
programs = {
|
6
|
-
'mercy' =>
|
7
|
-
'city' =>
|
8
|
-
'general' =>
|
9
|
-
'state' =>
|
6
|
+
'mercy' => [ ['chen', 'garcia'] , 2 ],
|
7
|
+
'city' => [ ['garcia', 'hassan', 'eastman', 'anderson', 'brown', 'chen', 'davis', 'ford'] , 2 ],
|
8
|
+
'general' => [ ['brown', 'eastman', 'hassan', 'anderson', 'chen', 'davis', 'garcia'] , 2 ],
|
9
|
+
'state' => [ ['brown', 'eastman', 'anderson', 'chen', 'hassan', 'ford', 'davis', 'garcia'] , 2 ]
|
10
10
|
}
|
11
11
|
|
12
12
|
applicants = {
|
13
|
-
'anderson' =>
|
14
|
-
'brown' =>
|
15
|
-
'chen' =>
|
16
|
-
'davis' =>
|
17
|
-
'eastman' =>
|
18
|
-
'ford' =>
|
19
|
-
'garcia' =>
|
20
|
-
'hassan' =>
|
13
|
+
'anderson' => [ 'city' ],
|
14
|
+
'brown' => [ 'city' , 'mercy' ],
|
15
|
+
'chen' => [ 'city' , 'mercy' ],
|
16
|
+
'davis' => [ 'mercy' , 'city' , 'general' , 'state' ],
|
17
|
+
'eastman' => [ 'city' , 'mercy' , 'state' , 'general' ],
|
18
|
+
'ford' => [ 'city' , 'general' , 'mercy' , 'state' ],
|
19
|
+
'garcia' => [ 'city' , 'mercy' , 'state' , 'general' ],
|
20
|
+
'hassan' => [ 'state' , 'city' , 'mercy' , 'general' ]
|
21
21
|
}
|
22
22
|
|
23
23
|
programs_expectations = {
|
@@ -38,14 +38,15 @@ class NationalResidentMatchingProgramFunctionalTest < MiniTest::Unit::TestCase
|
|
38
38
|
'hassan' => [ 'state' ]
|
39
39
|
}
|
40
40
|
|
41
|
-
runner = StableMatch.run applicants , programs , :
|
41
|
+
runner = StableMatch.run applicants , programs , :asymmetric
|
42
42
|
|
43
43
|
## applicants
|
44
44
|
#
|
45
45
|
applicants_expectations.each do | applicant , expected_matches |
|
46
46
|
candidate = runner.candidate_set1[ applicant ]
|
47
47
|
actual_matches = candidate.matches.map &:target
|
48
|
-
|
48
|
+
assertion = "#{ applicant } matches #{ actual_matches }"
|
49
|
+
assert( assertion ){ expected_matches.sort == actual_matches.sort }
|
49
50
|
end
|
50
51
|
|
51
52
|
## programs
|
@@ -53,6 +54,7 @@ class NationalResidentMatchingProgramFunctionalTest < MiniTest::Unit::TestCase
|
|
53
54
|
programs_expectations.each do | program , expected_matches |
|
54
55
|
candidate = runner.candidate_set2[ program ]
|
55
56
|
actual_matches = candidate.matches.map &:target
|
57
|
+
assertion = "#{ program } matches #{ actual_matches }"
|
56
58
|
assert{ expected_matches.sort == actual_matches.sort }
|
57
59
|
end
|
58
60
|
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class StableMarriageTest < StableMatch::Test
|
4
|
+
def test_case
|
5
|
+
men = {
|
6
|
+
'abe' => %w(abi eve cath ivy jan dee fay bea hope gay),
|
7
|
+
'bob' => %w(cath hope abi dee eve fay bea jan ivy gay),
|
8
|
+
'col' => %w(hope eve abi dee bea fay ivy gay cath jan),
|
9
|
+
'dan' => %w(ivy fay dee gay hope eve jan bea cath abi),
|
10
|
+
'ed' => %w(jan dee bea cath fay eve abi ivy hope gay),
|
11
|
+
'fred' => %w(bea abi dee gay eve ivy cath jan hope fay),
|
12
|
+
'gav' => %w(gay eve ivy bea cath abi dee hope jan fay),
|
13
|
+
'hal' => %w(abi eve hope fay ivy cath jan bea gay dee),
|
14
|
+
'ian' => %w(hope cath dee gay bea abi fay ivy jan eve),
|
15
|
+
'jon' => %w(abi fay jan gay eve bea dee cath ivy hope)
|
16
|
+
}
|
17
|
+
|
18
|
+
women = {
|
19
|
+
'abi' => %w(bob fred jon gav ian abe dan ed col hal),
|
20
|
+
'bea' => %w(bob abe col fred gav dan ian ed jon hal),
|
21
|
+
'cath' => %w(fred bob ed gav hal col ian abe dan jon),
|
22
|
+
'dee' => %w(fred jon col abe ian hal gav dan bob ed),
|
23
|
+
'eve' => %w(jon hal fred dan abe gav col ed ian bob),
|
24
|
+
'fay' => %w(bob abe ed ian jon dan fred gav col hal),
|
25
|
+
'gay' => %w(jon gav hal fred bob abe col ed dan ian),
|
26
|
+
'hope' => %w(gav jon bob abe ian dan hal ed col fred),
|
27
|
+
'ivy' => %w(ian col hal gav fred bob abe ed jon dan),
|
28
|
+
'jan' => %w(ed hal gav abe bob jon col ian fred dan)
|
29
|
+
}
|
30
|
+
|
31
|
+
mens_expectations = {
|
32
|
+
'abe' => [ 'ivy' ],
|
33
|
+
'bob' => [ 'cath' ],
|
34
|
+
'col' => [ 'dee' ],
|
35
|
+
'dan' => [ 'fay' ],
|
36
|
+
'ed' => [ 'jan' ],
|
37
|
+
'fred' => [ 'bea' ],
|
38
|
+
'gav' => [ 'gay' ],
|
39
|
+
'hal' => [ 'eve' ],
|
40
|
+
'ian' => [ 'hope' ],
|
41
|
+
'jon' => [ 'abi' ]
|
42
|
+
}
|
43
|
+
|
44
|
+
womens_expectations = {
|
45
|
+
'abi' => [ 'jon' ],
|
46
|
+
'bea' => [ 'fred' ],
|
47
|
+
'cath' => [ 'bob' ],
|
48
|
+
'dee' => [ 'col' ],
|
49
|
+
'eve' => [ 'hal' ],
|
50
|
+
'fay' => [ 'dan' ],
|
51
|
+
'gay' => [ 'gav' ],
|
52
|
+
'hope' => [ 'ian' ],
|
53
|
+
'ivy' => [ 'abe' ],
|
54
|
+
'jan' => [ 'ed' ]
|
55
|
+
}
|
56
|
+
|
57
|
+
runner = StableMatch.run men , women , :asymmetric
|
58
|
+
|
59
|
+
## men
|
60
|
+
#
|
61
|
+
mens_expectations.each do | man , expected_matches |
|
62
|
+
candidate = runner.candidate_set1[ man ]
|
63
|
+
actual_matches = candidate.matches.map &:target
|
64
|
+
assert{ expected_matches.sort == actual_matches.sort }
|
65
|
+
end
|
66
|
+
|
67
|
+
## women
|
68
|
+
#
|
69
|
+
womens_expectations.each do | woman , expected_matches |
|
70
|
+
candidate = runner.candidate_set2[ woman ]
|
71
|
+
actual_matches = candidate.matches.map &:target
|
72
|
+
assert{ expected_matches.sort == actual_matches.sort }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|