philiprehberger-graph 0.2.4 → 0.3.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: 8e3ace407427d0a54b4279defb1a165c04aa85f1286c2234be016e96a359ad27
4
+ data.tar.gz: 03d2e98d913ee10c80ed8a2d18f5c896095e568f1e21f57dfb023815c55d8e72
5
5
  SHA512:
6
- metadata.gz: 80f68b85ca03998c51dada82ca2e82fb059ab75ca00fb0ef12c0b45f0cab7f666183ec510a9d3857210c7d955f05f86a513a3adc3750bee8771a05299c681628
7
- data.tar.gz: 97a78d205f80b0bf95f803da97dbbad1d735486fa12af3f78aa4eb98dc39d89a49609a570d7fd5be87d4e3114447c928223d8207a37efbd4b2f557a0d89c4167
6
+ metadata.gz: 61b9e62ef38c0993c979f032453f7a142f57079e0c46434265371ad7344e2dbcd8b7a9a32ebf92fa1ce22b5fbd42a1b55895479bdf83e7d6b8bce5c907502575
7
+ data.tar.gz: 47f5e812b101281eff00b8d0e261caf2473e5f3fc47c5686800fb6a3d5e9c155fbedd67bcadfbe608c2dde0a89b79b8fa0141a350a21e6adf544af441ed49f74
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-10
11
+
12
+ ### Added
13
+ - Node and edge existence checks (`node?`, `edge?`)
14
+ - Edge weight retrieval (`weight`)
15
+ - Graph size queries (`node_count`, `edge_count`, `empty?`)
16
+ - Directed graph degree decomposition (`in_degree`, `out_degree`)
17
+ - Path existence check (`path?`)
18
+ - Subgraph extraction (`subgraph`)
19
+ - Graph transpose (`transpose`)
20
+ - Graph density calculation (`density`)
21
+
22
+ ### Changed
23
+ - Update gemspec description to reflect full feature set
24
+ - Add missing fields to GitHub issue templates
25
+
10
26
  ## [0.2.4] - 2026-04-08
11
27
 
12
28
  ### Changed
data/README.md CHANGED
@@ -139,6 +139,63 @@ g.add_edge(:d, :c)
139
139
  g.strongly_connected_components # => [[:d, :c], [:b, :a]]
140
140
  ```
141
141
 
142
+ ### Query Methods
143
+
144
+ ```ruby
145
+ g = Philiprehberger::Graph.new
146
+ g.add_edge(:a, :b, weight: 3)
147
+ g.add_edge(:b, :c)
148
+ g.add_node(:d)
149
+
150
+ g.node?(:a) # => true
151
+ g.edge?(:a, :b) # => true
152
+ g.weight(:a, :b) # => 3
153
+ g.node_count # => 4
154
+ g.edge_count # => 2
155
+ g.empty? # => false
156
+ g.path?(:a, :c) # => true
157
+ g.path?(:a, :d) # => false
158
+ g.density # => 0.333...
159
+ ```
160
+
161
+ ### Directed Degree
162
+
163
+ ```ruby
164
+ g = Philiprehberger::Graph.new(directed: true)
165
+ g.add_edge(:a, :b)
166
+ g.add_edge(:a, :c)
167
+ g.add_edge(:b, :c)
168
+
169
+ g.in_degree(:c) # => 2
170
+ g.out_degree(:a) # => 2
171
+ ```
172
+
173
+ ### Subgraph
174
+
175
+ ```ruby
176
+ g = Philiprehberger::Graph.new
177
+ g.add_edge(:a, :b)
178
+ g.add_edge(:b, :c)
179
+ g.add_edge(:c, :d)
180
+
181
+ sg = g.subgraph([:a, :b, :c])
182
+ sg.nodes # => [:a, :b, :c]
183
+ sg.edge?(:a, :b) # => true
184
+ sg.node?(:d) # => false
185
+ ```
186
+
187
+ ### Transpose
188
+
189
+ ```ruby
190
+ g = Philiprehberger::Graph.new(directed: true)
191
+ g.add_edge(:a, :b)
192
+ g.add_edge(:b, :c)
193
+
194
+ t = g.transpose
195
+ t.edge?(:b, :a) # => true
196
+ t.edge?(:c, :b) # => true
197
+ ```
198
+
142
199
  ### Serialization
143
200
 
144
201
  ```ruby
