philiprehberger-graph 0.2.4 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ea79730e427e091bf405d36a868055b567b90218a8227d8d59eabde37bc04c4
4
- data.tar.gz: a57a435c08e6a966b227234216997ab886e13a0cf28a7666d2ad9bcd0eb8057c
3
+ metadata.gz: 73596f486ec100249fa2df0e4a9ef6aa1bdcd5e37e16b9b587dd6802f3fc9584
4
+ data.tar.gz: 0ddc969e510fb5543d9b981d53f73c15b53e73066defb0b7226998c79ef7ec15
5
5
  SHA512:
6
- metadata.gz: 80f68b85ca03998c51dada82ca2e82fb059ab75ca00fb0ef12c0b45f0cab7f666183ec510a9d3857210c7d955f05f86a513a3adc3750bee8771a05299c681628
7
- data.tar.gz: 97a78d205f80b0bf95f803da97dbbad1d735486fa12af3f78aa4eb98dc39d89a49609a570d7fd5be87d4e3114447c928223d8207a37efbd4b2f557a0d89c4167
6
+ metadata.gz: 4cffa4c6398bfa251b9b8f82704d883d36e4c2dc7f1296369ff073364b8a71d0256a8f75b056d78372a38ca99809e8a09c560d549da4b8b8efa38ccf35130811
7
+ data.tar.gz: 80ca432cc5c0b1b35c979ccfebb1e7409d9170687a99ae9f4336e5159ce72a8d063cd5c8f7cc4829a4e22d9bad34c8d329e4fb8e8d3a3a668876994455ac628d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-17
11
+
12
+ ### Added
13
+ - Enumerator-style node iteration (`each_node`)
14
+ - Enumerator-style edge iteration (`each_edge`)
15
+ - Distance-only shortest path query (`shortest_path_distance`)
16
+ - Graph complement operation (`complement`)
17
+
18
+ ## [0.3.0] - 2026-04-10
19
+
20
+ ### Added
21
+ - Node and edge existence checks (`node?`, `edge?`)
22
+ - Edge weight retrieval (`weight`)
23
+ - Graph size queries (`node_count`, `edge_count`, `empty?`)
24
+ - Directed graph degree decomposition (`in_degree`, `out_degree`)
25
+ - Path existence check (`path?`)
26
+ - Subgraph extraction (`subgraph`)
27
+ - Graph transpose (`transpose`)
28
+ - Graph density calculation (`density`)
29
+
30
+ ### Changed
31
+ - Update gemspec description to reflect full feature set
32
+ - Add missing fields to GitHub issue templates
33
+
10
34
  ## [0.2.4] - 2026-04-08
11
35
 
12
36
  ### Changed
data/README.md CHANGED
@@ -139,6 +139,101 @@ g.add_edge(:d, :c)
139
139
  g.strongly_connected_components # => [[:d, :c], [:b, :a]]
