matchy_matchy 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.
@@ -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: []