stable_match 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +19 -0
- data/Gemfile +6 -0
- data/LICENSE +22 -0
- data/README.md +29 -0
- data/Rakefile +51 -0
- data/examples/example_1.rb +27 -0
- data/lib/stable_match.rb +12 -0
- data/lib/stable_match/candidate.rb +194 -0
- data/lib/stable_match/runner.rb +165 -0
- data/lib/stable_match/version.rb +3 -0
- data/stable_match.gemspec +28 -0
- data/test/support/minitest.rb +51 -0
- data/test/test_helper.rb +10 -0
- data/test/unit/stable_match/candidate_test.rb +189 -0
- data/test/unit/stable_match/runner_test.rb +121 -0
- metadata +127 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Ryan Cook
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# StableMatch
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'stable_match'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install stable_match
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rake/testtask"
|
5
|
+
|
6
|
+
namespace :examples do
|
7
|
+
desc "Run any example"
|
8
|
+
task :any do
|
9
|
+
exec "ruby #{ Dir[ "#{ File.dirname File.expand_path( __FILE__ ) }/examples/**/*.rb" ].first }"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
task :example => "examples:any"
|
13
|
+
|
14
|
+
namespace :test do
|
15
|
+
desc "Run All The Tests"
|
16
|
+
task :all => [ "test:unit" ]
|
17
|
+
|
18
|
+
desc "Run The Unit Tests"
|
19
|
+
Rake::TestTask.new( :unit ) do | t |
|
20
|
+
t.libs = [ "test" ]
|
21
|
+
t.pattern = "test/unit/**/*_test.rb"
|
22
|
+
t.verbose = true
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "Start a watcher process that runs the tests on file changes in `lib` or `test` dirs"
|
26
|
+
task :watch do
|
27
|
+
exec "rego {lib,test} -- rake"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
desc "Run All The Tests"
|
32
|
+
task :test => "test:all"
|
33
|
+
task :default => :test
|
34
|
+
|
35
|
+
BEGIN {
|
36
|
+
def load_bundle_env!
|
37
|
+
is_blank = lambda { |o| o.nil? or o.size < 1 }
|
38
|
+
ENV[ "BUNDLE_GEMFILE" ] = File.expand_path( "Gemfile" ) unless !is_blank[ ENV[ "BUNDLE_GEMFILE" ] ]
|
39
|
+
begin
|
40
|
+
require "bundler"
|
41
|
+
rescue
|
42
|
+
require "rubygems"
|
43
|
+
require "bundler"
|
44
|
+
ensure
|
45
|
+
raise LoadError.new( "Bundler not found!" ) unless defined?( Bundler )
|
46
|
+
require "bundler/setup"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
load_bundle_env! unless defined?( Bundler )
|
51
|
+
}
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "stable_match"
|
2
|
+
|
3
|
+
class Test
|
4
|
+
PROGRAMS = {
|
5
|
+
'city' => { :match_positions => 1, :preferences => ['garcia', 'hassan', 'eastman', 'brown', 'chen', 'davis', 'ford']},
|
6
|
+
'general' => { :match_positions => 3, :preferences => ['brown', 'eastman', 'hassan', 'anderson', 'chen', 'davis', 'garcia']},
|
7
|
+
'mercy' => { :match_positions => 1, :preferences => ['chen', 'garcia', 'brown']},
|
8
|
+
'state' => { :match_positions => 1, :preferences => ['anderson', 'brown', 'eastman', 'chen', 'hassan', 'ford', 'davis', 'garcia']}
|
9
|
+
}
|
10
|
+
|
11
|
+
APPLICANTS = {
|
12
|
+
'anderson' => { :match_positions => 1 , :preferences => ['state', 'city'] },
|
13
|
+
'brown' => { :match_positions => 2 , :preferences => ['city', 'mercy', 'state'] },
|
14
|
+
'chen' => { :match_positions => 1 , :preferences => ['city', 'mercy'] },
|
15
|
+
'davis' => { :match_positions => 1 , :preferences => ['mercy', 'city', 'general', 'state'] },
|
16
|
+
'eastman' => { :match_positions => 1 , :preferences => ['city', 'mercy', 'state', 'general'] },
|
17
|
+
'ford' => { :match_positions => 1 , :preferences => ['city', 'general', 'mercy', 'state'] },
|
18
|
+
'garcia' => { :match_positions => 1 , :preferences => ['city', 'mercy', 'state', 'general'] },
|
19
|
+
'hassan' => { :match_positions => 1 , :preferences => ['state', 'city', 'mercy', 'general' ] }
|
20
|
+
}
|
21
|
+
|
22
|
+
def self.main
|
23
|
+
p StableMatch.run(APPLICANTS, PROGRAMS)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
Test.main
|
data/lib/stable_match.rb
ADDED
@@ -0,0 +1,194 @@
|
|
1
|
+
module StableMatch
|
2
|
+
class Candidate
|
3
|
+
## The matches this candidate has attained
|
4
|
+
#
|
5
|
+
fattr( :matches ){ [] }
|
6
|
+
|
7
|
+
## The number of matches the candidate is able to make
|
8
|
+
#
|
9
|
+
fattr( :match_positions ){ 1 }
|
10
|
+
|
11
|
+
## The tracked position for preferences that have been attempted for matches
|
12
|
+
#
|
13
|
+
fattr( :preference_position ){ -1 }
|
14
|
+
|
15
|
+
## An ordered array of candidates where, the lower the index, the higher the preference
|
16
|
+
#
|
17
|
+
# WARNING -- this may be instantiated with targets at first that get converted to Candidates
|
18
|
+
#
|
19
|
+
fattr( :preferences ){ [] }
|
20
|
+
|
21
|
+
## The array to track proposals that have already been made
|
22
|
+
#
|
23
|
+
fattr( :proposals ){ [] }
|
24
|
+
|
25
|
+
## The object that the candidate represents
|
26
|
+
#
|
27
|
+
fattr :target
|
28
|
+
|
29
|
+
def initialize(*args)
|
30
|
+
options = Map.opts(args)
|
31
|
+
@target = options.target rescue args.shift or raise ArgumentError.new( "No `target` provided!" )
|
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 )
|
35
|
+
end
|
36
|
+
|
37
|
+
## Candidate#better_match?
|
38
|
+
#
|
39
|
+
# Is the passed candidate a better match than any of the current matches?
|
40
|
+
#
|
41
|
+
# ARG: `other` -- another Candidate instance to check against the current set
|
42
|
+
#
|
43
|
+
def better_match?( other )
|
44
|
+
return true if prefers?( other ) && free?
|
45
|
+
preference_index = preferences.index other
|
46
|
+
match_preference_indexes = matches.map { | match | preferences.index match }
|
47
|
+
preference_index and match_preference_indexes.any? { |i| i > preference_index }
|
48
|
+
end
|
49
|
+
|
50
|
+
## Candidate#exhausted_preferences?
|
51
|
+
#
|
52
|
+
# Have all possible preferences been cycled through?
|
53
|
+
#
|
54
|
+
def exhausted_preferences?
|
55
|
+
preference_position >= preferences.size - 1
|
56
|
+
end
|
57
|
+
|
58
|
+
## Candidate#free?
|
59
|
+
#
|
60
|
+
# Is there available positions for more matches based on the defined `match_positions`?
|
61
|
+
#
|
62
|
+
def free?
|
63
|
+
matches.length < match_positions
|
64
|
+
end
|
65
|
+
|
66
|
+
## Candidate#free!
|
67
|
+
#
|
68
|
+
# Delete the least-preferred candidate from the matches array
|
69
|
+
#
|
70
|
+
def free!
|
71
|
+
return false if matches.empty?
|
72
|
+
match_preference_indexes = matches.map { | match | preferences.index match }
|
73
|
+
max = match_preference_indexes.max # The index of the match with the lowest preference
|
74
|
+
candidate_to_reject = preferences[ max ]
|
75
|
+
|
76
|
+
## Delete from both sides
|
77
|
+
#
|
78
|
+
candidate_to_reject.matches.delete self
|
79
|
+
self.matches.delete candidate_to_reject
|
80
|
+
end
|
81
|
+
|
82
|
+
## Candidate#full?
|
83
|
+
#
|
84
|
+
# Are there no remaining positions available for matches?
|
85
|
+
#
|
86
|
+
def full?
|
87
|
+
!free?
|
88
|
+
end
|
89
|
+
|
90
|
+
def inspect
|
91
|
+
require "yaml"
|
92
|
+
|
93
|
+
{
|
94
|
+
:target => target,
|
95
|
+
:match_positions => match_positions,
|
96
|
+
:matches => matches.map( &:target ),
|
97
|
+
:preference_position => preference_position,
|
98
|
+
:preferences => preferences.map( &:target ),
|
99
|
+
:proposals => proposals.map( &:target )
|
100
|
+
}.to_yaml
|
101
|
+
end
|
102
|
+
|
103
|
+
## Candidate#match!
|
104
|
+
#
|
105
|
+
# Match with another Candidate
|
106
|
+
#
|
107
|
+
# ARG: `other` -- another Candidate instance to match with
|
108
|
+
#
|
109
|
+
def match!( other )
|
110
|
+
return false unless prefers?( other ) && !matched?( other )
|
111
|
+
matches << other
|
112
|
+
other.matches << self
|
113
|
+
end
|
114
|
+
|
115
|
+
## Candidate#matched?
|
116
|
+
#
|
117
|
+
# If no argument is passed: Do we have at least as many matches as available `match_positions`?
|
118
|
+
# If another Candidate is passed: Is that candidate included in the matches?
|
119
|
+
#
|
120
|
+
# ARG: `other` [optional] -- another Candidate instance
|
121
|
+
#
|
122
|
+
def matched?( other = nil )
|
123
|
+
return full? if other.nil?
|
124
|
+
matches.include? other
|
125
|
+
end
|
126
|
+
|
127
|
+
## Candidate#next_preference!
|
128
|
+
#
|
129
|
+
# Increment `preference_position` and return the preference at that position
|
130
|
+
#
|
131
|
+
def next_preference!
|
132
|
+
self.preference_position += 1
|
133
|
+
preferences.fetch preference_position
|
134
|
+
end
|
135
|
+
|
136
|
+
## Candidate#prefers?
|
137
|
+
#
|
138
|
+
# Is there a preference for the passed Candidate?
|
139
|
+
#
|
140
|
+
# ARG: `other` -- another Candidate instance
|
141
|
+
#
|
142
|
+
def prefers?( other )
|
143
|
+
preferences.include? other
|
144
|
+
end
|
145
|
+
|
146
|
+
## Candidate#propose_to
|
147
|
+
#
|
148
|
+
# Track that a proposal was made then ask the other Candidate to respond to a proposal
|
149
|
+
#
|
150
|
+
# ARG: `other` -- another Candidate instance
|
151
|
+
#
|
152
|
+
def propose_to( other )
|
153
|
+
proposals << other
|
154
|
+
other.respond_to_proposal_from self
|
155
|
+
end
|
156
|
+
|
157
|
+
## Candidate#propose_to_next_preference
|
158
|
+
#
|
159
|
+
# Send a proposal to the next tracked preference
|
160
|
+
#
|
161
|
+
def propose_to_next_preference
|
162
|
+
propose_to next_preference!
|
163
|
+
end
|
164
|
+
|
165
|
+
## Candidate#respond_to_proposal_from
|
166
|
+
#
|
167
|
+
# Given another candidate, respond properly based on current state
|
168
|
+
#
|
169
|
+
# ARG: `other` -- another Candidate instance
|
170
|
+
#
|
171
|
+
def respond_to_proposal_from( other )
|
172
|
+
case
|
173
|
+
## Is there a preference for the candidate?
|
174
|
+
#
|
175
|
+
when !prefers?( other )
|
176
|
+
false
|
177
|
+
|
178
|
+
## Are there available positions for more matches?
|
179
|
+
#
|
180
|
+
when free?
|
181
|
+
match! other
|
182
|
+
|
183
|
+
## Is the passed Candidate a better match than any other match?
|
184
|
+
#
|
185
|
+
when better_match?( other )
|
186
|
+
free!
|
187
|
+
match! other
|
188
|
+
|
189
|
+
else
|
190
|
+
false
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
module StableMatch
|
2
|
+
class Runner
|
3
|
+
## Whether the sets have been built into candidate sets
|
4
|
+
#
|
5
|
+
fattr( :built ){ false }
|
6
|
+
alias_method :built? , :built
|
7
|
+
|
8
|
+
## Container for all the candidates
|
9
|
+
#
|
10
|
+
fattr( :candidates ){ [] }
|
11
|
+
|
12
|
+
## Whether the sets have been checked for consistency
|
13
|
+
#
|
14
|
+
fattr( :checked ){ false }
|
15
|
+
alias_method :checked? , :checked
|
16
|
+
|
17
|
+
## The first set to use in the matching
|
18
|
+
#
|
19
|
+
fattr( :candidate_set1 ){ {} } # for Candidates
|
20
|
+
fattr :set1 # raw data
|
21
|
+
|
22
|
+
## The second set to use in the matching
|
23
|
+
#
|
24
|
+
fattr( :candidate_set2 ){ {} } # for Candidates
|
25
|
+
fattr :set2 # raw data
|
26
|
+
|
27
|
+
## Runner::run
|
28
|
+
#
|
29
|
+
# Class-level factory method to construct, check, build and run a Runner instance
|
30
|
+
#
|
31
|
+
def self.run( *args , &block )
|
32
|
+
runner = new *args , &block
|
33
|
+
runner.check!
|
34
|
+
runner.build!
|
35
|
+
runner.run
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize( *args , &block )
|
39
|
+
options = Map.opts args
|
40
|
+
@set1 = options.set1 rescue args.shift or raise ArgumentError.new( "No `set1` provided!" )
|
41
|
+
@set2 = options.set2 rescue args.shift or raise ArgumentError.new( "No `set2` provided!" )
|
42
|
+
|
43
|
+
yield self if block_given?
|
44
|
+
end
|
45
|
+
|
46
|
+
## Runner#build!
|
47
|
+
#
|
48
|
+
# Convert `set1` and `set2` into `candidate_set1` and `candidate_set2`
|
49
|
+
# Also, track a master array of `candidates`
|
50
|
+
# Mark itself as `built`
|
51
|
+
#
|
52
|
+
def build!
|
53
|
+
set1.each do | target , options |
|
54
|
+
candidate = Candidate.new target , *[ options ]
|
55
|
+
candidates.push candidate
|
56
|
+
candidate_set1[ target ] = candidate
|
57
|
+
end
|
58
|
+
|
59
|
+
set2.each do | target , options |
|
60
|
+
candidate = Candidate.new target , *[ options ]
|
61
|
+
candidates.push candidate
|
62
|
+
candidate_set2[ target ] = candidate
|
63
|
+
end
|
64
|
+
|
65
|
+
candidate_set1.each do | target , candidate |
|
66
|
+
candidate.preferences.map! { | preference_target | candidate_set2[ preference_target ] }
|
67
|
+
end
|
68
|
+
|
69
|
+
candidate_set2.each do | target , candidate |
|
70
|
+
candidate.preferences.map! { | preference_target | candidate_set1[ preference_target ] }
|
71
|
+
end
|
72
|
+
|
73
|
+
## We've built the candidates
|
74
|
+
#
|
75
|
+
self.built = true
|
76
|
+
end
|
77
|
+
|
78
|
+
## Runner#check!
|
79
|
+
#
|
80
|
+
# Run basic checks against each raw set
|
81
|
+
# Meant to be run before being built into candidate sets
|
82
|
+
# Mark itself as `checked`
|
83
|
+
#
|
84
|
+
def check!
|
85
|
+
error = proc { | message | raise ArgumentError.new( message ) }
|
86
|
+
set1_keys = set1.keys
|
87
|
+
set2_keys = set2.keys
|
88
|
+
set1_size = set1.size
|
89
|
+
set2_size = set2.size
|
90
|
+
|
91
|
+
## Check set1
|
92
|
+
#
|
93
|
+
set1.each do | target , options |
|
94
|
+
message = "Preferences for #{ target.inspect } in `set1` do not match availabilities in `set2`!"
|
95
|
+
error[ message ] unless \
|
96
|
+
## Anything there is a preference for is in the other set
|
97
|
+
#
|
98
|
+
( options[ :preferences ].inject( true ){ | memo , preference | memo && set2_keys.include?( preference ) } )
|
99
|
+
end
|
100
|
+
|
101
|
+
## Check set2 the same way
|
102
|
+
#
|
103
|
+
set2.each do | target , options |
|
104
|
+
message = "Preferences for #{ target.inspect } in `set2` do not match availabilities in `set1`!"
|
105
|
+
error[ message ] unless \
|
106
|
+
( options[ :preferences ].inject( true ){ | memo , preference | memo && set1_keys.include?( preference ) } )
|
107
|
+
end
|
108
|
+
|
109
|
+
## We've run the check
|
110
|
+
#
|
111
|
+
self.checked = true
|
112
|
+
end
|
113
|
+
|
114
|
+
def inspect
|
115
|
+
require "yaml"
|
116
|
+
|
117
|
+
inspection = proc do | set |
|
118
|
+
set.keys.inject( Hash.new ) do | hash , key |
|
119
|
+
candidate = set[ key ]
|
120
|
+
preferences = candidate.preferences
|
121
|
+
|
122
|
+
hash.update(
|
123
|
+
key => {
|
124
|
+
'matches' => candidate.matches.map( &:target ),
|
125
|
+
'preferences' => candidate.preferences.map( &:target ),
|
126
|
+
'proposals' => candidate.proposals.map( &:target )
|
127
|
+
}
|
128
|
+
)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
{
|
133
|
+
'candidate_set1' => inspection[ candidate_set1 ],
|
134
|
+
'candidate_set2' => inspection[ candidate_set2 ]
|
135
|
+
}.to_yaml
|
136
|
+
end
|
137
|
+
|
138
|
+
## Runner#remaining_candidates
|
139
|
+
#
|
140
|
+
# List the remaining candidates that:
|
141
|
+
# -> have remaining slots available for matches AND
|
142
|
+
# -> have not already proposed to all of their preferences
|
143
|
+
#
|
144
|
+
def remaining_candidates
|
145
|
+
candidates.reject{ | candidate | candidate.full? || candidate.exhausted_preferences? }
|
146
|
+
end
|
147
|
+
|
148
|
+
## Runner#run
|
149
|
+
#
|
150
|
+
# While there are remaining candidates, ask each one to propose to all of their preferences until:
|
151
|
+
# -> a candidate has proposed to all of their preferences
|
152
|
+
# -> a candidate has no more `matching_positions` to be filled
|
153
|
+
#
|
154
|
+
def run
|
155
|
+
while ( rcs = remaining_candidates ).any?
|
156
|
+
rcs.each do | candidate |
|
157
|
+
while !candidate.exhausted_preferences? && candidate.free?
|
158
|
+
candidate.propose_to_next_preference
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
self
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/stable_match/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do | gem |
|
5
|
+
gem.authors = [ "Ryan Cook" , "Ara Howard" ]
|
6
|
+
gem.email = [ "cookrn@gmail.com" , "ara.t.howard@gmail.com" ]
|
7
|
+
gem.description = %q{A generic implementation of the stable match algorightm.}
|
8
|
+
gem.summary = %q{stable_match v0.1.0}
|
9
|
+
gem.homepage = "https://github.com/cookrn/stable_match"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "stable_match"
|
15
|
+
gem.require_paths = [ "lib" ]
|
16
|
+
gem.version = StableMatch::VERSION
|
17
|
+
|
18
|
+
## Runtime Dependencies
|
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"
|
28
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module MiniTest
|
2
|
+
class Unit
|
3
|
+
class TestCase
|
4
|
+
alias_method '__assert__' , 'assert'
|
5
|
+
|
6
|
+
## TestCase#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
|
+
#
|
16
|
+
def assert( *args , &block )
|
17
|
+
if args.size == 1 and args.first.is_a?(Hash)
|
18
|
+
options = args.first
|
19
|
+
expected = getopt(:expected, options){ missing }
|
20
|
+
actual = getopt(:actual, options){ missing }
|
21
|
+
if expected == missing and actual == missing
|
22
|
+
actual , expected , *_ = options.to_a.flatten
|
23
|
+
end
|
24
|
+
expected = expected.call() if expected.respond_to?(:call)
|
25
|
+
actual = actual.call() if actual.respond_to?(:call)
|
26
|
+
assert_equal expected , actual
|
27
|
+
end
|
28
|
+
|
29
|
+
if block
|
30
|
+
label = "assert(#{ args.join(' ') })"
|
31
|
+
result = nil
|
32
|
+
raised = false
|
33
|
+
result = begin
|
34
|
+
block.call
|
35
|
+
rescue Object => e
|
36
|
+
raised = e
|
37
|
+
false
|
38
|
+
end
|
39
|
+
__assert__ !raised , ( raised.message rescue label )
|
40
|
+
__assert__ result , label
|
41
|
+
result
|
42
|
+
else
|
43
|
+
result = args.shift
|
44
|
+
label = "assert(#{ args.join(' ') })"
|
45
|
+
__assert__ result , label
|
46
|
+
result
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,189 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class StableMatch::CandidateTest < MiniTest::Unit::TestCase
|
4
|
+
def setup
|
5
|
+
@candidate1 = StableMatch::Candidate.new 1 , [ 2 , 3 ]
|
6
|
+
@candidate2 = StableMatch::Candidate.new 2 , [ 1 , 3 ]
|
7
|
+
@candidate3 = StableMatch::Candidate.new 3 , [ 2 , 1 ]
|
8
|
+
|
9
|
+
@candidate1.preferences = [ @candidate2 , @candidate3 ]
|
10
|
+
@candidate2.preferences = [ @candidate1 , @candidate3 ]
|
11
|
+
@candidate3.preferences = [ @candidate2 , @candidate1 ]
|
12
|
+
end
|
13
|
+
|
14
|
+
## Candidate#initialize
|
15
|
+
#
|
16
|
+
def test_accepts_args_as_named_options_or_positionally
|
17
|
+
target = 1
|
18
|
+
preferences = 2
|
19
|
+
|
20
|
+
assert{ StableMatch::Candidate.new( target , preferences ) }
|
21
|
+
assert{ StableMatch::Candidate.new( :target => target , :preferences => preferences ) }
|
22
|
+
|
23
|
+
candidate = assert{ StableMatch::Candidate.new( target , :preferences => preferences ) }
|
24
|
+
assert{ candidate.target == target }
|
25
|
+
|
26
|
+
candidate = assert{ StableMatch::Candidate.new( preferences , :target => target ) }
|
27
|
+
assert{ candidate.preferences == preferences }
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_match_positions_option_overrides_default
|
31
|
+
assert{ @candidate1.match_positions }
|
32
|
+
|
33
|
+
match_positions = 3
|
34
|
+
candidate = StableMatch::Candidate.new( 1 , [ 2 , 3 ] , :match_positions => match_positions )
|
35
|
+
assert{ candidate.match_positions == match_positions }
|
36
|
+
end
|
37
|
+
|
38
|
+
## Candidate#better_match?
|
39
|
+
#
|
40
|
+
def test_better_match_returns_false_if_not_preferred
|
41
|
+
candidate4 = StableMatch::Candidate.new 4 , [ @candidate3 , @candidate2 ]
|
42
|
+
assert{ !@candidate1.better_match?( candidate4 ) }
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_better_match_returns_false_if_already_matched
|
46
|
+
@candidate1.match! @candidate2
|
47
|
+
assert{ !@candidate1.better_match?( @candidate2 ) }
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_better_match_returns_true_if_free
|
51
|
+
assert{ @candidate1.better_match? @candidate2 }
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_better_match_returns_true_if_other_has_higher_preference
|
55
|
+
@candidate1.match! @candidate3
|
56
|
+
assert{ @candidate1.better_match? @candidate2 }
|
57
|
+
end
|
58
|
+
|
59
|
+
## Candidate#exhausted_preferences?
|
60
|
+
#
|
61
|
+
def test_exhausted_preferences_returns_false_if_not_all_preferences_have_been_checked
|
62
|
+
assert{ !@candidate1.exhausted_preferences? }
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_exhausted_preferences_returns_true_if_all_preferences_have_been_checked
|
66
|
+
@candidate1.preference_position = @candidate1.preferences.size
|
67
|
+
assert{ @candidate1.exhausted_preferences? }
|
68
|
+
end
|
69
|
+
|
70
|
+
## Candidate#free?
|
71
|
+
#
|
72
|
+
def test_free_returns_false_if_all_match_positions_are_filled
|
73
|
+
@candidate1.match! @candidate2
|
74
|
+
assert{ !@candidate1.free? }
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_free_returns_true_if_not_all_match_positions_are_filled
|
78
|
+
assert{ @candidate1.free? }
|
79
|
+
end
|
80
|
+
|
81
|
+
## Candidate#free!
|
82
|
+
#
|
83
|
+
def test_free_returns_false_if_there_are_no_matches
|
84
|
+
assert{ !@candidate1.free! }
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_free_deletes_the_least_preferred_match
|
88
|
+
@candidate1.match! @candidate2
|
89
|
+
assert{ @candidate1.free! }
|
90
|
+
assert{ !@candidate1.matches.include?( @candidate2 ) }
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_free_deletes_the_least_preferred_match_from_both_sides
|
94
|
+
@candidate1.match! @candidate2
|
95
|
+
assert{ @candidate1.free! }
|
96
|
+
assert{ !@candidate2.matches.include?( @candidate1 ) }
|
97
|
+
end
|
98
|
+
|
99
|
+
## Candidate#full?
|
100
|
+
#
|
101
|
+
def test_free_returns_false_if_all_match_positions_are_filled
|
102
|
+
@candidate1.match! @candidate2
|
103
|
+
assert{ @candidate1.full? }
|
104
|
+
end
|
105
|
+
|
106
|
+
def test_free_returns_true_if_not_all_match_positions_are_filled
|
107
|
+
assert{ !@candidate1.full? }
|
108
|
+
end
|
109
|
+
|
110
|
+
## Candidate#match!
|
111
|
+
#
|
112
|
+
def test_match_returns_false_if_other_is_not_preferred
|
113
|
+
candidate4 = StableMatch::Candidate.new 4 , [ @candidate3 , @candidate2 ]
|
114
|
+
assert{ !@candidate1.match!( candidate4 ) }
|
115
|
+
end
|
116
|
+
|
117
|
+
def test_match_returns_false_if_already_matched
|
118
|
+
assert{ @candidate1.match! @candidate2 }
|
119
|
+
assert{ !@candidate1.match!( @candidate2 ) }
|
120
|
+
end
|
121
|
+
|
122
|
+
def test_match_adds_other_to_matches_array
|
123
|
+
assert{ @candidate1.match! @candidate2 }
|
124
|
+
assert{ @candidate1.matches.include? @candidate2 }
|
125
|
+
end
|
126
|
+
|
127
|
+
def test_match_adds_self_to_others_matches_array
|
128
|
+
assert{ @candidate1.match! @candidate2 }
|
129
|
+
assert{ @candidate2.matches.include? @candidate1 }
|
130
|
+
end
|
131
|
+
|
132
|
+
## Candidate#matched?
|
133
|
+
#
|
134
|
+
def test_matched_returns_full_with_no_args
|
135
|
+
@candidate1.match! @candidate2
|
136
|
+
assert{ @candidate1.matched? == @candidate1.full? }
|
137
|
+
end
|
138
|
+
|
139
|
+
def test_matched_returns_whether_passed_candidate_is_a_match
|
140
|
+
@candidate1.match! @candidate2
|
141
|
+
assert{ @candidate1.matched? @candidate2 }
|
142
|
+
end
|
143
|
+
|
144
|
+
## Candidate#next_preference!
|
145
|
+
#
|
146
|
+
def test_next_preference_increments_the_preference_position
|
147
|
+
preference_position = @candidate1.preference_position
|
148
|
+
assert{ @candidate1.next_preference! }
|
149
|
+
assert{ preference_position != @candidate1.preference_position }
|
150
|
+
end
|
151
|
+
|
152
|
+
def test_next_preference_returns_a_candidate
|
153
|
+
expected_candidate = @candidate1.preferences.first
|
154
|
+
assert{ expected_candidate == @candidate1.next_preference! }
|
155
|
+
end
|
156
|
+
|
157
|
+
## Candidate#prefers?
|
158
|
+
#
|
159
|
+
def test_prefers_checks_for_existence_of_candidate_in_preferences
|
160
|
+
assert{ @candidate1.prefers? @candidate2 }
|
161
|
+
assert{ !@candidate1.prefers?( "bogus" ) }
|
162
|
+
end
|
163
|
+
|
164
|
+
## Candidate#propose_to
|
165
|
+
#
|
166
|
+
def test_propose_to_tracks_proposals
|
167
|
+
assert{ @candidate1.propose_to @candidate2 }
|
168
|
+
assert{ @candidate1.proposals.include? @candidate2 }
|
169
|
+
end
|
170
|
+
|
171
|
+
## Candidate#respond_to_proposal_from
|
172
|
+
#
|
173
|
+
def test_respond_to_proposal_from_returns_false_if_other_is_not_preferred
|
174
|
+
candidate4 = StableMatch::Candidate.new 4 , [ @candidate3 , @candidate2 ]
|
175
|
+
assert{ !@candidate1.respond_to_proposal_from( candidate4 ) }
|
176
|
+
end
|
177
|
+
|
178
|
+
def test_respond_to_proposal_from_matches_if_preferred_and_free
|
179
|
+
assert{ @candidate1.respond_to_proposal_from @candidate2 }
|
180
|
+
end
|
181
|
+
|
182
|
+
def test_respond_to_proposal_from_frees_and_matches_if_full
|
183
|
+
assert{ @candidate1.respond_to_proposal_from @candidate3 }
|
184
|
+
assert{ @candidate3.matched? @candidate1 }
|
185
|
+
assert{ @candidate1.respond_to_proposal_from @candidate2 }
|
186
|
+
assert{ @candidate2.matched? @candidate1 }
|
187
|
+
assert{ !@candidate3.matched?( @candidate1 ) }
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class StableMatch::RunnerTest < MiniTest::Unit::TestCase
|
4
|
+
def setup
|
5
|
+
@set1 = { 1 => { :preferences => [ 2 ] } }
|
6
|
+
@set2 = { 2 => { :preferences => [ 1 ] } }
|
7
|
+
end
|
8
|
+
|
9
|
+
## Runner::run
|
10
|
+
#
|
11
|
+
def test_has_a_factory_class_method_that_automatically_runs_match
|
12
|
+
runner = assert{ StableMatch::Runner.run @set1 , @set2 }
|
13
|
+
assert{ runner.checked? }
|
14
|
+
assert{ runner.built? }
|
15
|
+
end
|
16
|
+
|
17
|
+
## Runner#initialize
|
18
|
+
#
|
19
|
+
def test_accepts_args_as_named_options_or_positionally
|
20
|
+
assert{ StableMatch::Runner.new( @set1 , @set2 ) }
|
21
|
+
assert{ StableMatch::Runner.new( :set1 => @set1 , :set2 => @set2 ) }
|
22
|
+
|
23
|
+
runner = assert{ StableMatch::Runner.new( @set1 , :set2 => @set2 ) }
|
24
|
+
assert{ runner.set1 == @set1 }
|
25
|
+
|
26
|
+
runner = assert{ StableMatch::Runner.new( @set2 , :set1 => @set1 ) }
|
27
|
+
assert{ runner.set2 == @set2 }
|
28
|
+
end
|
29
|
+
|
30
|
+
## Runner#build!
|
31
|
+
#
|
32
|
+
def test_build_creates_candidate_sets_from_each_raw_set
|
33
|
+
runner = build_runner
|
34
|
+
assert{ runner.build! }
|
35
|
+
|
36
|
+
assert{ !runner.candidate_set1.empty? }
|
37
|
+
assert{ runner.candidate_set1[ @set1.keys.first ] }
|
38
|
+
|
39
|
+
assert{ !runner.candidate_set2.empty? }
|
40
|
+
assert{ runner.candidate_set2[ @set2.keys.first ] }
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_build_adds_to_list_of_candidates
|
44
|
+
runner = build_runner
|
45
|
+
assert{ runner.candidates.empty? }
|
46
|
+
assert{ runner.build! }
|
47
|
+
assert{ !runner.candidates.empty? }
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_build_replaces_candidates_preferences_with_candidates
|
51
|
+
runner = build_runner.tap { | r | r.build! }
|
52
|
+
assert{ runner.candidate_set1.values.first.preferences.all? { | c | c.is_a? StableMatch::Candidate } }
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_build_marks_runner_as_built
|
56
|
+
runner = build_runner
|
57
|
+
assert{ runner.build! }
|
58
|
+
assert{ runner.built }
|
59
|
+
assert{ runner.built? }
|
60
|
+
end
|
61
|
+
|
62
|
+
## Runner#check!
|
63
|
+
#
|
64
|
+
def test_checks_that_sets_meet_criteria
|
65
|
+
runner = build_runner
|
66
|
+
assert{ runner.check! }
|
67
|
+
assert{ runner.checked }
|
68
|
+
assert{ runner.checked? }
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_checks_that_a_sets_preferences_are_included_in_other_set
|
72
|
+
set1 = { 1 => { :preferences => [ 2 ] } }
|
73
|
+
set2 = { 2 => { :preferences => [ 1 , 3 ] } }
|
74
|
+
runner = StableMatch::Runner.new set1 , set2
|
75
|
+
|
76
|
+
assert "set2 contains a preference for 3 that does not exist" do
|
77
|
+
raised = false
|
78
|
+
begin
|
79
|
+
runner.check!
|
80
|
+
rescue Object => e
|
81
|
+
raised = e.is_a? ArgumentError
|
82
|
+
end
|
83
|
+
raised
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
## Runner#remaining_candidates
|
88
|
+
#
|
89
|
+
def test_remaining_candidates_rejects_candidates_that_have_filled_match_positions
|
90
|
+
runner = build_prepared_runner
|
91
|
+
original_size = runner.remaining_candidates.size
|
92
|
+
runner.candidates.first.tap { | c | c.propose_to_next_preference }
|
93
|
+
assert{ original_size > runner.remaining_candidates.size }
|
94
|
+
end
|
95
|
+
|
96
|
+
def test_remaining_candidates_rejects_candidates_that_have_made_all_possible_proposals
|
97
|
+
runner = build_prepared_runner
|
98
|
+
original_size = runner.remaining_candidates.size
|
99
|
+
runner.candidates.first.tap { | c | c.preference_position = c.preferences.size - 1 }
|
100
|
+
assert{ original_size > runner.remaining_candidates.size }
|
101
|
+
end
|
102
|
+
|
103
|
+
## Runner#run
|
104
|
+
#
|
105
|
+
def test_has_a_run_loop
|
106
|
+
assert{ build_runner.respond_to? :run }
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def build_runner
|
112
|
+
assert{ StableMatch::Runner.new( @set1 , @set2 ) }
|
113
|
+
end
|
114
|
+
|
115
|
+
def build_prepared_runner
|
116
|
+
runner = build_runner
|
117
|
+
assert{ runner.check! }
|
118
|
+
assert{ runner.build! }
|
119
|
+
runner
|
120
|
+
end
|
121
|
+
end
|
metadata
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: stable_match
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Ryan Cook
|
9
|
+
- Ara Howard
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-05-01 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: fattr
|
17
|
+
requirement: &70273026247540 !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ~>
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 2.2.1
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: *70273026247540
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: map
|
28
|
+
requirement: &70273026246260 !ruby/object:Gem::Requirement
|
29
|
+
none: false
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 5.5.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: *70273026246260
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: minitest
|
39
|
+
requirement: &70273026229780 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ~>
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: 2.12.1
|
45
|
+
type: :development
|
46
|
+
prerelease: false
|
47
|
+
version_requirements: *70273026229780
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: rake
|
50
|
+
requirement: &70273026228300 !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ~>
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: 0.9.2.2
|
56
|
+
type: :development
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: *70273026228300
|
59
|
+
- !ruby/object:Gem::Dependency
|
60
|
+
name: rego
|
61
|
+
requirement: &70273026227040 !ruby/object:Gem::Requirement
|
62
|
+
none: false
|
63
|
+
requirements:
|
64
|
+
- - ~>
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: 1.0.0
|
67
|
+
type: :development
|
68
|
+
prerelease: false
|
69
|
+
version_requirements: *70273026227040
|
70
|
+
description: A generic implementation of the stable match algorightm.
|
71
|
+
email:
|
72
|
+
- cookrn@gmail.com
|
73
|
+
- ara.t.howard@gmail.com
|
74
|
+
executables: []
|
75
|
+
extensions: []
|
76
|
+
extra_rdoc_files: []
|
77
|
+
files:
|
78
|
+
- .gitignore
|
79
|
+
- Gemfile
|
80
|
+
- LICENSE
|
81
|
+
- README.md
|
82
|
+
- Rakefile
|
83
|
+
- examples/example_1.rb
|
84
|
+
- lib/stable_match.rb
|
85
|
+
- lib/stable_match/candidate.rb
|
86
|
+
- lib/stable_match/runner.rb
|
87
|
+
- lib/stable_match/version.rb
|
88
|
+
- stable_match.gemspec
|
89
|
+
- test/support/minitest.rb
|
90
|
+
- test/test_helper.rb
|
91
|
+
- test/unit/stable_match/candidate_test.rb
|
92
|
+
- test/unit/stable_match/runner_test.rb
|
93
|
+
homepage: https://github.com/cookrn/stable_match
|
94
|
+
licenses: []
|
95
|
+
post_install_message:
|
96
|
+
rdoc_options: []
|
97
|
+
require_paths:
|
98
|
+
- lib
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
100
|
+
none: false
|
101
|
+
requirements:
|
102
|
+
- - ! '>='
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
segments:
|
106
|
+
- 0
|
107
|
+
hash: 4207718686449127189
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
109
|
+
none: false
|
110
|
+
requirements:
|
111
|
+
- - ! '>='
|
112
|
+
- !ruby/object:Gem::Version
|
113
|
+
version: '0'
|
114
|
+
segments:
|
115
|
+
- 0
|
116
|
+
hash: 4207718686449127189
|
117
|
+
requirements: []
|
118
|
+
rubyforge_project:
|
119
|
+
rubygems_version: 1.8.16
|
120
|
+
signing_key:
|
121
|
+
specification_version: 3
|
122
|
+
summary: stable_match v0.1.0
|
123
|
+
test_files:
|
124
|
+
- test/support/minitest.rb
|
125
|
+
- test/test_helper.rb
|
126
|
+
- test/unit/stable_match/candidate_test.rb
|
127
|
+
- test/unit/stable_match/runner_test.rb
|