@@ -163,10 +220,19 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
163
220
  | `#add_edge(from, to, weight:)` | Add a weighted edge |
164
221
  | `#remove_node(id)` | Remove a node and its edges |
165
222
  | `#remove_edge(from, to)` | Remove an edge |
223
+ | `#node?(id)` | Check if a node exists |
224
+ | `#edge?(from, to)` | Check if an edge exists |
225
+ | `#weight(from, to)` | Get edge weight (nil if none) |
166
226
  | `#neighbors(node)` | Get neighbor node ids |
167
227
  | `#degree(node)` | Number of edges on a node |
228
+ | `#in_degree(node)` | Incoming edges (directed) |
229
+ | `#out_degree(node)` | Outgoing edges (directed) |
168
230
  | `#nodes` | All node ids |
169
231
  | `#edges` | All edges as hashes |
232
+ | `#node_count` | Number of nodes |
233
+ | `#edge_count` | Number of edges |
234
+ | `#empty?` | Whether the graph has no nodes |
235
+ | `#path?(from, to)` | Check if a path exists |
170
236
  | `#bfs(start)` | Breadth-first search |
171
237
  | `#dfs(start)` | Depth-first search |
172
238
  | `#shortest_path(from, to)` | Dijkstra's shortest path |
@@ -180,6 +246,9 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
180
246
  | `#bipartite?` | Check if graph is bipartite |
181
247
  | `#bipartite_sets` | Get bipartite partition sets |
182
248
  | `#strongly_connected_components` | Tarjan's SCC algorithm |
249
+ | `#subgraph(nodes)` | Extract a subgraph |
250
+ | `#transpose` | Reverse all edges (directed) |
251
+ | `#density` | Graph density (0.0 to 1.0) |
183
252
  | `#to_dot` | Serialize to DOT format |
184
253
  | `#to_json` | Serialize to JSON |
185
254
  | `.from_json(str)` | Deserialize from JSON |
@@ -79,6 +79,63 @@ module Philiprehberger
79
79
  (@adjacency[node] || []).length
80
80
  end
81
81
 
82
+ # Return the in-degree of a node (directed graphs).
83
+ # For undirected graphs, returns the same as degree.
84
+ #
85
+ # @param node [Object] the node
86
+ # @return [Integer]
87
+ def in_degree(node)
88
+ return degree(node) unless @directed
89
+
90
+ count = 0
91
+ @adjacency.each_value do |edges_list|
92
+ edges_list.each { |e| count += 1 if e[:node] == node }
93
+ end
94
+ count
95
+ end
96
+
97
+ # Return the out-degree of a node (directed graphs).
98
+ # For undirected graphs, returns the same as degree.
99
+ #
100
+ # @param node [Object] the node
101
+ # @return [Integer]
102
+ def out_degree(node)
103
+ return degree(node) unless @directed
104
+
105
+ (@adjacency[node] || []).length
106
+ end
107
+
108
+ # Check if a node exists.
109
+ #
110
+ # @param id [Object] the node identifier
111
+ # @return [Boolean]
112
+ def node?(id)
113
+ @adjacency.key?(id)
114
+ end
115
+
116
+ # Check if an edge exists between two nodes.
117
+ #
118
+ # @param from [Object] source node
119
+ # @param to [Object] destination node
120
+ # @return [Boolean]
121
+ def edge?(from, to)
122
+ return false unless @adjacency.key?(from)
123
+
124
+ @adjacency[from].any? { |e| e[:node] == to }
125
+ end
126
+
127
+ # Get the weight of an edge.
128
+ #
129
+ # @param from [Object] source node
130
+ # @param to [Object] destination node
131
+ # @return [Numeric, nil] the edge weight, or nil if no edge exists
132
+ def weight(from, to)
133
+ return nil unless @adjacency.key?(from)
134
+
135
+ edge = @adjacency[from].find { |e| e[:node] == to }
136
+ edge&.dig(:weight)
137
+ end
138
+
82
139
  # Return all node ids.
