stable_match 0.1.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.
- 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
|