140
140
  ```
141
141
 
142
+ ### Iteration
143
+
144
+ ```ruby
145
+ g = Philiprehberger::Graph.new
146
+ g.add_edge(:a, :b)
147
+ g.add_edge(:a, :c)
148
+
149
+ g.each_node { |id| puts id }
150
+ g.each_edge { |e| puts "#{e[:from]} -> #{e[:to]}" }
151
+
152
+ # Enumerator chaining
153
+ high_degree = g.each_node.select { |id| g.degree(id) > 1 }
154
+ heavy_edges = g.each_edge.select { |e| e[:weight] > 5 }
155
+ ```
156
+
157
+ ### Shortest Path Distance
158
+
159
+ ```ruby
160
+ g = Philiprehberger::Graph.new
161
+ g.add_edge(:a, :b, weight: 1)
162
+ g.add_edge(:b, :c, weight: 2)
163
+ g.add_edge(:a, :c, weight: 10)
164
+
165
+ g.shortest_path_distance(:a, :c) # => 3
166
+ ```
167
+
168
+ ### Graph Complement
169
+
170
+ ```ruby
171
+ g = Philiprehberger::Graph.new
172
+ g.add_edge(:a, :b)
173
+ g.add_edge(:b, :c)
174
+
175
+ c = g.complement
176
+ c.edge?(:a, :c) # => true
177
+ c.edge?(:a, :b) # => false
178
+ ```
179
+
180
+ ### Query Methods
181
+
182
+ ```ruby
183
+ g = Philiprehberger::Graph.new
184
+ g.add_edge(:a, :b, weight: 3)
185
+ g.add_edge(:b, :c)
186
+ g.add_node(:d)
187
+
188
+ g.node?(:a) # => true
189
+ g.edge?(:a, :b) # => true
190
+ g.weight(:a, :b) # => 3
191
+ g.node_count # => 4
192
+ g.edge_count # => 2
193
+ g.empty? # => false
194
+ g.path?(:a, :c) # => true
195
+ g.path?(:a, :d) # => false
196
+ g.density # => 0.333...
197
+ ```
198
+
199
+ ### Directed Degree
200
+
201
+ ```ruby
202
+ g = Philiprehberger::Graph.new(directed: true)
203
+ g.add_edge(:a, :b)
204
+ g.add_edge(:a, :c)
205
+ g.add_edge(:b, :c)
206
+
207
+ g.in_degree(:c) # => 2
208
+ g.out_degree(:a) # => 2
209
+ ```
210
+
211
+ ### Subgraph
212
+
213
+ ```ruby
214
+ g = Philiprehberger::Graph.new
215
+ g.add_edge(:a, :b)
216
+ g.add_edge(:b, :c)
217
+ g.add_edge(:c, :d)
218
+
219
+ sg = g.subgraph([:a, :b, :c])
220
+ sg.nodes # => [:a, :b, :c]
221
+ sg.edge?(:a, :b) # => true
222
+ sg.node?(:d) # => false
223
+ ```
224
+
225
+ ### Transpose
226
+
227
+ ```ruby
228
+ g = Philiprehberger::Graph.new(directed: true)
229
+ g.add_edge(:a, :b)
230
+ g.add_edge(:b, :c)
231
+
232
+ t = g.transpose
233
+ t.edge?(:b, :a) # => true
234
+ t.edge?(:c, :b) # => true
235
+ ```
236
+
142
237
  ### Serialization
143
238
 
144
239
  ```ruby
@@ -163,13 +258,25 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
163
258
  | `#add_edge(from, to, weight:)` | Add a weighted edge |
164
259
  | `#remove_node(id)` | Remove a node and its edges |
165
260
  | `#remove_edge(from, to)` | Remove an edge |
261
+ | `#node?(id)` | Check if a node exists |
262
+ | `#edge?(from, to)` | Check if an edge exists |
263
+ | `#weight(from, to)` | Get edge weight (nil if none) |
166
264
  | `#neighbors(node)` | Get neighbor node ids |
167
265
  | `#degree(node)` | Number of edges on a node |
266
+ | `#in_degree(node)` | Incoming edges (directed) |
267
+ | `#out_degree(node)` | Outgoing edges (directed) |
168
268
  | `#nodes` | All node ids |
169
269
  | `#edges` | All edges as hashes |
270
+ | `#node_count` | Number of nodes |
271
+ | `#edge_count` | Number of edges |
272
+ | `#empty?` | Whether the graph has no nodes |
273
+ | `#each_node` | Iterate nodes (yields or returns Enumerator) |
274
+ | `#each_edge` | Iterate edges (yields or returns Enumerator) |
275
+ | `#path?(from, to)` | Check if a path exists |
170
276
  | `#bfs(start)` | Breadth-first search |
171
277
  | `#dfs(start)` | Depth-first search |
172
278
  | `#shortest_path(from, to)` | Dijkstra's shortest path |
279
+ | `#shortest_path_distance(from, to)` | Shortest distance (numeric) |
173
280
  | `#topological_sort` | Topological ordering (DAG only) |
174
281
  | `#cycle?` | Check for cycles |
175
282
  | `#connected_components` | Find connected components |
@@ -180,6 +287,10 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
180
287
  | `#bipartite?` | Check if graph is bipartite |
181
288
  | `#bipartite_sets` | Get bipartite partition sets |
182
289
  | `#strongly_connected_components` | Tarjan's SCC algorithm |
290
+ | `#subgraph(nodes)` | Extract a subgraph |
291
+ | `#transpose` | Reverse all edges (directed) |
292
+ | `#density` | Graph density (0.0 to 1.0) |
293
+ | `#complement` | Graph complement (missing edges) |
183
294
  | `#to_dot` | Serialize to DOT format |
184
295
  | `#to_json` | Serialize to JSON |
185
296
  | `.from_json(str)` | Deserialize from JSON |
@@ -17,6 +17,26 @@ module Philiprehberger
17
17
  @directed
18
18
  end
19
19
 