83
140
  #
84
141
  # @return [Array<Object>]
@@ -86,6 +143,27 @@ module Philiprehberger
86
143
  @adjacency.keys
87
144
  end
88
145
 
146
+ # Return the number of nodes.
147
+ #
148
+ # @return [Integer]
149
+ def node_count
150
+ @adjacency.length
151
+ end
152
+
153
+ # Return the number of edges.
154
+ #
155
+ # @return [Integer]
156
+ def edge_count
157
+ edges.length
158
+ end
159
+
160
+ # Whether the graph has no nodes.
161
+ #
162
+ # @return [Boolean]
163
+ def empty?
164
+ @adjacency.empty?
165
+ end
166
+
89
167
  # Return all edges as [from, to, weight] tuples.
90
168
  #
91
169
  # @return [Array<Hash>]
@@ -205,6 +283,88 @@ module Philiprehberger
205
283
  end
206
284
  end
207
285
 
286
+ # Check if a path exists between two nodes using BFS.
287
+ #
288
+ # @param from [Object] source node
289
+ # @param to [Object] destination node
290
+ # @return [Boolean]
291
+ def path?(from, to)
292
+ return false unless @adjacency.key?(from) && @adjacency.key?(to)
293
+ return true if from == to
294
+
295
+ visited = { from => true }
296
+ queue = [from]
297
+
298
+ until queue.empty?
299
+ node = queue.shift
300
+ neighbors(node).each do |neighbor|
301
+ return true if neighbor == to
302
+
303
+ unless visited[neighbor]
304
+ visited[neighbor] = true
305
+ queue << neighbor
306
+ end
307
+ end
308
+ end
309
+
310
+ false
311
+ end
312
+
313
+ # Extract a subgraph containing only the specified nodes and edges between them.
314
+ #
315
+ # @param node_ids [Array<Object>] the nodes to include
316
+ # @return [Graph] a new graph
317
+ def subgraph(node_ids)
318
+ node_set = node_ids.is_a?(Set) ? node_ids : Set.new(node_ids)
319
+ g = self.class.new(directed: @directed)
320
+
321
+ node_set.each { |id| g.add_node(id) if @adjacency.key?(id) }
322
+
323
+ @adjacency.each do |from, edges_list|
324
+ next unless node_set.include?(from)
325
+
326
+ edges_list.each do |edge|
327
+ next unless node_set.include?(edge[:node])
328
+ next if !@directed && from.to_s > edge[:node].to_s
329
+
330
+ g.add_edge(from, edge[:node], weight: edge[:weight])
331
+ end
332
+ end
333
+
334
+ g
335
+ end
336
+
337
+ # Return a new graph with all edges reversed.
338
+ # Only works on directed graphs.
339
+ #
340
+ # @return [Graph] a new graph with reversed edges
341
+ # @raise [Error] if the graph is not directed
342
+ def transpose
343
+ raise Error, 'transpose requires a directed graph' unless @directed
344
+
345
+ g = self.class.new(directed: true)
346
+ @adjacency.each_key { |id| g.add_node(id) }
347
+ @adjacency.each do |from, edges_list|
348
+ edges_list.each do |edge|
349
+ g.add_edge(edge[:node], from, weight: edge[:weight])
350
+ end
351
+ end
352
+ g
353
+ end
354
+
355
+ # Calculate the density of the graph.
356
+ # Density is the ratio of actual edges to possible edges.
357
+ #
358
+ # @return [Float] density between 0.0 and 1.0
359
+ def density
360
+ n = @adjacency.length
361
+ return 0.0 if n < 2
362
+
363
+ m = edge_count.to_f
364
+ max_edges = @directed ? n * (n - 1) : n * (n - 1) / 2.0
365
+ m / max_edges
366
+ end
367
+
208
368
  # Find connected components (undirected) or weakly connected components (directed).
209
369
  #
210
370
  # @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.3.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.3.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-11 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: []