mendel 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 773c767d9054373c84771c9ddc4d8d0a0b83b823
4
+ data.tar.gz: a56609df4cc57d0f978206562d6962dd3a673ee2
5
+ SHA512:
6
+ metadata.gz: 6278978743f8c42ac2a3a35bff2dc2e6de520ccd9786e484d7b168b2b0243a30bfbedc75083b490181361f922b7b2705d5108dc2dbef11445f1d8919574256d1
7
+ data.tar.gz: 8c2f2e5514b45ce1f646d3785b0d8aae0dc62d17d4386b35216d32b568a63227b6c7212f86710bc4c435dba3b7abb2ab9e004fd41ccd5507546a9cbdead7e722
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ benchmark/data
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
@@ -0,0 +1 @@
1
+ ruby-2.3.0
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in combiner.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Nathan Long
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.
@@ -0,0 +1,119 @@
1
+ # Mendel
2
+
3
+ Mendel breeds the best combinations of sorted lists that you provide.
4
+
5
+ For example, suppose you have 100 shirts, 200 pairs of pants, and 50 hats, ordered by price. How could you find the 50 cheapest outfits?
6
+
7
+ A brute force approach would build all 1 million possibilities (100 * 200 * 50), sort by price, and take the best 50. An ideal solution would build the best 50 and stop.
8
+
9
+ Mendel gets much closer to the ideal by incrementally building candidates for the "next best" combination and using a priority queue to pull the best one at any given moment.
10
+
11
+ ## How it Works
12
+
13
+ Mendel is easiest to explain for two lists. In that case, we can think of the combinations as a grid, where the X value is from the first list and the Y value is from the second. Inside the grid, we can represent combinations as the sum of the coordinate values.
14
+
15
+ **The lists must be sorted by score**. This means that the sums will increase (or remain constant) along one or both axes.
16
+
17
+ For example, imagine that these grids are landscapes, and the scores in the middle are elevations. **Mendel chooses combinations like a tide, rising from the bottom left.**
18
+
19
+ +---+ +---+ +---+ +---+
20
+ 1|555| 1|567| 3|777| 3|789|
21
+ 1|555| 1|567| 2|666| 2|678|
22
+ 1|555| 1|567| 1|555| 1|567|
23
+ +---+ +---+ +---+ +---+
24
+ 444 456 444 456
25
+
26
+ In every case, we are guaranteed that the bottom left corner - the best item from list Y combined with the best item from list X - has the lowest elevation. Beyond that, the next best combination could be at `0,1` or `1,0`; we don't know. All we can do is check them both and choose the best one. "Check them both" means producing a score, and to "choose the best one", Mendel uses a [priority queue](https://en.wikipedia.org/wiki/Priority_queue).
27
+
28
+ If we find that we've chosen `0,1`, before we return it, we add `1,1` and `0,2` to the queue. We don't know yet whether either of them is better than `1,0`, but next time we need a value, the priority queue will decide. So the water line continues to move up and to the right. Any coordinate "under water" has been returned, any coordinate above the water line has not yet been scored, and any coordinate the water line is just touching is a combination that's currently in the priority queue,
29
+
30
+ Run `rake visualize` to see this process in action.
31
+
32
+ Mendel does the same process for combinations of 3 or more lists, too. Imagining a 6-dimensional graph is beyond the author's cognitive abilities, but in principle, it's the same.
33
+
34
+ ## Usage
35
+
36
+ Create a combiner class that knows how to score combinations of your items. Then provide lists of items, sorted in ascending value.
37
+
38
+ For example:
39
+
40
+ ```ruby
41
+ # Simple lists of numbers. Any combination of these
42
+ # can be scored by adding them together
43
+ list1 = (1..100).to_a
44
+ list2 = (1.0..100.0).to_a
45
+
46
+ class NumericCombiner
47
+ include Mendel::Combiner
48
+ # Scores a combination from the two lists by adding them
49
+ def score_combination(numbers)
50
+ numbers.reduce(0) { |sum, number| sum += number }
51
+ end
52
+ end
53
+
54
+ nc = NumericCombiner.new(list1, list2)
55
+ nc.take(50) # The 50 best combinations
56
+ ```
57
+
58
+ Mendel will return two-item arrays of `[combination, score]`.
59
+
60
+ A combination of items is, by default, an array with one item from each list. However, if you like, you may specify how to build combinations of your items.
61
+
62
+ ```ruby
63
+ defense_players = [{name: 'Jimmy', age: 10}, {name: 'Susan', age: 12}]
64
+ offense_players = [{name: 'Roger', age: 8}, {name: 'Carla', age: 14}]
65
+
66
+ class FoosballTeam
67
+ attr_accessor :players
68
+
69
+ def initialize(*players)
70
+ self.players = players
71
+ end
72
+
73
+ def average_age
74
+ players.reduce(0){ |total, player|
75
+ total += player.fetch(:age)
76
+ } / 2.0
77
+ end
78
+ end
79
+
80
+ class TeamBuilder
81
+ include Mendel::Combiner
82
+
83
+ def build_combination(players)
84
+ FoosballTeam.new(*players)
85
+ end
86
+
87
+ def score_combination(team)
88
+ team.average_age
89
+ end
90
+ end
91
+
92
+ pc = TeamBuilder.new(defense_players, offense_players)
93
+ pc.take(2) # The youngest teams
94
+ ```
95
+
96
+ If you need to apply other criteria besides the score, use lazy enumeration and chain other calls:
97
+
98
+ ```ruby
99
+ pc.each.lazy.reject { |team, score| team.contains_siblings? }.take(50).to_a
100
+ ```
101
+
102
+ ## Serialization and deserialization
103
+
104
+ `Mendel::Combiner` provides the instance methods `#dump` and `#dump_json` and the class methods `.load` and `.load_json`. This allows you to pause enumeration, save the data, and resume enumerating some time later.
105
+
106
+ ## Caveats
107
+
108
+ 1. **Single Enumeration**. For memory's sake, Mendel **does not keep** combinations it has returned to you. Combinations are built and flushed as you enumerate, so if you enumerate twice, there will be no data the second time; you will have to build a new combiner. If you need to keep the combinations, it is up to you to do so.
109
+ 2. **Memory**. Producing ALL combinations of your lists in inherently expensive. Mendel shines at producing the N best. It will allow you to enumerate all of the combinations, but the more there are, the more memory it will need to queue them up. If you want the top 10,000 combinations, you'll probably be fine. If you want the top 10 billion, I hope you have lots of RAM.
110
+
111
+ ## Installation
112
+
113
+ In Bundler:
114
+
115
+ gem 'mendel', git: (this repo address)
116
+
117
+ ## Naming
118
+
119
+ Mendel is named for [Gregor Mendel](https://en.wikipedia.org/wiki/Gregor_Mendel), "the father of modern genetics", a scientist and monk who discovered patterns of inheritance while breeding pea plants. The Mendel gem helps you breed the best possible hybrids of your data.
@@ -0,0 +1,52 @@
1
+ require "bundler/gem_tasks"
2
+ require 'mendel'
3
+ require_relative "benchmark/addition_combiner"
4
+ require 'rspec/core/rake_task'
5
+ Bundler.setup
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task "default" => "spec"
10
+
11
+ desc "Open IRB to experiment with Mendel::Combiner"
12
+ task :console do
13
+ require 'irb'
14
+ require 'irb/completion'
15
+ require_relative 'lib/mendel'
16
+ ARGV.clear
17
+ IRB.start
18
+ end
19
+
20
+ desc "See a visualization of the Mendel::Combiner algorithm"
21
+ task :visualize do
22
+ require 'irb'
23
+ require "mendel/observable_combiner"
24
+ require "mendel/visualizers/ascii"
25
+ class ConsoleCombiner < Mendel::ObservableCombiner
26
+ def score_combination(items)
27
+ items.reduce(0) { |sum, item| sum += item }
28
+ end
29
+ end
30
+
31
+ def clear_screen
32
+ system('clear') or system('cls')
33
+ end
34
+
35
+ def show(limit = nil)
36
+ list1 = 10.times.map { rand(100) }.sort
37
+ list2 = 10.times.map { rand(1.0...100.0) }.sort
38
+ combiner = ConsoleCombiner.new(list1, list2)
39
+ visualizer = Mendel::Visualizers::ASCII.new(combiner)
40
+ combiner.each_with_index do |combo, i|
41
+ break if limit.kind_of?(Numeric) && i > limit
42
+ clear_screen
43
+ puts visualizer.output
44
+ sleep(0.5)
45
+ end; nil
46
+ end
47
+ ARGV.clear
48
+ clear_screen
49
+ puts "Mendel works like rising water, finding the lowest points"
50
+ puts "Type 'show()'. Optionally, pass a max number of frames"
51
+ IRB.start
52
+ end
data/TODO.md ADDED
@@ -0,0 +1,15 @@
1
+ # TODO
2
+
3
+ - Pretty documentation
4
+
5
+ # Probably Not TODO
6
+
7
+ ## Memory Optimization
8
+
9
+ We currently track a set of all seen coordinates. As we build combinations, the size of the set approaches the total number of possible combinations. Although each item in the set is very small (an array of fixnums), the number can grow large.
10
+
11
+ The purpose of the set is to keep from queuing the same coordinates repeatedly (eg, [1,1] could be queued as a child of [1,0] and again as a child of [0,1]). We could save memory by not remembering every coordinate we've seen and rejecting subsequent attempts to score that spot; rather, for this purpose it's probably enough to have the priority queue reject duplicates of what it currently has in it. This would make the set an order of magnitude smaller: instead of having every coordinate in a grid (2D), it would have only the advancing edge (1D), or instead of every coordinate in a cube, only the advancing surface.
12
+
13
+ This only works if we can assume that a child will never be returned before its parent; eg, [1,1] won't be returned before [0,1]; if it were, when [0,1] got returned, [1,1] would be queued again. That assumption, in turn, is only true if we don't have duplicate scores, which we very well might. In that case, if [0,1] and [1,1] are both scored 10, we can make no guarantees about which will be returned first. A workaround is to give to the priority queue a score consisting of the "normal" score PLUS the coordinates; eg, [10, [1,1]]. This guarantees that children have higher scores than parents.
14
+
15
+ A test indicated that this does save memory, but it seems not to be worth the trouble. It makes the code more complicated and, in an upper bound use case for me, consisting of 10 lists of 200 items each and pulling (I think) 40k results, it saved (I think) something like 100MB. So I scrapped it. I record this here only because someone may have a use case where it matters, and an order of magnitude in memory use may be important for them. So: free idea.
@@ -0,0 +1,6 @@
1
+ class AdditionCombiner
2
+ include Mendel::Combiner
3
+ def score_combination(items)
4
+ items.reduce(0) { |sum, item| sum += item }
5
+ end
6
+ end
@@ -0,0 +1,58 @@
1
+ "#{File.expand_path('..',File.dirname(__FILE__))}/lib".tap {|lib_dir|
2
+ $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
3
+ }
4
+
5
+ require 'mendel'
6
+ require 'time'
7
+ require 'benchmark'
8
+ require 'csv'
9
+
10
+ class Mendel::Benchmarker
11
+ attr_accessor :combiner, :chunk_size
12
+ def initialize(combiner, chunk_size)
13
+ self.combiner = combiner
14
+ self.chunk_size = chunk_size
15
+ end
16
+
17
+ # Look! A big ol' procedural script shoved into a method!
18
+ def go!
19
+ column_names = %i[cstime cutime real stime total utime]
20
+
21
+ puts "Benchmarking..."
22
+ stats = []
23
+ $stdout = File.open(File::NULL, 'w')
24
+ Benchmark.bm do |benchmark|
25
+ done = false
26
+ until done do
27
+ # Ensure GC doesn't run during benchmarking
28
+ GC.disable
29
+ bm = benchmark.report do
30
+ chunk = combiner.take(chunk_size)
31
+ done = true if chunk.empty?
32
+ end
33
+ data_point = {queue_length: combiner.queue_length}
34
+ column_names.map {|colname| data_point[colname] = bm.send(colname) }
35
+ stats << data_point
36
+ GC.enable
37
+ end
38
+ end
39
+ $stdout = STDOUT
40
+
41
+ puts "Writing performance data into 'benchmark/data'"
42
+ Dir.chdir('benchmark') do
43
+ Dir.mkdir('data') unless Dir.exist?('data')
44
+ Dir.chdir('data') do
45
+ lengths = combiner.lists.map {|l| l.length.to_s}.join('x')
46
+ filename = "#{lengths}-#{chunk_size}_each"
47
+ CSV.open("#{filename}.csv", "wb") do |csv|
48
+ csv << stats.first.keys
49
+ stats.each do |entry|
50
+ csv << entry.values
51
+ end
52
+ end
53
+ end
54
+ end
55
+ puts "Done!"
56
+
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ data_dir = File.expand_path('data', File.dirname(__FILE__))
2
+ unless Dir.exist?(data_dir)
3
+ puts "No data directory found: #{data_dir}"
4
+ exit
5
+ end
6
+
7
+ require 'gruff'
8
+ require 'csv'
9
+
10
+ Dir.glob("#{data_dir}/*.csv") do |data_file|
11
+ queue_lengths = []
12
+ utimes = []
13
+
14
+ CSV.foreach(data_file, headers: true) do |row|
15
+ utimes << row.fetch('utime').to_f
16
+ queue_lengths << row.fetch('queue_length').to_i
17
+ end
18
+
19
+ base_image_name = data_file.sub('.csv', '')
20
+
21
+ g = Gruff::Line.new
22
+ g.data(:utimes, utimes)
23
+ g.title = "Time per .take()"
24
+ g.write("#{base_image_name}_utimes.png")
25
+
26
+ g = Gruff::Line.new
27
+ g.data(:queue_length, queue_lengths)
28
+ g.title = "Queue Length per .take()"
29
+ g.write("#{base_image_name}_queue_lengths.png")
30
+
31
+
32
+
33
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'benchmarker'
2
+ require_relative 'addition_combiner'
3
+
4
+ chunk_size = ENV.fetch('CS', 25)
5
+ puts "Using chunk size #{chunk_size} - set ENV var CS to change"
6
+
7
+ lists = 6.times.map {
8
+ # More than this eats a ton of memory
9
+ 8.times.map { rand(1.0...1_000.0) }.sort
10
+ }
11
+
12
+ # require 'pry'
13
+ # binding.pry
14
+ # exit
15
+
16
+ benchmarker = Mendel::Benchmarker.new(AdditionCombiner.new(*lists), chunk_size)
17
+ benchmarker.go!
@@ -0,0 +1,14 @@
1
+ require_relative 'benchmarker'
2
+ require_relative 'addition_combiner'
3
+
4
+ list1_length = ENV.fetch('L1', 100)
5
+ list2_length = ENV.fetch('L2', 200)
6
+ chunk_size = ENV.fetch('CS', 10)
7
+ puts "Using list lengths #{list1_length} and #{list2_length} and chunk size #{chunk_size}"
8
+ puts "Set ENV vars L1, L2, and CS to change"
9
+
10
+ list1 = list1_length.times.map { rand(1_000_000) }.sort
11
+ list2 = list2_length.times.map { rand(1.0...1_000_000) }.sort
12
+
13
+ benchmarker = Mendel::Benchmarker.new(AdditionCombiner.new(list1, list2), chunk_size)
14
+ benchmarker.go!
@@ -0,0 +1,34 @@
1
+ "#{File.expand_path('..',File.dirname(__FILE__))}/lib".tap {|lib_dir|
2
+ $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
3
+ }
4
+
5
+ require 'mendel'
6
+ require 'time'
7
+ require_relative 'addition_combiner'
8
+
9
+ list_count = ENV.fetch('LIST_COUNT', 10).to_i
10
+ list_length = ENV.fetch('LIST_LENGTH', 200).to_i
11
+ result_count = ENV.fetch('RESULT_COUNT', 10_000).to_i
12
+
13
+ puts "Pulling #{result_count} results from #{list_count} lists of #{list_length} each"
14
+ puts "You may override with ENV vars LIST_COUNT, LIST_LENGTH, RESULT_COUNT"
15
+
16
+ if result_count >= list_length**list_count
17
+ puts "***(You asked for #{result_count} results, but only #{list_length**list_count} are possible...)"
18
+ end
19
+
20
+ lists = list_count.times.map {
21
+ list_length.times.map { rand(1.0...1_000.0) }.sort
22
+ }
23
+
24
+ puts "Look at the starting memory usage - you have 10 seconds"
25
+ sleep(10)
26
+ puts "about to do the work"
27
+ start = Time.now
28
+ GC.disable
29
+ bc = AdditionCombiner.new(*lists)
30
+ bc.take(result_count)
31
+ fin = Time.now
32
+ puts "Took #{fin - start} seconds to pull #{result_count} combos"
33
+ puts "Look at the final memory usage - you have 10 seconds till exit"
34
+ sleep(10)
@@ -0,0 +1,5 @@
1
+ module Mendel
2
+ end
3
+
4
+ require_relative 'mendel/version'
5
+ require_relative 'mendel/combiner'
@@ -0,0 +1,174 @@
1
+ require "mendel/version"
2
+ require "mendel/min_priority_queue"
3
+ require "observer"
4
+ require "set"
5
+
6
+ module Mendel
7
+
8
+ module Combiner
9
+ include Enumerable
10
+
11
+ attr_accessor :lists, :priority_queue
12
+
13
+ def self.included(target)
14
+ target.extend(ClassMethods)
15
+ end
16
+
17
+ def initialize(*lists)
18
+ raise EmptyList if lists.any?(&:empty?)
19
+ self.lists = lists
20
+ self.priority_queue = MinPriorityQueue.new
21
+ queue_combo_at(lists.map {0} )
22
+ end
23
+
24
+ def each
25
+ return self.to_enum unless block_given?
26
+ loop do
27
+ combo = next_combination
28
+ break if combo == :none
29
+ yield combo
30
+ end
31
+ end
32
+
33
+ def dump
34
+ {INPUT => lists, SEEN => seen_set.to_a, QUEUED => priority_queue.dump }
35
+ end
36
+
37
+ def dump_json
38
+ JSON.dump(dump)
39
+ end
40
+
41
+ def queue_length
42
+ priority_queue.length
43
+ end
44
+
45
+ def score_combination(items)
46
+ raise NotImplementedError,
47
+ <<-MESSAGE
48
+ Including class must define. Must take a combination and produce a score.
49
+ - If you have not defined `build_combination`, `score combination` will receive
50
+ an array of N items (one from each list)
51
+ - If you have defined `build_combination`, `score_combination` will receive
52
+ whatever `build_combination` returns
53
+ MESSAGE
54
+ end
55
+
56
+ private
57
+
58
+ def seen_set
59
+ @seen ||= Set.new
60
+ end
61
+
62
+ def seen_set=(set)
63
+ @seen = set
64
+ end
65
+
66
+ def next_combination
67
+ pair = pop_queue
68
+ return :none if pair.nil?
69
+ data, score = pair
70
+ coordinates = data.fetch(COORDINATES)
71
+ combo = data.fetch(COMBO)
72
+ queue_children_of(coordinates)
73
+ [combo, score]
74
+ end
75
+
76
+ def pop_queue
77
+ priority_queue.pop
78
+ end
79
+
80
+ def queue_children_of(coordinates)
81
+ children_coordinates = next_steps_from(coordinates)
82
+ children_coordinates.each {|cc| queue_combo_at(cc) }
83
+ end
84
+
85
+ def queue_combo_at(coordinates)
86
+ return if seen_set.include?(coordinates)
87
+ seen_set << coordinates
88
+ queue_item = queueable_item_for(coordinates)
89
+ score = queue_item.delete(SCORE)
90
+ priority_queue.push(queue_item, score)
91
+ end
92
+
93
+ def queueable_item_for(coordinates)
94
+ raise InvalidCoordinates, coordinates unless valid_for_lists?(coordinates, lists)
95
+ combo = combo_at(coordinates)
96
+ score = score_combination(combo)
97
+ {COMBO => combo, COORDINATES => coordinates, SCORE => score}
98
+ end
99
+
100
+ def combo_at(coordinates)
101
+ items = lists.each_with_index.map {|list, i| list[coordinates[i]] }
102
+ build_combination(items)
103
+ end
104
+
105
+ def build_combination(items)
106
+ items
107
+ end
108
+
109
+ # Increments which are valid for instance's lists
110
+ def next_steps_from(coordinates)
111
+ increments_from(coordinates).select { |coords| valid_for_lists?(coords, lists) }
112
+ end
113
+
114
+ # All possible coordinates which are one greater than the given
115
+ # coords in a single direction.
116
+ # Eg:
117
+ # increments_from([0,0])
118
+ # #=> [[0,1], [1, 0]]
119
+ # increments_from([10,5,7])
120
+ # => [[11, 5, 7], [10, 6, 7], [10, 5, 8]]
121
+ def increments_from(coordinates)
122
+ coordinates.length.times.map { |i| coordinates.dup.tap { |c| c[i] += 1} }
123
+ end
124
+
125
+ # Do the coordinates represent a valid location given these lists?
126
+ # Eg:
127
+ # valid_for_lists?([0,1], [['thundercats', 'voltron'], ['hi', 'ho']])
128
+ # #=> true - represents ['thundercats', 'ho']
129
+ # valid_for_lists?([0,2], [['thundercats', 'voltron'], ['hi', 'ho']])
130
+ # #=> false - first list has an index 0, but second list has no index 2
131
+ # valid_for_lists?([0,2,0], [['thundercats', 'voltron'], ['hi', 'ho']])
132
+ # #=> false - there are only two lists
133
+ def valid_for_lists?(coords, lists)
134
+ # Must give exactly one index per list
135
+ return false unless coords.length == lists.length
136
+ coords.each_with_index.all? { |value, index| valid_index_in?(lists[index], value) }
137
+ end
138
+
139
+ # Eg:
140
+ # valid_index_in?(['hi', 'ho'], 1) #=> true
141
+ # valid_index_in?(['hi', 'ho'], 2) #=> false
142
+ # valid_index_in?(['hi', 'ho'], -2) #=> true
143
+ # valid_index_in?(['hi', 'ho'], -3) #=> true
144
+ def valid_index_in?(array, index)
145
+ index <= (array.length - 1) && index >= (0 - array.length)
146
+ end
147
+
148
+ # To keep from allocating so many strings
149
+ COMBO = 'combo'.freeze
150
+ COORDINATES = 'coordinates'.freeze
151
+ INPUT = 'input'.freeze
152
+ QUEUED = 'queued'.freeze
153
+ SCORE = 'score'.freeze
154
+ SEEN = 'seen'.freeze
155
+
156
+ module ClassMethods
157
+ def load(data)
158
+ instance = new(*data.fetch(INPUT))
159
+ instance.instance_eval {
160
+ self.seen_set = Set.new(data.fetch(SEEN))
161
+ self.priority_queue = MinPriorityQueue.new.tap {|q| q.load(data.fetch(QUEUED))}
162
+ }
163
+ instance
164
+ end
165
+
166
+ def load_json(json)
167
+ self.load(JSON.parse(json))
168
+ end
169
+ end
170
+
171
+ InvalidCoordinates = Class.new(StandardError)
172
+ EmptyList = Class.new(StandardError)
173
+ end
174
+ end