connected 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.images/logo.png +0 -0
- data/.rspec +3 -0
- data/.rubocop.yml +44 -0
- data/.travis.yml +14 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +338 -0
- data/Rakefile +16 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/connected.gemspec +35 -0
- data/lib/connected.rb +8 -0
- data/lib/connected/edge.rb +51 -0
- data/lib/connected/generic_connection.rb +52 -0
- data/lib/connected/generic_node.rb +32 -0
- data/lib/connected/path.rb +114 -0
- data/lib/connected/version.rb +5 -0
- data/lib/connected/vertex.rb +21 -0
- metadata +162 -0
checksums.yaml
ADDED
@@ -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
|
data/.gitignore
ADDED
data/.images/logo.png
ADDED
Binary file
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -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
|
data/.travis.yml
ADDED
@@ -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
data/Gemfile.lock
ADDED
@@ -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
|
data/LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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).
|
data/Rakefile
ADDED
@@ -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]
|
data/bin/console
ADDED
@@ -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__)
|
data/bin/setup
ADDED
data/connected.gemspec
ADDED
@@ -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
|
data/lib/connected.rb
ADDED
@@ -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,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: []
|