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
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
|