matchy_matchy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,58 @@
1
+ module MatchyMatchy
2
+ # A sorted list of matches with a strict capacity.
3
+ class MatchList
4
+ include Enumerable
5
+
6
+ # Initializes the list.
7
+ #
8
+ # @param capacity [Integer] The maximum number of matches this list can hold
9
+ def initialize(capacity = 1)
10
+ @matches = []
11
+ @capacity = capacity
12
+ end
13
+
14
+ # Pushes a match into the list.
15
+ # The list is re-sorted and any matches that don’t fit are rejected.
16
+ #
17
+ # @param match [MatchyMatchy::Match]
18
+ # @return [MatchyMatchy::MatchList] Self
19
+ def <<(match)
20
+ if include?(match)
21
+ match.reject!
22
+ else
23
+ @matches << match
24
+ @matches.sort!
25
+ @matches.pop.reject! if @matches.size > @capacity
26
+ end
27
+ self
28
+ end
29
+
30
+ # Returns true if the list contains the given match.
31
+ #
32
+ # @param match [MatchyMatchy::Match]
33
+ # @return [Boolean] True if the list contains this match already.
34
+ def include?(match)
35
+ any? { |m| m.eql?(match) }
36
+ end
37
+
38
+ # Returns an enumerator for the matches in the list.
39
+ # If a block is given, iterates through the matches in order, yielding them
40
+ # to the block.
41
+ #
42
+ # @yield [MatchyMatchy::Match]
43
+ # @return Enumerator
44
+ def each(&block)
45
+ return enum_for(:each) unless block_given?
46
+ to_a.each(&block)
47
+ end
48
+
49
+ # Returns an array of the matches in the list.
50
+ #
51
+ # @return [Array]
52
+ def to_a
53
+ @matches.dup.freeze
54
+ end
55
+
56
+ alias to_ary to_a
57
+ end
58
+ end
@@ -0,0 +1,53 @@
1
+ module MatchyMatchy
2
+ # An object representing the results of the stable match algorithm.
3
+ class MatchResults
4
+ # A list of match targets.
5
+ attr_reader :targets
6
+
7
+ # A list of match candidates.
8
+ attr_reader :candidates
9
+
10
+ # Initializes the match results with an empty list of matches.
11
+ #
12
+ # @param targets [Array<MatchyMatchy::Target>] Array of all possible targets
13
+ # @param candidates [Array<MatchyMatchy::Candidate>
14
+ # Array of all possible candidates
15
+ def initialize(targets:, candidates:)
16
+ @targets = targets
17
+ @candidates = candidates
18
+ @matches = targets.map { |t| [t, MatchList.new(t.capacity)] }.to_h
19
+ end
20
+
21
+ # Adds a match to the results.
22
+ #
23
+ # @param match [MatchyMatchy::Match] A match to add
24
+ # @return [MatchyMatchy::MatchResults] Self
25
+ def <<(match)
26
+ @matches[match.target] << match
27
+ self
28
+ end
29
+
30
+ # Returns a hash where the keys are the targets in the match, and the
31
+ # values are an ordered list of candidates for each target (if any).
32
+ # Targets are included even if no candidates could be matched there.
33
+ #
34
+ # @return [Hash<MatchyMatchy::Target, Array<MatchyMatchy::Candidate>>]
35
+ def by_target
36
+ targets.
37
+ map { |t| [t.object, @matches[t].map { |m| m.candidate.object }] }.
38
+ to_h.
39
+ freeze
40
+ end
41
+
42
+ # Returns a hash where the keys are the candidates in the match, and the
43
+ # values are the targets selected for each candidate (or nil, if the
44
+ # algorithm was unable to place the candidate).
45
+ #
46
+ # @return [Hash<MatchyMatchy::Target, Array<MatchyMatchy::Candidate>>]
47
+ def by_candidate
48
+ placements =
49
+ by_target.to_a.flat_map { |t, cs| cs.map { |c| [c, t] } }.to_h
50
+ candidates.map { |c| [c.object, placements[c.object]] }.to_h.freeze
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,92 @@
1
+ module MatchyMatchy
2
+ # Small class to maintain a cache of candidates and targets, for use by the
3
+ # +MatchMaker+. Ensures the following conditions:
4
+ #
5
+ # * Candidates and targets are kept separate, even if the objects they wrap
6
+ # are identical
7
+ # * Exactly one +Candidate+ or +Target+ instance is created for each object
8
+ # considered by the +MatchMaker+
9
+ # * Objects are not double-wrapped
10
+ class Matchbook
11
+ # Initializes the Matchbook with an empty cache.
12
+ def initialize
13
+ @targets = {}
14
+ @candidates = {}
15
+ end
16
+
17
+ # Builds a list of targets for the +MatchMaker+.
18
+ # The parameter is a hash of unwrapped target objects to their preferred
19
+ # candidates and maximum capacity (a 2-tuple expressed as an array):
20
+ #
21
+ # @example With explicit capacities
22
+ # matchbook.build_targets(
23
+ # 'Gryffindor' => [['Hermione', 'Ron', 'Harry'], 2],
24
+ # 'Ravenclaw' => [['Hermione'], 1],
25
+ # 'Hufflepuff' => [['Neville', 'Hermione'], 2],
26
+ # 'Slytherin' => [['Harry', 'Hermione', 'Ron', 'Neville'], 4]
27
+ # )
28
+ #
29
+ # The capacity may also be omitted, in which case it defaults to 1:
30
+ #
31
+ # @example With explicit capacities
32
+ # matchbook.build_targets(
33
+ # 'Gryffindor' => ['Hermione', 'Ron', 'Harry'],
34
+ # 'Ravenclaw' => ['Hermione'],
35
+ # 'Hufflepuff' => ['Neville', 'Hermione'],
36
+ # 'Slytherin' => ['Harry', 'Hermione', 'Ron', 'Neville'],
37
+ # )
38
+ #
39
+ # @param targets [Hash<Object, Array<Object>>]
40
+ # Hash of target objects to a list of their preferred candidates
41
+ def build_targets(targets)
42
+ targets.to_a.map do |object, row|
43
+ preferences, capacity = parse_target_row(row)
44
+ target(object).tap do |t|
45
+ t.capacity = capacity
46
+ t.prefer(*preferences.map { |c| candidate(c) })
47
+ end
48
+ end
49
+ end
50
+
51
+ # Builds a list of candidates for the +MatchMaker+.
52
+ # The parameter is a hash of unwrapped canditate objects to their preferred
53
+ # targets.
54
+ #
55
+ # @example
56
+ # matchbook.build_candidates(
57
+ # 'Harry' => ['Gryffindor', 'Slytherin'],
58
+ # 'Hermione' => ['Ravenclaw', 'Gryffindor'],
59
+ # 'Ron' => ['Gryffindor'],
60
+ # 'Neville' => ['Hufflepuff', 'Gryffindor', 'Ravenclaw', 'Slytherin']
61
+ # )
62
+ #
63
+ # @param candidates [Hash<Object, Array<Object>>]
64
+ # Hash of candidate objects to a list of their preferred targets
65
+ def build_candidates(candidates)
66
+ candidates.to_a.map do |object, preferences|
67
+ candidate(object).tap do |c|
68
+ c.prefer(*preferences.map { |t| target(t) })
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def target(object)
76
+ return object if object.is_a?(Target)
77
+ @targets[object] ||= Target.new(object)
78
+ end
79
+
80
+ def candidate(object)
81
+ return object if object.is_a?(Candidate)
82
+ @candidates[object] ||= Candidate.new(object)
83
+ end
84
+
85
+ def parse_target_row(row)
86
+ return row if row.size == 2 &&
87
+ row.first.respond_to?(:map) &&
88
+ row.last.respond_to?(:zero?)
89
+ [row, 1]
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,90 @@
1
+ module MatchyMatchy
2
+ # Implements the Stable Match algorithm ovver a given set of candidates and
3
+ # targets.
4
+ class MatchMaker
5
+ # Initializes the MatchMaker.
6
+ # You must specify a list of +targets+ (objects “offering” places) and
7
+ # +candidates+ (objects “seeking” places). These can be any type of object:
8
+ # they are wrapped internally, but you should never have to deal with that.
9
+ #
10
+ # @example
11
+ # match_maker = MatchyMatchy::MatchMaker.new(
12
+ # targets: {
13
+ # 'Gryffindor' => [['Hermione', 'Ron', 'Harry'], 2],
14
+ # 'Ravenclaw' => [['Hermione'], 1],
15
+ # 'Hufflepuff' => [['Neville', 'Hermione'], 2],
16
+ # 'Slytherin' => [['Harry', 'Hermione', 'Ron', 'Neville'], 4]
17
+ # },
18
+ # candidates: {
19
+ # 'Harry' => ['Gryffindor', 'Slytherin'],
20
+ # 'Hermione' => ['Ravenclaw', 'Gryffindor'],
21
+ # 'Ron' => ['Gryffindor'],
22
+ # 'Neville' => ['Hufflepuff', 'Gryffindor', 'Ravenclaw', 'Slytherin']
23
+ # }
24
+ # )
25
+ #
26
+ # The +target+ and +candidate+ parameters look similar, but the values in
27
+ # the +target+ hash have an extra number, which is the maximum capacity of
28
+ # that target (the total number of candidates it can accept). The capacity
29
+ # may also be omitted, in which case it defaults to 1; that is:
30
+ #
31
+ # {
32
+ # 'Gryffindor' => ['Hermione', 'Ron', 'Harry'],
33
+ # 'Ravenclaw' => ['Hermione'],
34
+ # 'Hufflepuff' => ['Neville', 'Hermione'],
35
+ # 'Slytherin' => ['Harry', 'Hermione', 'Ron', 'Neville'],
36
+ # }
37
+ #
38
+ # ...is equivalent to:
39
+ #
40
+ # {
41
+ # 'Gryffindor' => [['Hermione', 'Ron', 'Harry'], 1],
42
+ # 'Ravenclaw' => [['Hermione'], 1],
43
+ # 'Hufflepuff' => [['Neville', 'Hermione'], 1],
44
+ # 'Slytherin' => [['Harry', 'Hermione', 'Ron', 'Neville'], 1]
45
+ # }
46
+ def initialize(targets:, candidates:)
47
+ @matchbook = Matchbook.new
48
+ @targets = matchbook.build_targets(targets)
49
+ @candidates = matchbook.build_candidates(candidates)
50
+ @matches = MatchResults.new(targets: @targets, candidates: @candidates)
51
+ end
52
+
53
+ # Kicks off the match.
54
+ # Iterates through the candidates in order, proposing each to their
55
+ # first-choice target. If this match is rejected, the candidate is proposed
56
+ # to their next choice of candidate (if any).
57
+ #
58
+ # The running time of the algorithm is proportional to the number of
59
+ # candidates and targets in the system, but is guaranteed to finish and
60
+ # yield the same result for the same input.
61
+ #
62
+ # @return [MatchyMatchy::MatchResults] A +MatchResults+ object representing
63
+ # the outcome of the algorithm.
64
+ def perform
65
+ candidates.each { |candidate| propose(candidate) }
66
+ @matches
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :matchbook, :targets, :candidates
72
+
73
+ def propose(candidate, index = 0)
74
+ return unless index < candidate.preferences.size
75
+
76
+ proposed = match(candidate, index)
77
+ if proposed.mutual?
78
+ @matches << proposed
79
+ else
80
+ proposed.reject!
81
+ end
82
+ end
83
+
84
+ def match(candidate, index)
85
+ Match.
86
+ new(candidate: candidate, index: index).
87
+ on(:reject) { propose(candidate, index + 1) }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,17 @@
1
+ module MatchyMatchy
2
+ # Represents a target in the Stable Match algorithm.
3
+ # A target is largely the same as a candidate, with the addition of the
4
+ # concept of “capacity”: how many candidates the target is willing to accept.
5
+ class Target < Candidate
6
+ attr_accessor :capacity
7
+
8
+ # Initializes the target with an object to wrap, and a capacity.
9
+ #
10
+ # @param object [Object] The object represented by this target
11
+ # @param capacity [Integer] The target’s maximum capacity
12
+ def initialize(object, capacity: 1)
13
+ super(object)
14
+ @capacity = capacity
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ module MatchyMatchy
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'matchy_matchy/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'matchy_matchy'
7
+ spec.version = MatchyMatchy::VERSION
8
+ spec.authors = ['Matt Powell']
9
+ spec.email = ['fauxparse@gmail.com']
10
+
11
+ spec.summary = 'A cute Stable Match implementation'
12
+ spec.homepage = 'https://github.com/fauxparse/matchy_matchy'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = 'exe'
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'cry', '~> 0.1'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.16.a'
25
+ spec.add_development_dependency 'rake', '~> 10.0'
26
+ spec.add_development_dependency 'rspec', '~> 3.0'
27
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4.1'
28
+ spec.add_development_dependency 'simplecov'
29
+ end
metadata ADDED
@@ -0,0 +1,151 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: matchy_matchy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Powell
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-09-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: cry
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.16.a
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.16.a
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec_junit_formatter
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.4.1
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.4.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description:
98
+ email:
99
+ - fauxparse@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".circleci/config.yml"
105
+ - ".gitignore"
106
+ - ".rspec"
107
+ - ".rubocop.yml"
108
+ - ".rubocop_todo.yml"
109
+ - CODE_OF_CONDUCT.md
110
+ - Gemfile
111
+ - Gemfile.lock
112
+ - LICENSE.txt
113
+ - README.md
114
+ - Rakefile
115
+ - bin/console
116
+ - bin/setup
117
+ - lib/matchy_matchy.rb
118
+ - lib/matchy_matchy/candidate.rb
119
+ - lib/matchy_matchy/match.rb
120
+ - lib/matchy_matchy/match_list.rb
121
+ - lib/matchy_matchy/match_results.rb
122
+ - lib/matchy_matchy/matchbook.rb
123
+ - lib/matchy_matchy/matchmaker.rb
124
+ - lib/matchy_matchy/target.rb
125
+ - lib/matchy_matchy/version.rb
126
+ - matchy_matchy.gemspec
127
+ homepage: https://github.com/fauxparse/matchy_matchy
128
+ licenses:
129
+ - MIT
130
+ metadata: {}
131
+ post_install_message:
132
+ rdoc_options: []
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubyforge_project:
147
+ rubygems_version: 2.5.2.3
148
+ signing_key:
149
+ specification_version: 4
150
+ summary: A cute Stable Match implementation
151
+ test_files: []