20
+ # Iterate over all node ids.
21
+ #
22
+ # @yield [Object] each node id
23
+ # @return [Enumerator] if no block given
24
+ def each_node(&)
25
+ return enum_for(:each_node) unless block_given?
26
+
27
+ @adjacency.each_key(&)
28
+ end
29
+
30
+ # Iterate over all edges as {from:, to:, weight:} hashes.
31
+ #
32
+ # @yield [Hash] each edge
33
+ # @return [Enumerator] if no block given
34
+ def each_edge(&)
35
+ return enum_for(:each_edge) unless block_given?
36
+
37
+ edges.each(&)
38
+ end
39
+
20
40
  # Add a node to the graph.
21
41
  #
22
42
  # @param id [Object] the node identifier
@@ -79,6 +99,63 @@ module Philiprehberger
79
99
  (@adjacency[node] || []).length
80
100
  end
81
101
 
102
+ # Return the in-degree of a node (directed graphs).
103
+ # For undirected graphs, returns the same as degree.
104
+ #
105
+ # @param node [Object] the node
106
+ # @return [Integer]
107
+ def in_degree(node)
108
+ return degree(node) unless @directed
109
+
110
+ count = 0
111
+ @adjacency.each_value do |edges_list|
112
+ edges_list.each { |e| count += 1 if e[:node] == node }
113
+ end
114
+ count
115
+ end
116
+
117
+ # Return the out-degree of a node (directed graphs).
118
+ # For undirected graphs, returns the same as degree.
119
+ #
120
+ # @param node [Object] the node
121
+ # @return [Integer]
122
+ def out_degree(node)
123
+ return degree(node) unless @directed
124
+
125
+ (@adjacency[node] || []).length
126
+ end
127
+
128
+ # Check if a node exists.
129
+ #
130
+ # @param id [Object] the node identifier
131
+ # @return [Boolean]
132
+ def node?(id)
133
+ @adjacency.key?(id)
134
+ end
135
+
136
+ # Check if an edge exists between two nodes.
137
+ #
138
+ # @param from [Object] source node
139
+ # @param to [Object] destination node
140
+ # @return [Boolean]
141
+ def edge?(from, to)
142
+ return false unless @adjacency.key?(from)
143
+
144
+ @adjacency[from].any? { |e| e[:node] == to }
145
+ end
146
+
147
+ # Get the weight of an edge.
148
+ #
149
+ # @param from [Object] source node
150
+ # @param to [Object] destination node
151
+ # @return [Numeric, nil] the edge weight, or nil if no edge exists
152
+ def weight(from, to)
153
+ return nil unless @adjacency.key?(from)
154
+
155
+ edge = @adjacency[from].find { |e| e[:node] == to }
156
+ edge&.dig(:weight)
157
+ end
158
+
82
159
  # Return all node ids.
83
160
  #
84
161
  # @return [Array<Object>]
@@ -86,6 +163,27 @@ module Philiprehberger
86
163
  @adjacency.keys
87
164
  end
88
165
 
166
+ # Return the number of nodes.
167
+ #
168
+ # @return [Integer]
169
+ def node_count
170
+ @adjacency.length
171
+ end
172
+
173
+ # Return the number of edges.
174
+ #
175
+ # @return [Integer]
176
+ def edge_count
177
+ edges.length
178
+ end
179
+
180
+ # Whether the graph has no nodes.
181
+ #
182
+ # @return [Boolean]
183
+ def empty?
184
+ @adjacency.empty?
185
+ end
186
+
89
187
  # Return all edges as [from, to, weight] tuples.
90
188
  #
91
189
  # @return [Array<Hash>]
@@ -176,6 +274,36 @@ module Philiprehberger
176
274
  nil
177
275
  end
178
276
 
277
+ # Find the shortest distance between two nodes using Dijkstra's algorithm.
278
+ #
279
+ # @param from [Object] source node
280
+ # @param to [Object] destination node
281
+ # @return [Numeric, nil] the shortest distance, or nil if no path exists
282
+ def shortest_path_distance(from, to)
283
+ return nil unless @adjacency.key?(from) && @adjacency.key?(to)
284
+ return 0 if from == to
285
+
286
+ distances = Hash.new(Float::INFINITY)
287
+ distances[from] = 0
288
+ unvisited = @adjacency.keys.dup
289
+
290
+ until unvisited.empty?
291
+ current = unvisited.min_by { |n| distances[n] }
292
+ break if distances[current] == Float::INFINITY
293
+
294
+ unvisited.delete(current)
295
+
296
+ return distances[to] if current == to
297
+
298
+ (@adjacency[current] || []).each do |edge|
299
+ alt = distances[current] + edge[:weight]
300
+ distances[edge[:node]] = alt if alt < distances[edge[:node]]
301
+ end
302
+ end
303
+
304
+ nil
305
+ end
306
+
179
307
  # Topological sort (directed acyclic graphs only).
