connected 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3207847fee29e8a23ed1a8456571a8e88ab3e73c3d2df63d8cadad3b34b6382f
4
+ data.tar.gz: 6c9944da0f906bb91ad8add7ae63dfed303452ac8f4f6b228b8e769cdb69de96
5
+ SHA512:
6
+ metadata.gz: 12ba3ef422355e9b9cc608a29c90579af5e4adee228e94bc4348de44e595c8e673c9ec12cc319143a28b6e39d26807ede22232edfe12756feed08a21a4768f4c
7
+ data.tar.gz: 4ab7b7e541f3171dbd841980f0d63d0ad83a0cf18504d6d11b2a8e499ba70f4738f0ad093737b10e38af485f055f10268f9152c32a2ba111c23abbc2adacdb84
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
Binary file
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,44 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Metrics/MethodLength:
5
+ Max: 30
6
+
7
+ Layout/LineLength:
8
+ Max: 100
9
+
10
+ Metrics/ClassLength:
11
+ Max: 150
12
+
13
+ Metrics/ModuleLength:
14
+ Max: 150
15
+
16
+ Metrics/CyclomaticComplexity:
17
+ Max: 7
18
+
19
+ Metrics/AbcSize:
20
+ Max: 25
21
+
22
+ Metrics/PerceivedComplexity:
23
+ Max: 8
24
+ Exclude:
25
+ - 'spec/**/*_spec.rb'
26
+
27
+ Metrics/BlockLength:
28
+ Max: 35
29
+ Exclude:
30
+ - '*.gemspec'
31
+ - Rakefile
32
+ - 'spec/**/*_spec.rb'
33
+ - 'spec/spec_helper.rb'
34
+
35
+ Metrics/ParameterLists:
36
+ Max: 6
37
+
38
+ Naming/MethodParameterName:
39
+ Exclude:
40
+ - 'spec/**/*.rb'
41
+
42
+ Security/Eval:
43
+ Exclude:
44
+ - Gemfile
@@ -0,0 +1,14 @@
1
+ language: ruby
2
+ cache: bundler
3
+ rvm:
4
+ - 2.6.6
5
+ before_install: gem install bundler -v 2.1.4
6
+ deploy:
7
+ provider: rubygems
8
+ api_key:
9
+ secure: nCpiK6ht0RMKBZa5nX3o1YXKbgL6+DdXQYjszMUrt1+i7edVLqg9Y6XOXfsY8r3zI9CiX0dbrq9w9uF7ELLBkPiV7bhzvshmkGMQuc5MvC/FvHTzizkeJj7L85Z8impkF+cQXFKyKOptlUOlud9SJQZXk86DEHYI3Rg1GJklZ6AAVZQ5ixz/TBUhrMJPGXZEaN+tErio17CVZ38xgp/CwkIsV3MhRMaruGOidIXPQXJCOtElD+BUOIZ9pU0yJxJlbTucCtcWyO1wa7B9HbxodEv7O/kDRj8+rWESSHg18T8otS7vVyDZYUeVbSNVULQM3epCDvwVQZiMyBphpNaNOf9p9SCH2i11L+HWomOEdcZNy+8BwRbIiU/vWgbm2QI3XQxnQxwg48eIR3xjwWcsGwZFIP9DGykuF7fP+IgrBJ3pPb1tORW8FwTB6adMaSdIYKRvt5D+mm3DUzrMDHHN6Nc3hGGQkNc9Tya2PvwBx/bdjPK/L9nYBhsTEao3DyfFLXDU1zGwuWcmQHjd6qStgD21AgbL8qvhLbAP5tVeYx6gKeONcHtoCLf6XP/UN5RyabBCVNBYymHQLobgAw/Ozthj+b0n5ke2CkIOyRB4pgk/nee4UChMikRscG5CHvj3pBrhFGWRHxUOMvHSGtOswuclDqs8v20Vm/BGrcJEsaU=
10
+ gem: connected
11
+ on:
12
+ tags: true
13
+ repo: jgnagy/connected
14
+ skip_cleanup: 'true'
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in spf.gemspec
6
+ gemspec
@@ -0,0 +1,67 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ connected (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.0)
10
+ diff-lcs (1.3)
11
+ docile (1.3.2)
12
+ parallel (1.19.1)
13
+ parser (2.7.1.3)
14
+ ast (~> 2.4.0)
15
+ rainbow (3.0.0)
16
+ rake (13.0.1)
17
+ regexp_parser (1.7.0)
18
+ rexml (3.2.4)
19
+ rspec (3.9.0)
20
+ rspec-core (~> 3.9.0)
21
+ rspec-expectations (~> 3.9.0)
22
+ rspec-mocks (~> 3.9.0)
23
+ rspec-core (3.9.2)
24
+ rspec-support (~> 3.9.3)
25
+ rspec-expectations (3.9.2)
26
+ diff-lcs (>= 1.2.0, < 2.0)
27
+ rspec-support (~> 3.9.0)
28
+ rspec-mocks (3.9.1)
29
+ diff-lcs (>= 1.2.0, < 2.0)
30
+ rspec-support (~> 3.9.0)
31
+ rspec-support (3.9.3)
32
+ rubocop (0.85.0)
33
+ parallel (~> 1.10)
34
+ parser (>= 2.7.0.1)
35
+ rainbow (>= 2.2.2, < 4.0)
36
+ regexp_parser (>= 1.7)
37
+ rexml
38
+ rubocop-ast (>= 0.0.3)
39
+ ruby-progressbar (~> 1.7)
40
+ unicode-display_width (>= 1.4.0, < 2.0)
41
+ rubocop-ast (0.0.3)
42
+ parser (>= 2.7.0.1)
43
+ ruby-progressbar (1.10.1)
44
+ simplecov (0.18.5)
45
+ docile (~> 1.1)
46
+ simplecov-html (~> 0.11)
47
+ simplecov-cobertura (1.3.1)
48
+ simplecov (~> 0.8)
49
+ simplecov-html (0.12.2)
50
+ unicode-display_width (1.7.0)
51
+ yard (0.9.25)
52
+
53
+ PLATFORMS
54
+ ruby
55
+
56
+ DEPENDENCIES
57
+ bundler (~> 2)
58
+ connected!
59
+ rake (~> 13)
60
+ rspec (~> 3.0)
61
+ rubocop (~> 0.50)
62
+ simplecov (~> 0.15)
63
+ simplecov-cobertura (~> 1.3)
64
+ yard (~> 0.9)
65
+
66
+ BUNDLED WITH
67
+ 2.1.4
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Jonathan Gnagy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,338 @@
1
+ <img src=".images/logo.png" alt="Connected logo" title="Connected" align="right" height="40" />
2
+
3
+ # Connected
4
+
5
+ _Connected_ aims to be useful for overlaying and solving both directed and undirected graphs. The goal is to provide generic mixins to add a solver to real code, rather than an academic demonstration of how Dijkstra's algorithm works.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'connected'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install connected
22
+
23
+ ## Usage
24
+
25
+ ### Simple Uses
26
+
27
+ For a simpler use-cases, the classes `Connected::GenericNode`, `Connected::GenericConnection`, and `Connected::Path` will suffice. These can either be subclassed or extended to wrap any real work. First, I'll just demonstrate how they work with an undirected graph (meaning a set of bidirectional connections):
28
+
29
+ ```ruby
30
+ require 'connected'
31
+
32
+ include Connected
33
+
34
+ # First, let's make some nodes
35
+ node_a = GenericNode.new('a')
36
+ node_b = GenericNode.new('b')
37
+ node_c = GenericNode.new('c')
38
+ node_d = GenericNode.new('d')
39
+
40
+ # Now we can create connections between some of them
41
+ node_a.connects_to node_b, metric: 1
42
+ node_a.connects_to node_c, metric: 10
43
+ node_b.connects_to node_c, metric: 3
44
+ node_b.connects_to node_d, metric: 2, state: :closed
45
+ node_c.connects_to node_d, metric: 3
46
+ ```
47
+
48
+ The above represents a graph like this in code:
49
+
50
+ ```text
51
+ (a)_(b) _ _ _ _ (d)
52
+ \____\___(c)___/
53
+ ```
54
+
55
+ Based on this, the fastest (lowest cost) route from node "a" to node "d" should be "a" to "b" to "c" to "d" with a total cost of `7`. Let's find that in code:
56
+
57
+ ```ruby
58
+ path = Path.find(from: node_a, to: node_d)
59
+ path.to_s
60
+ # => "a -> b -> c -> d"
61
+ path.cost
62
+ # => 7
63
+ ```
64
+
65
+ We can also find paths based on least hops rather than lowest metric cost by first getting all "reasonable" routes (meaning as it finds candidates, it rejects those that have too many hops or too high of a metric):
66
+
67
+ ```ruby
68
+ candidate_paths = Path.all(from: node_a, to: node_d)
69
+ candidate_paths.size
70
+ # => 2
71
+ candidate_paths.min_by(&:hops).to_s
72
+ # => "a -> c -> d"
73
+ ```
74
+
75
+ We can simulate or ignore when paths are closed as well:
76
+
77
+ ```ruby
78
+ path = Path.find(from: node_a, to: node_d, include_closed: true)
79
+ path.to_s
80
+ # => "a -> b -> d"
81
+ path.cost
82
+ # => 3
83
+ ```
84
+
85
+ This behavior of excluding suboptimal routes from `Path.all()` might be confusing if you're really looking for **every** possible route. In this case, you can add `suboptimal: true` and it'll really give you every possible route, sorted from lowest to highest cost.
86
+
87
+ ```ruby
88
+ every_path = Path.all(from: node_a, to: node_d, suboptimal: true)
89
+ every_path.size
90
+ # => 2
91
+ every_path_even_closed = Path.all(
92
+ from: node_a,
93
+ to: node_d,
94
+ suboptimal: true,
95
+ include_closed: true
96
+ )
97
+ every_path_even_closed.size
98
+ # => 4
99
+ every_path_even_closed.map(&:to_s)
100
+ # => ["a -> b -> d", "a -> b -> c -> d", "a -> c -> d", "a -> c -> b -> d"]
101
+ ```
102
+
103
+ It is possible to manipulate connections after they've been defined. To retrieve the `GenericConnection` object, use `#connection_to` on the source node:
104
+
105
+ ```ruby
106
+ connection = node_b.connection_to(node_d)
107
+ connection.state
108
+ # => :closed
109
+ connection.metric
110
+ # => 2
111
+ connection.state = :open
112
+
113
+ # Now we can see that our previous simulation is right
114
+ Path.find(from: node_a, to: node_d).to_s
115
+ # => "a -> b -> d"
116
+ ```
117
+
118
+ All the above examples are based on connections created using `node.connects_to other_node`, which creates a bi-directional connection. For representing _directed_ graph, you can add `directed: true` to the method which causes it to only create the connection you explicitly described (meaning it won't create the connection back for you).
119
+
120
+ ### Overlaying on Other Objects
121
+
122
+ Nearly all the above capabilities are available via modules that can be mixed into your own objects. Just like adding `include Comparable` to your own code requires the `<=>` method be defined on your Object, mixing in the Connected modules require a few methods be available on instances.
123
+
124
+ Mixing in `Connected::Vertex` into your node-like classes requires the `#connections` method be implemented (either directly or as a readable attribute). This method is used to find all connections/`Edge` objects associated with (i.e., with the `from` set to) the object in question. No sorting is necessary; it just needs to provide an Array (or Array-like collection) of instances with `Connected::Edge` mixed in. These connections don't even need to exist outside of the `#connections` method call; they can be constructed and yielded on demand.
125
+
126
+ Connections between Vertices (called _edges_) mix in the `Connected::Edge` module and require `#from` and `#to` be implemented. In most cases, you'll also want to implement `#metric` (otherwise it'll always be `1`) and either `#state` (otherwise it'll always be `:open`) or both `#open?` and `#closed?`. The default implementations of `#open?` and `#closed?` check if `#state` is `:open` or `:closed`, respectively.
127
+
128
+ Let's build a simple example network and implement a lightweight version of [OSPF](https://en.wikipedia.org/wiki/Open_Shortest_Path_First) (we won't actually implement the _protocol_, just the concept of finding the shortest open path). This is going to be a decent chunk of code, but it is worth reading over. First, let's build our classes:
129
+
130
+ ```ruby
131
+ require 'connected'
132
+
133
+ class Subnet
134
+ attr_reader :links
135
+
136
+ def initialize(block:)
137
+ @block = block # needs to be in CIDR notation <= /24
138
+ @links = [] # Here we'll store all the links to this subnet
139
+ end
140
+
141
+ def size
142
+ bits = 32 - @block.split('/').last.to_i
143
+ 2**bits
144
+ end
145
+
146
+ def first_three
147
+ @block.split('.').first(3).join('.')
148
+ end
149
+
150
+ def assign_ip(link, ip: nil)
151
+ all_ips = (1..(size - 2)).to_a.map { |ip| [first_three, ip].join('.') }
152
+ used_ips = @links.map(&:ip)
153
+
154
+ new_ip = if ip
155
+ raise 'Invalid IP' unless all_ips.include?(ip)
156
+
157
+ raise 'Duplicate IP' if used_ips.include?(ip)
158
+
159
+ ip
160
+ else
161
+ (all_ips - used_ips).sample # picks a random available IP
162
+ end
163
+
164
+ @links << link unless @links.include?(link)
165
+
166
+ new_ip
167
+ end
168
+ end
169
+
170
+ class Link
171
+ attr_reader :device, :subnet
172
+ attr_accessor :speed, :state
173
+
174
+ def initialize(device:, subnet:, speed: 1000, state: :up)
175
+ @device = device
176
+ @subnet = subnet
177
+ @speed = speed
178
+ @state = state
179
+ @ip = nil
180
+ end
181
+
182
+ def ip
183
+ @ip ||= subnet.assign_ip(self)
184
+ end
185
+
186
+ def ip=(value)
187
+ return true if @ip == value
188
+
189
+ subnet.assign_ip(self, ip: value) # will raise an error if duplicate
190
+ @ip = value
191
+ end
192
+ end
193
+
194
+ class Router
195
+ include Connected::Vertex
196
+
197
+ attr_reader :name, :links
198
+
199
+ def initialize(name:)
200
+ @name = name
201
+ @links = []
202
+ end
203
+
204
+ def add_link(subnet, ip: nil, speed: 1000, state: :up)
205
+ return false if subnets.include?(subnet) # only one connection per subnet
206
+
207
+ link = Link.new(device: self, subnet: subnet, speed: speed, state: state)
208
+ if ip
209
+ link.ip = ip
210
+ else
211
+ link.ip # let the link request its own IP
212
+ end
213
+
214
+ @links << link
215
+ link
216
+ end
217
+
218
+ def subnets
219
+ links.map(&:subnet)
220
+ end
221
+
222
+ def ips
223
+ links.map(&:ip)
224
+ end
225
+
226
+ # Here's the method required by Connected
227
+ def connections
228
+ # Dynamically build a collection of Adjacencies
229
+ links.map do |link|
230
+ link.subnet.links.reject { |l| l == link }.map do |other_link|
231
+ Adjacency.new(from: self, to: other_link.device, links: [link, other_link])
232
+ end
233
+ end.flatten
234
+ end
235
+ end
236
+
237
+ class Adjacency
238
+ include Connected::Edge
239
+
240
+ attr_reader :from, :to
241
+
242
+ def initialize(from:, to:, links:)
243
+ @from = from
244
+ @to = to
245
+ @links = links
246
+ end
247
+
248
+ def state
249
+ # either both up or the adjacency is "closed"
250
+ @links.map(&:state).uniq == [:up] ? :open : :closed
251
+ end
252
+
253
+ # Lower metrics are better, so take the lowest speed connection and
254
+ # use the inverse
255
+ def metric
256
+ 1.0 / @links.map(&:speed).min
257
+ end
258
+ end
259
+
260
+ class Route < Connected::Path
261
+ end
262
+ ```
263
+
264
+ Now we can setup some routers and attach them to some subnets (and let our fake DHCP assign them IPs):
265
+
266
+ ```ruby
267
+ sub1 = Subnet.new(block: '192.168.1.0/24')
268
+ sub2 = Subnet.new(block: '192.168.2.0/24')
269
+ sub3 = Subnet.new(block: '192.168.3.0/27')
270
+
271
+ bob = Router.new(name: 'Bob')
272
+ alice = Router.new(name: 'Alice')
273
+ joe = Router.new(name: 'Joe')
274
+ jim = Router.new(name: 'Jim')
275
+ lonely = Router.new(name: 'Lonely')
276
+
277
+ bob.add_link(sub1)
278
+ bob.add_link(sub3, speed: 100)
279
+ alice.add_link(sub1)
280
+ alice.add_link(sub2)
281
+ joe.add_link(sub2)
282
+ joe.add_link(sub3)
283
+ jim.add_link(sub1, speed: 10_000)
284
+ jim.add_link(sub3, speed: 10_000)
285
+ lonely.add_link(sub3, speed: 5_000)
286
+
287
+ bob.ips
288
+ # => ["192.168.1.25", "192.168.3.8"]
289
+ alice.ips
290
+ # => ["192.168.1.155", "192.168.2.11"]
291
+ joe.ips
292
+ # => ["192.168.2.123", "192.168.3.17"]
293
+ jim.ips
294
+ # => ["192.168.1.174", "192.168.3.6"]
295
+ lonely.ips
296
+ # => ["192.168.3.2"]
297
+
298
+ # Bob has a direct connection to everyone
299
+ bob.neighbors.map(&:name)
300
+ # => ["Alice", "Jim", "Joe", "Lonely"]
301
+
302
+ # Alice can't _directly_ connect to Lonely
303
+ alice.neighbors.map(&:name)
304
+ # => ["Bob", "Jim", "Joe"]
305
+ ```
306
+
307
+ Now let's take a look at how routing looks:
308
+
309
+ ```ruby
310
+ # Even though Bob is directly connected to subnet 3 (where Lonely is),
311
+ # it is faster to get there through Jim
312
+ Route.find(from: bob, to: lonely).to_s
313
+ # => "Bob -> Jim -> Lonely"
314
+
315
+ # Bob uses his direct connection to talk to Alice
316
+ Route.find(from: bob, to: alice).to_s
317
+ # => "Bob -> Alice"
318
+
319
+ # Alice also goes through Jim to get to Lonely
320
+ Route.find(from: alice, to: lonely).to_s
321
+ # => "Alice -> Jim -> Lonely"
322
+ ```
323
+
324
+ That all works! Links themselves (and their states) were memoized but the adjacencies were constructed and used dynamically. Notice that while there was a fair amout of setup/code there, very little was associated with comparing connections and the `Route` class is entirely empty!
325
+
326
+ ## Development
327
+
328
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
329
+
330
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
331
+
332
+ ## Contributing
333
+
334
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jgnagy/connected.
335
+
336
+ ## License
337
+
338
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+ require 'yard'
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new(:rubocop)
10
+ YARD::Rake::YardocTask.new do |y|
11
+ y.options = [
12
+ '--markup', 'markdown'
13
+ ]
14
+ end
15
+
16
+ task default: %i[spec rubocop yard]
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'connected'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/connected/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'connected'
7
+ spec.version = Connected::VERSION
8
+ spec.authors = ['Jonathan Gnagy']
9
+ spec.email = ['jonathan.gnagy@gmail.com']
10
+
11
+ spec.summary = 'A shortest path first gem'
12
+ spec.description = 'A Ruby object-oriented solver for directed and undirected ' \
13
+ 'graphs based loosely on Dijkstra\'s algorithm'
14
+ spec.homepage = 'https://github.com/jgnagy/connected'
15
+
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
23
+ end
24
+ spec.bindir = 'exe'
25
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ['lib']
27
+
28
+ spec.add_development_dependency 'bundler', '~> 2'
29
+ spec.add_development_dependency 'rake', '~> 13'
30
+ spec.add_development_dependency 'rspec', '~> 3.0'
31
+ spec.add_development_dependency 'rubocop', '~> 0.50'
32
+ spec.add_development_dependency 'simplecov', '~> 0.15'
33
+ spec.add_development_dependency 'simplecov-cobertura', '~> 1.3'
34
+ spec.add_development_dependency 'yard', '~> 0.9'
35
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'connected/version'
4
+ require 'connected/edge'
5
+ require 'connected/vertex'
6
+ require 'connected/generic_node'
7
+ require 'connected/generic_connection'
8
+ require 'connected/path'
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ # Used to mixin a direct connection between two Vertices (an Edge) on a graph
5
+ module Edge
6
+ include Comparable
7
+
8
+ def from
9
+ # Expect classes to describe the "from" Vertex
10
+ raise "#from() MUST be implemented on #{self.class.name}"
11
+ end
12
+
13
+ def to
14
+ # Expect classes to describe the "to" Vertex
15
+ raise "#to() MUST be implemented on #{self.class.name}"
16
+ end
17
+
18
+ # This should almost certainly be overridden
19
+ def metric
20
+ 1
21
+ end
22
+
23
+ def closed?
24
+ unless respond_to?(:state)
25
+ # Expect classes to describe how to determine if they're closed
26
+ raise "#state() or #closed? MUST be implemented on #{self.class.name}"
27
+ end
28
+
29
+ state.to_s == :closed.to_s
30
+ end
31
+
32
+ def open?
33
+ unless respond_to?(:state)
34
+ # Expect classes to describe how to determine if they're open
35
+ raise "#state() or #open? MUST be implemented on #{self.class.name}"
36
+ end
37
+
38
+ state.to_s == :open.to_s
39
+ end
40
+
41
+ def <=>(other)
42
+ if (open? && other.open?) || (closed? && other.closed?)
43
+ metric <=> other.metric
44
+ elsif open?
45
+ -1
46
+ elsif other.open?
47
+ 1
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ # A generic implementation of an Edge
5
+ class GenericConnection
6
+ include Edge
7
+
8
+ attr_reader :from, :to, :metric, :state
9
+
10
+ def initialize(from:, to:, metric: 1, state: :open)
11
+ validate_from(from)
12
+ validate_to(to)
13
+ validate_metric(metric)
14
+ validate_state(state)
15
+
16
+ raise 'Invalid Connection from and to same Vertex' if from == to
17
+
18
+ @from = from
19
+ @to = to
20
+ @metric = metric
21
+ @state = state
22
+ end
23
+
24
+ def metric=(value)
25
+ validate_metric(value)
26
+ @metric = value
27
+ end
28
+
29
+ def state=(setting)
30
+ validate_state(setting)
31
+ @state = setting
32
+ end
33
+
34
+ private
35
+
36
+ def validate_from(node)
37
+ raise 'Invalid from Node' unless node.is_a?(Vertex)
38
+ end
39
+
40
+ def validate_to(node)
41
+ raise 'Invalid to Node' unless node.is_a?(Vertex)
42
+ end
43
+
44
+ def validate_metric(num)
45
+ raise 'Invalid metric' unless num.is_a?(Numeric) && num.positive?
46
+ end
47
+
48
+ def validate_state(value)
49
+ raise 'Invalid state' unless %i[open closed].include?(value)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ # Generic example node
5
+ class GenericNode
6
+ include Vertex
7
+
8
+ attr_reader :name
9
+ attr_accessor :connections
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @connections = []
14
+ end
15
+
16
+ def connects_to(other, metric: 1, state: :open, directed: false)
17
+ # Only one connection between nodes
18
+ return true if neighbors.include?(other)
19
+
20
+ connections << GenericConnection.new(
21
+ from: self, to: other, metric: metric, state: state.to_sym
22
+ )
23
+
24
+ other.connects_to(self, metric: metric, state: state) unless directed
25
+ end
26
+
27
+ def disconnect_from(other, directed: false)
28
+ connections.delete_if { |c| c.to == other }
29
+ other.disconnect_from(self) if other.neighbors.include?(self) && !directed
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ # Represents an indirect connection (combined edges) between two Vertices on a graph
5
+ class Path
6
+ def initialize(nodes, validate: true)
7
+ validate_nodes(nodes) if validate
8
+
9
+ @nodes = nodes
10
+ end
11
+
12
+ def connections
13
+ links = nodes.dup
14
+ links.size.downto(2).map do |i|
15
+ links[-1 * i].connection_to(links[-1 * (i - 1)])
16
+ end
17
+ end
18
+
19
+ def cost
20
+ connections.map(&:metric).reduce(&:+)
21
+ end
22
+
23
+ def hops
24
+ nodes.size - 1
25
+ end
26
+
27
+ def branch(node)
28
+ return unless nodes.last.neighbors.include?(node) && !nodes.include?(node)
29
+
30
+ self.class.new(nodes + [node], validate: false)
31
+ end
32
+
33
+ def nodes
34
+ @nodes.dup
35
+ end
36
+
37
+ def open?
38
+ connections.select(&:closed?).empty?
39
+ end
40
+
41
+ def from
42
+ nodes.first
43
+ end
44
+
45
+ def to
46
+ nodes.last
47
+ end
48
+
49
+ def to_s(separator = ' -> ')
50
+ nodes.map(&:name).join(separator)
51
+ end
52
+
53
+ # rubocop:disable Metrics/AbcSize
54
+ # rubocop:disable Metrics/CyclomaticComplexity
55
+ # rubocop:disable Metrics/PerceivedComplexity
56
+ def self.all(from:, to:, include_closed: false, debug: false, suboptimal: false)
57
+ paths = []
58
+
59
+ path_queue = from.neighbors.map { |n| new([from, n]) }
60
+
61
+ until path_queue.empty?
62
+ this_path = path_queue.pop
63
+ next unless this_path.open? || include_closed
64
+
65
+ puts "Walking from #{this_path.nodes.map(&:name).join(' to ')}" if debug
66
+
67
+ if this_path.to == to
68
+ puts "Found destination with #{this_path.nodes.map(&:name).join(' to ')}" if debug
69
+ paths << this_path
70
+ else
71
+ highmetric = paths.max_by(&:cost)&.cost
72
+ highops = paths.max_by(&:hops)&.hops
73
+
74
+ this_path.to.neighbors.each do |n|
75
+ new_path = this_path.branch(n)
76
+ next unless new_path
77
+
78
+ if paths.empty? || new_path.cost <= highmetric || new_path.hops <= highops || suboptimal
79
+ path_queue.unshift(new_path)
80
+ elsif debug
81
+ puts "Skipping #{new_path.nodes.map(&:name).join(' to ')}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ # Return the list of paths, sorted first by cost then by hops
88
+ paths.sort_by { |p| [p.cost, p.hops] }
89
+ end
90
+ # rubocop:enable Metrics/AbcSize
91
+ # rubocop:enable Metrics/CyclomaticComplexity
92
+ # rubocop:enable Metrics/PerceivedComplexity
93
+
94
+ def self.find(from:, to:, include_closed: false)
95
+ all(from: from, to: to, include_closed: include_closed).first
96
+ end
97
+
98
+ private
99
+
100
+ def validate_nodes(list)
101
+ # Want to throw an exception if there are loops
102
+ raise 'Invalid Nodes list, duplicates found' unless list.size == list.uniq.size
103
+
104
+ list.each_with_index do |item, index|
105
+ break if index == list.size - 1
106
+
107
+ # Each node should connect to the next (no leaves except the end of the list)
108
+ raise 'Invalid Nodes list, broken chain' unless item.neighbors.include?(list[index + 1])
109
+ end
110
+
111
+ true
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connected
4
+ # Vertices are based on a mixin
5
+ module Vertex
6
+ def connections
7
+ # Expect classes to describe how to find connections
8
+ raise "#connections() MUST be implemented on #{self.class.name}"
9
+ end
10
+
11
+ # A shortcut for retrieving this node's neighbors
12
+ def neighbors
13
+ connections.map(&:to).uniq
14
+ end
15
+
16
+ # Retrieves the Connection object responsible for connecting to a Node
17
+ def connection_to(other)
18
+ connections.select { |c| c.to == other }.min_by(&:metric)
19
+ end
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connected
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Gnagy
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-06-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.50'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.50'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.15'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.15'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov-cobertura
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.9'
111
+ description: A Ruby object-oriented solver for directed and undirected graphs based
112
+ loosely on Dijkstra's algorithm
113
+ email:
114
+ - jonathan.gnagy@gmail.com
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - ".gitignore"
120
+ - ".images/logo.png"
121
+ - ".rspec"
122
+ - ".rubocop.yml"
123
+ - ".travis.yml"
124
+ - Gemfile
125
+ - Gemfile.lock
126
+ - LICENSE.txt
127
+ - README.md
128
+ - Rakefile
129
+ - bin/console
130
+ - bin/setup
131
+ - connected.gemspec
132
+ - lib/connected.rb
133
+ - lib/connected/edge.rb
134
+ - lib/connected/generic_connection.rb
135
+ - lib/connected/generic_node.rb
136
+ - lib/connected/path.rb
137
+ - lib/connected/version.rb
138
+ - lib/connected/vertex.rb
139
+ homepage: https://github.com/jgnagy/connected
140
+ licenses:
141
+ - MIT
142
+ metadata: {}
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 2.6.0
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 3.0.8
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: A shortest path first gem
162
+ test_files: []