stable_match 0.1.1 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
MWI3M2IwN2ZiZGJiYzcwZGIyMDcxOWY1MTViYmZiYzJhYTliMDJlZg==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
NmMyZjk0MDI0NjA0NDZhOWE1M2Q2M2Y4NThmMGVmM2QyMzc4NTdiZg==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
NTAyNjhjYjgxYzYyN2E2ODZlOWE3NzFkODJhMjk2MmRkNWQ5MTE5Y2I4ZTlh
|
10
|
+
OWJmZmMwZTEyNmY3MjFlYTcxMWMzMmNjZGQ5MDc4ODZhZWFjMzI3YTRmYWUw
|
11
|
+
NTdiZjNiNWVmZGE1MGY4Y2JmNzY1NGUzNTM4YjIzNzQ5NGM5NDA=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
ZTQ1MGIxZjQyZTZmOGY3ODBlOGFhNWEzNzVlMzkzMGExN2Y2ODkyOTU1M2Ix
|
14
|
+
Y2FkYTNlZTUyOWM5ZmNkNjUwODQwM2E1MGJmNWUzY2M1MmIxMzJiZTM0YzAy
|
15
|
+
ZDRjMzMyYmY1Yjc5N2MwNGY3YzgzZDE5ZmVjZDczMGMxNzJkOTU=
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -2,6 +2,52 @@
|
|
2
2
|
|
3
3
|
A generic implementation of the Stable Match class of algorithms.
|
4
4
|
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
* See: `examples/example_1.rb`
|
8
|
+
* See: `test/functional/nrmp_test.rb`
|
9
|
+
* See: `test/functional/stable_marriage_test.rb`
|
10
|
+
* Run: `rake example`
|
11
|
+
|
12
|
+
The idea behind stable matching is the following: You have two sets of
|
13
|
+
data that you would like to find the most ideal matching between. Stable
|
14
|
+
matching means that candidates from either set might remain unmatched.
|
15
|
+
Unstable matching means that matches are forced even if they are not
|
16
|
+
ideal.
|
17
|
+
|
18
|
+
Your inputs are two hashes where the keys are known as 'target's to
|
19
|
+
StableMatch. Targets ideally are domain specific objects to your
|
20
|
+
application. The value in the hash for each target is an array of
|
21
|
+
preferences for the target. (See the note below about when preferences
|
22
|
+
are themselves each an Array object.)
|
23
|
+
|
24
|
+
Preferences are an ordered array of 'target's belonging to the other set
|
25
|
+
such that, the lower the index, the higher the preference. This is required
|
26
|
+
and checked at runtime. It can be an empty array, but all preferences must
|
27
|
+
belong to the other set.
|
28
|
+
|
29
|
+
The final argument that a `Candidate` object can be instantiated with is
|
30
|
+
called `match_positions` and equates to the number of matches that the
|
31
|
+
given target can acquire. This is optional and defaults to 1.
|
32
|
+
|
33
|
+
### In-Depth Example (IN PROGRESS)
|
34
|
+
|
35
|
+
Let's talk about a dog-walker example. We have two domain classes: Dog
|
36
|
+
and Walker. Let's say that the preferences will be determined by the
|
37
|
+
weight and geographic location of the dog.
|
38
|
+
|
39
|
+
```
|
40
|
+
TODO !!
|
41
|
+
FIXME !!
|
42
|
+
this needs finished
|
43
|
+
```
|
44
|
+
|
45
|
+
### Gotchas
|
46
|
+
|
47
|
+
* To match against objects that _are_ arrays, they'll need to be
|
48
|
+
preemptively wrapped in another array when passing them a runner. See
|
49
|
+
the NRMP test in `test/functional` for example.
|
50
|
+
|
5
51
|
## Installation
|
6
52
|
|
7
53
|
### Without Bundler
|
@@ -26,13 +72,6 @@ And then execute:
|
|
26
72
|
$ bundle
|
27
73
|
```
|
28
74
|
|
29
|
-
## Usage
|
30
|
-
|
31
|
-
* See: `examples/example_1.rb`
|
32
|
-
* Run: `rake example`
|
33
|
-
|
34
|
-
TODO: Write more usage instructions here
|
35
|
-
|
36
75
|
## References
|
37
76
|
|
38
77
|
* [http://halfamind.aghion.com/the-national-resident-matching-programs-nrmp](http://halfamind.aghion.com/the-national-resident-matching-programs-nrmp)
|
data/Rakefile
CHANGED
@@ -1,18 +1,36 @@
|
|
1
1
|
#!/usr/bin/env rake
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
require 'rake/testtask'
|
5
5
|
|
6
6
|
namespace :examples do
|
7
|
-
desc
|
7
|
+
desc 'Run any example'
|
8
|
+
task :all do
|
9
|
+
Dir[ "#{ File.dirname File.expand_path( __FILE__ ) }/examples/**/*.rb" ].each do | file |
|
10
|
+
puts
|
11
|
+
puts '============================================='
|
12
|
+
puts "Start Example: #{ file }"
|
13
|
+
puts '============================================='
|
14
|
+
puts
|
15
|
+
system "ruby #{ file }"
|
16
|
+
puts
|
17
|
+
puts '============================================='
|
18
|
+
puts "End Example: #{ file }"
|
19
|
+
puts '============================================='
|
20
|
+
puts
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
desc 'Run any example'
|
8
25
|
task :any do
|
9
26
|
exec "ruby #{ Dir[ "#{ File.dirname File.expand_path( __FILE__ ) }/examples/**/*.rb" ].first }"
|
10
27
|
end
|
11
28
|
end
|
12
|
-
task :example
|
29
|
+
task :example => 'examples:any'
|
30
|
+
task :examples => 'examples:all'
|
13
31
|
|
14
32
|
namespace :test do
|
15
|
-
desc
|
33
|
+
desc 'Run All The Tests'
|
16
34
|
task :all do
|
17
35
|
suites = %w(
|
18
36
|
test:functional
|
@@ -20,59 +38,36 @@ namespace :test do
|
|
20
38
|
)
|
21
39
|
|
22
40
|
suites.each do | suite |
|
23
|
-
puts
|
41
|
+
puts '=========================================='
|
24
42
|
puts "Running: #{ suite }"
|
25
|
-
puts
|
43
|
+
puts '=========================================='
|
26
44
|
begin
|
27
45
|
Rake::Task[ suite ].invoke
|
28
46
|
rescue
|
29
|
-
puts
|
47
|
+
puts '=========================================='
|
30
48
|
puts "FAILED: #{ suite }"
|
31
49
|
ensure
|
32
|
-
puts
|
50
|
+
puts '=========================================='
|
33
51
|
puts "\n"
|
34
52
|
end
|
35
53
|
end
|
36
54
|
end
|
37
55
|
|
38
|
-
desc
|
56
|
+
desc 'Run The Functional Tests'
|
39
57
|
Rake::TestTask.new( :functional ) do | t |
|
40
|
-
t.libs << [
|
41
|
-
t.pattern =
|
58
|
+
t.libs << [ 'test' ]
|
59
|
+
t.pattern = 'test/functional/**/*_test.rb'
|
42
60
|
t.verbose = true
|
43
61
|
end
|
44
62
|
|
45
|
-
desc
|
63
|
+
desc 'Run The Unit Tests'
|
46
64
|
Rake::TestTask.new( :unit ) do | t |
|
47
|
-
t.libs << [
|
48
|
-
t.pattern =
|
65
|
+
t.libs << [ 'test' ]
|
66
|
+
t.pattern = 'test/unit/**/*_test.rb'
|
49
67
|
t.verbose = true
|
50
68
|
end
|
51
|
-
|
52
|
-
desc "Start a watcher process that runs the tests on file changes in `lib` or `test` dirs"
|
53
|
-
task :watch do
|
54
|
-
exec "rego {lib,test} -- rake"
|
55
|
-
end
|
56
69
|
end
|
57
70
|
|
58
|
-
desc
|
59
|
-
task :test =>
|
71
|
+
desc 'Run All The Tests'
|
72
|
+
task :test => 'test:all'
|
60
73
|
task :default => :test
|
61
|
-
|
62
|
-
BEGIN {
|
63
|
-
def load_bundle_env!
|
64
|
-
is_blank = lambda { |o| o.nil? or o.size < 1 }
|
65
|
-
ENV[ "BUNDLE_GEMFILE" ] = File.expand_path( "Gemfile" ) unless !is_blank[ ENV[ "BUNDLE_GEMFILE" ] ]
|
66
|
-
begin
|
67
|
-
require "bundler"
|
68
|
-
rescue
|
69
|
-
require "rubygems"
|
70
|
-
require "bundler"
|
71
|
-
ensure
|
72
|
-
raise LoadError.new( "Bundler not found!" ) unless defined?( Bundler )
|
73
|
-
require "bundler/setup"
|
74
|
-
end
|
75
|
-
end
|
76
|
-
|
77
|
-
load_bundle_env! unless defined?( Bundler )
|
78
|
-
}
|
data/examples/example_1.rb
CHANGED
@@ -4,21 +4,21 @@ class Test
|
|
4
4
|
## This is a modified version of the example NRMP case
|
5
5
|
#
|
6
6
|
PROGRAMS = {
|
7
|
-
'city' =>
|
8
|
-
'general' =>
|
9
|
-
'mercy' =>
|
10
|
-
'state' =>
|
7
|
+
'city' => ['garcia', 'hassan', 'eastman', 'brown', 'chen', 'davis', 'ford'],
|
8
|
+
'general' => [ ['brown', 'eastman', 'hassan', 'anderson', 'chen', 'davis', 'garcia'] , 3 ],
|
9
|
+
'mercy' => [ ['chen', 'garcia', 'brown'] , 2 ],
|
10
|
+
'state' => ['anderson', 'brown', 'eastman', 'chen', 'hassan', 'ford', 'davis', 'garcia']
|
11
11
|
}
|
12
12
|
|
13
13
|
APPLICANTS = {
|
14
|
-
'anderson' =>
|
15
|
-
'brown' =>
|
16
|
-
'chen' =>
|
17
|
-
'davis' =>
|
18
|
-
'eastman' =>
|
19
|
-
'ford' =>
|
20
|
-
'garcia' =>
|
21
|
-
'hassan' =>
|
14
|
+
'anderson' => ['state', 'city'],
|
15
|
+
'brown' => ['city', 'mercy', 'state'],
|
16
|
+
'chen' => ['city', 'mercy'],
|
17
|
+
'davis' => ['mercy', 'city', 'general', 'state'],
|
18
|
+
'eastman' => ['city', 'mercy', 'state', 'general'],
|
19
|
+
'ford' => ['city', 'general', 'mercy', 'state'],
|
20
|
+
'garcia' => ['city', 'mercy', 'state', 'general'],
|
21
|
+
'hassan' => ['state', 'city', 'mercy', 'general' ]
|
22
22
|
}
|
23
23
|
|
24
24
|
def self.main
|
data/lib/stable_match.rb
CHANGED
@@ -1,12 +1,9 @@
|
|
1
|
-
require "fattr"
|
2
|
-
require "map"
|
3
|
-
|
4
1
|
module StableMatch
|
5
2
|
def self.run( *args , &block )
|
6
3
|
Runner.run *args , &block
|
7
4
|
end
|
8
5
|
end
|
9
6
|
|
10
|
-
require
|
11
|
-
require
|
12
|
-
require
|
7
|
+
require 'stable_match/candidate'
|
8
|
+
require 'stable_match/runner'
|
9
|
+
require 'stable_match/version'
|
@@ -1,45 +1,50 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'stable_match/util/initialize_with_defaults'
|
3
|
+
|
1
4
|
module StableMatch
|
2
5
|
class Candidate
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
@
|
32
|
-
@preferences = options.preferences rescue args.shift or raise ArgumentError.new( "No `preferences` provided!" )
|
33
|
-
|
34
|
-
@match_positions = options.match_positions if options.get( :match_positions )
|
6
|
+
include Util::InitializeWithDefaults
|
7
|
+
|
8
|
+
# The matches this candidate has attained
|
9
|
+
attr_accessor :matches
|
10
|
+
|
11
|
+
# The number of matches the candidate is able to make
|
12
|
+
attr_accessor :match_positions
|
13
|
+
|
14
|
+
# The tracked position for preferences that have been attempted for matches
|
15
|
+
attr_accessor :preference_position
|
16
|
+
|
17
|
+
# An ordered array of candidates where, the lower the index, the higher the preference
|
18
|
+
#
|
19
|
+
# WARNING -- this may be instantiated with targets at first that get converted to Candidates
|
20
|
+
attr_accessor :preferences
|
21
|
+
|
22
|
+
# The array to track proposals that have already been made
|
23
|
+
attr_accessor :proposals
|
24
|
+
|
25
|
+
# An ordered array of raw preference values
|
26
|
+
attr_accessor :raw_preferences
|
27
|
+
|
28
|
+
# The object that the candidate represents
|
29
|
+
attr_accessor :target
|
30
|
+
|
31
|
+
def initialize( target , raw_preferences , match_positions = 1 )
|
32
|
+
@target = target
|
33
|
+
@raw_preferences = raw_preferences
|
34
|
+
@match_positions = match_positions
|
35
35
|
end
|
36
36
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
37
|
+
def initialize_defaults!
|
38
|
+
@matches ||= []
|
39
|
+
@preference_position ||= -1
|
40
|
+
@preferences ||= []
|
41
|
+
@proposals ||= []
|
42
|
+
@raw_preferences ||= []
|
43
|
+
end
|
44
|
+
|
45
|
+
# Is the passed candidate a better match than any of the current matches?
|
46
|
+
#
|
47
|
+
# ARG: `other` -- another Candidate instance to check against the current set
|
43
48
|
def better_match?( other )
|
44
49
|
return true if prefers?( other ) && free?
|
45
50
|
preference_index = preferences.index other
|
@@ -47,141 +52,106 @@ module StableMatch
|
|
47
52
|
preference_index and match_preference_indexes.any? { |i| i > preference_index }
|
48
53
|
end
|
49
54
|
|
50
|
-
|
51
|
-
#
|
52
|
-
# Have all possible preferences been cycled through?
|
53
|
-
#
|
55
|
+
# Have all possible preferences been cycled through?
|
54
56
|
def exhausted_preferences?
|
55
57
|
preference_position >= preferences.size - 1
|
56
58
|
end
|
57
59
|
|
58
|
-
|
59
|
-
#
|
60
|
-
# Is there available positions for more matches based on the defined `match_positions`?
|
61
|
-
#
|
60
|
+
# Is there available positions for more matches based on the defined `match_positions`?
|
62
61
|
def free?
|
63
62
|
matches.length < match_positions
|
64
63
|
end
|
65
64
|
|
66
|
-
|
67
|
-
#
|
68
|
-
# Delete the least-preferred candidate from the matches array
|
69
|
-
#
|
65
|
+
# Delete the least-preferred candidate from the matches array
|
70
66
|
def free!
|
71
67
|
return false if matches.empty?
|
72
68
|
match_preference_indexes = matches.map { | match | preferences.index match }
|
73
69
|
max = match_preference_indexes.max # The index of the match with the lowest preference
|
74
70
|
candidate_to_reject = preferences[ max ]
|
75
71
|
|
76
|
-
|
77
|
-
#
|
72
|
+
# Delete from both sides
|
78
73
|
candidate_to_reject.matches.delete self
|
79
74
|
self.matches.delete candidate_to_reject
|
80
75
|
end
|
81
76
|
|
82
|
-
|
83
|
-
#
|
84
|
-
# Are there no remaining positions available for matches?
|
85
|
-
#
|
77
|
+
# Are there no remaining positions available for matches?
|
86
78
|
def full?
|
87
79
|
!free?
|
88
80
|
end
|
89
81
|
|
90
82
|
def inspect
|
91
|
-
require "yaml"
|
92
|
-
|
93
83
|
{
|
94
84
|
'target' => target,
|
95
85
|
'match_positions' => match_positions,
|
96
86
|
'matches' => matches.map( &:target ),
|
97
87
|
'preference_position' => preference_position,
|
98
88
|
'preferences' => preferences.map( &:target ),
|
99
|
-
'proposals' => proposals.map( &:target )
|
89
|
+
'proposals' => proposals.map( &:target ),
|
90
|
+
'raw_preferences' => raw_preferences
|
100
91
|
}.to_yaml
|
101
92
|
end
|
102
93
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
94
|
+
alias_method \
|
95
|
+
:pretty_inspect,
|
96
|
+
:inspect
|
97
|
+
|
98
|
+
# Match with another Candidate
|
99
|
+
#
|
100
|
+
# ARG: `other` -- another Candidate instance to match with
|
109
101
|
def match!( other )
|
110
102
|
return false unless prefers?( other ) && !matched?( other )
|
111
103
|
matches << other
|
112
104
|
other.matches << self
|
113
105
|
end
|
114
106
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
#
|
120
|
-
# ARG: `other` [optional] -- another Candidate instance
|
121
|
-
#
|
107
|
+
# If no argument is passed: Do we have at least as many matches as available `match_positions`?
|
108
|
+
# If another Candidate is passed: Is that candidate included in the matches?
|
109
|
+
#
|
110
|
+
# ARG: `other` [optional] -- another Candidate instance
|
122
111
|
def matched?( other = nil )
|
123
112
|
return full? if other.nil?
|
124
113
|
matches.include? other
|
125
114
|
end
|
126
115
|
|
127
|
-
|
128
|
-
#
|
129
|
-
# Increment `preference_position` and return the preference at that position
|
130
|
-
#
|
116
|
+
# Increment `preference_position` and return the preference at that position
|
131
117
|
def next_preference!
|
132
118
|
self.preference_position += 1
|
133
119
|
preferences.fetch preference_position
|
134
120
|
end
|
135
121
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
#
|
140
|
-
# ARG: `other` -- another Candidate instance
|
141
|
-
#
|
122
|
+
# Is there a preference for the passed Candidate?
|
123
|
+
#
|
124
|
+
# ARG: `other` -- another Candidate instance
|
142
125
|
def prefers?( other )
|
143
126
|
preferences.include? other
|
144
127
|
end
|
145
128
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
#
|
150
|
-
# ARG: `other` -- another Candidate instance
|
151
|
-
#
|
129
|
+
# Track that a proposal was made then ask the other Candidate to respond to a proposal
|
130
|
+
#
|
131
|
+
# ARG: `other` -- another Candidate instance
|
152
132
|
def propose_to( other )
|
153
133
|
proposals << other
|
154
134
|
other.respond_to_proposal_from self
|
155
135
|
end
|
156
136
|
|
157
|
-
## Candidate#propose_to_next_preference
|
158
|
-
#
|
159
|
-
# Send a proposal to the next tracked preference
|
160
|
-
#
|
161
137
|
def propose_to_next_preference
|
162
138
|
propose_to next_preference!
|
163
139
|
end
|
164
140
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
#
|
169
|
-
# ARG: `other` -- another Candidate instance
|
170
|
-
#
|
141
|
+
# Given another candidate, respond properly based on current state
|
142
|
+
#
|
143
|
+
# ARG: `other` -- another Candidate instance
|
171
144
|
def respond_to_proposal_from( other )
|
172
145
|
case
|
173
|
-
|
174
|
-
#
|
146
|
+
# Is there a preference for the candidate?
|
175
147
|
when !prefers?( other )
|
176
148
|
false
|
177
149
|
|
178
|
-
|
179
|
-
#
|
150
|
+
# Are there available positions for more matches?
|
180
151
|
when free?
|
181
152
|
match! other
|
182
153
|
|
183
|
-
|
184
|
-
#
|
154
|
+
# Is the passed Candidate a better match than any other match?
|
185
155
|
when better_match?( other )
|
186
156
|
free!
|
187
157
|
match! other
|