180
308
  #
181
309
  # @return [Array<Object>] nodes in topological order
@@ -205,6 +333,111 @@ module Philiprehberger
205
333
  end
206
334
  end
207
335
 
336
+ # Check if a path exists between two nodes using BFS.
337
+ #
338
+ # @param from [Object] source node
339
+ # @param to [Object] destination node
340
+ # @return [Boolean]
341
+ def path?(from, to)
342
+ return false unless @adjacency.key?(from) && @adjacency.key?(to)
343
+ return true if from == to
344
+
345
+ visited = { from => true }
346
+ queue = [from]
347
+
348
+ until queue.empty?
349
+ node = queue.shift
350
+ neighbors(node).each do |neighbor|
351
+ return true if neighbor == to
352
+
353
+ unless visited[neighbor]
354
+ visited[neighbor] = true
355
+ queue << neighbor
356
+ end
357
+ end
358
+ end
359
+
360
+ false
361
+ end
362
+
363
+ # Extract a subgraph containing only the specified nodes and edges between them.
364
+ #
365
+ # @param node_ids [Array<Object>] the nodes to include
366
+ # @return [Graph] a new graph
367
+ def subgraph(node_ids)
368
+ node_set = node_ids.is_a?(Set) ? node_ids : Set.new(node_ids)
369
+ g = self.class.new(directed: @directed)
370
+
371
+ node_set.each { |id| g.add_node(id) if @adjacency.key?(id) }
372
+
373
+ @adjacency.each do |from, edges_list|
374
+ next unless node_set.include?(from)
375
+
376
+ edges_list.each do |edge|
377
+ next unless node_set.include?(edge[:node])
378
+ next if !@directed && from.to_s > edge[:node].to_s
379
+
380
+ g.add_edge(from, edge[:node], weight: edge[:weight])
381
+ end
382
+ end
383
+
384
+ g
385
+ end
386
+
387
+ # Return a new graph with all edges reversed.
388
+ # Only works on directed graphs.
389
+ #
390
+ # @return [Graph] a new graph with reversed edges
391
+ # @raise [Error] if the graph is not directed
392
+ def transpose
393
+ raise Error, 'transpose requires a directed graph' unless @directed
394
+
395
+ g = self.class.new(directed: true)
396
+ @adjacency.each_key { |id| g.add_node(id) }
397
+ @adjacency.each do |from, edges_list|
398
+ edges_list.each do |edge|
399
+ g.add_edge(edge[:node], from, weight: edge[:weight])
400
+ end
401
+ end
402
+ g
403
+ end
404
+
405
+ # Calculate the density of the graph.
406
+ # Density is the ratio of actual edges to possible edges.
407
+ #
408
+ # @return [Float] density between 0.0 and 1.0
409
+ def density
410
+ n = @adjacency.length
411
+ return 0.0 if n < 2
412
+
413
+ m = edge_count.to_f
414
+ max_edges = @directed ? n * (n - 1) : n * (n - 1) / 2.0
415
+ m / max_edges
416
+ end
417
+
418
+ # Return the complement graph.
419
+ # The complement contains edges between all pairs of nodes that are NOT connected
420
+ # in the original graph.
421
+ #
422
+ # @return [Graph] a new graph with complementary edges
423
+ def complement
424
+ g = self.class.new(directed: @directed)
425
+ @adjacency.each_key { |id| g.add_node(id) }
426
+
427
+ node_list = @adjacency.keys
428
+ node_list.each_with_index do |a, i|
429
+ targets = @directed ? node_list : node_list[(i + 1)..]
430
+ targets.each do |b|
431
+ next if a == b
432
+ next if edge?(a, b)
433
+
434
+ g.add_edge(a, b)
435
+ end
436
+ end
437
+
438
+ g
439
+ end
440
+
208
441
  # Find connected components (undirected) or weakly connected components (directed).
209
442
  #
210
443
  # @return [Array<Array<Object>>] arrays of node ids per component
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module Graph
5
- VERSION = '0.2.4'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,18 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-09 00:00:00.000000000 Z
11
+ date: 2026-04-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Graph data structure supporting directed and undirected modes with adjacency
14
14
  list storage. Includes BFS, DFS, Dijkstra shortest path, topological sort, cycle
15
- detection, and connected component discovery.
15
+ detection, connected components, minimum spanning tree, maximum flow, graph coloring,
16
+ bipartiteness checking, strongly connected components, and DOT/JSON serialization.
16
17
  email:
17
18
  - me@philiprehberger.com
18
19
  executables: []