graphmatcher 0.3.5 → 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: 1a07b061aee6d3c909fa21e7dcc3ed815c556b358f0e233338614f97dc26d90f
4
- data.tar.gz: ac1149e8f375be540ba8d77d77276f9d309df1dc44c7e7057eef9def8ff38be8
3
+ metadata.gz: 825a8f82e234efc81b2de40f274f2dd60619bdaa797e65c303675a1b5a3c2a69
4
+ data.tar.gz: f9e114404851c37c7cd3bfefc84fe04ec8e3e11c63d21c7dd695ebff1a02e959
5
5
  SHA512:
6
- metadata.gz: be30bea5f4045cefb90288e632e43e4ea9d2472879c2e4d339fef667ddf842f863601064052e969bb1f7e6afa8f3d17daa5457ed52208e311faf76575ada5ca4
7
- data.tar.gz: 6ce6b7bd8a89fbc44c06079c014353220ed8f5cc3e938040bb7da9eb9b2226d967c11eadef2e1fe50c95989fbfd303d637d90cb057ea948adc3c785ba48ad142
6
+ metadata.gz: 37ff3e5e04a316849cb1f343b5f701b082a49a2582626cafa2ac51f88c9bb72bb0a04bec352833b1725abda7eb970f822688a2da538fa63b352c571fadd1fb65
7
+ data.tar.gz: 2d181382e180ca3cb8c888f1ce55db75cdfd8c2d27c1c8dfbccff3304bb0814c25f31514b9e36841509412d2e59b87bc96739b3b91f61b68ec71f482e07204de
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Emre Unlu
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,179 @@
1
+ # Graphmatcher
2
+
3
+ Labeled **subgraph matching** for Ruby: find all embeddings of a query graph
4
+ inside a larger data graph, respecting vertex labels and edge direction.
5
+
6
+ Two implementations are provided:
7
+
8
+ | Class | Algorithm | Notes |
9
+ |-------|-----------|-------|
10
+ | `Graphmatcher` | DualIso (graph simulation + backtracking), after M. Saltz et al. | Original implementation. Fast on DAGs; **does not match cyclic queries** (see Known issues). |
11
+ | `GraphmatcherVF2pp` | VF2++ (Jüttner & Madarasi, 2018) | Drop-in alternative with the same I/O. Handles cycles and self-loops; substantially faster on the bundled benchmarks. |
12
+
13
+ Both expose the same constructor and the same `getMatches` output shape, so they
14
+ are interchangeable behind that interface.
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'graphmatcher'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ $ bundle
27
+
28
+ Or install it yourself as:
29
+
30
+ $ gem install graphmatcher
31
+
32
+ ## Graph representation
33
+
34
+ A graph is a two-row array `[adjacency, labels]` (an optional third row of ids is
35
+ ignored by the matchers):
36
+
37
+ ```ruby
38
+ g = [
39
+ [[1, 2], [], [0]], # adjacency: OUTGOING edges. vertex 0 -> {1,2}, vertex 2 -> {0}
40
+ ["x", "y", "y"] # labels: vertex 0 is "x", vertices 1 and 2 are "y"
41
+ ]
42
+ ```
43
+
44
+ - `adjacency[i]` is the list of **outgoing** edge targets of vertex `i`.
45
+ - `labels[i]` is the label of vertex `i`; labels are compared with `==`.
46
+ - Both rows must be the same length (enforced by `validate!`).
47
+
48
+ ## Usage
49
+
50
+ ```ruby
51
+ require 'graphmatcher' # DualIso
52
+ require 'graphmatcher_vf2pp' # VF2++
53
+
54
+ query = [[[1, 2], [], [0]], ["T0", "T0", "T0"]]
55
+ data = [[[1, 2], [], [0]], ["T0", "T0", "T0"]]
56
+
57
+ matcher = GraphmatcherVF2pp.new(
58
+ query: query,
59
+ data: data,
60
+ limit: 100, # max matches to return (0 => unlimited)
61
+ max_allowed_time: 4.0, # wall-clock budget in seconds (0 => unlimited)
62
+ self_loops: true
63
+ )
64
+
65
+ matcher.getMatches
66
+ # => [[[0], [1], [2]]]
67
+ ```
68
+
69
+ ### Output shape
70
+
71
+ `getMatches` returns an array of matches. Each match has one entry per query
72
+ vertex, narrowed to a single data vertex:
73
+
74
+ ```
75
+ [[d0], [d1], ..., [dn]] # query vertex i -> data vertex di
76
+ ```
77
+
78
+ For example `[[0], [1], [2]]` means query vertices `0,1,2` map to data vertices
79
+ `0,1,2` respectively. When several data vertices are interchangeable (e.g. two
80
+ children sharing a label) each distinct embedding is returned separately.
81
+
82
+ ## Testing
83
+
84
+ The project uses [`just`](https://github.com/casey/just) as the task runner on
85
+ top of RSpec. From the repo root:
86
+
87
+ ```sh
88
+ just # list all recipes
89
+ just test # full RSpec suite (equivalent to `rake spec`)
90
+ just test-vf2 # VF2++ examples only
91
+ just verify # curated correctness check vs a brute-force oracle
92
+ just fuzz # 400 random graphs checked against the oracle
93
+ just fixtures # run JSON fixtures through the oracle-backed checker
94
+ just check # test + verify + fuzz + fixtures
95
+ ```
96
+
97
+ Plain RSpec (`rspec` / `bundle exec rake spec`) still works if you prefer not to
98
+ use `just`.
99
+
100
+ Test harnesses:
101
+
102
+ - **RSpec** (`spec/`) — behavioural examples for both matchers.
103
+ - **`verify_vf2pp.rb`** — curated cases run through VF2++, DualIso, and an
104
+ independent brute-force **oracle**; reports where each implementation agrees.
105
+ - **`fuzz_vf2pp.rb`** — randomized differential testing against the oracle.
106
+ - **`run_fixtures.rb`** — loads JSON fixtures and checks VF2++ against the oracle.
107
+
108
+ The oracle enumerates every injective labeled mapping and keeps those where every
109
+ query edge maps to a data edge. It is the ground truth, so a test case only needs
110
+ to supply the graphs — the expected answer is derived automatically.
111
+
112
+ ## Submitting your own fixtures
113
+
114
+ Fixtures are JSON. Drop entries into any `*.json` file under `spec/fixtures/`
115
+ (picked up automatically by `just fixtures`), or keep your own file anywhere and
116
+ pass it explicitly:
117
+
118
+ ```sh
119
+ just fixtures path/to/my_cases.json
120
+ # or run several
121
+ ruby run_fixtures.rb spec/fixtures/matcher_cases.json path/to/mine.json
122
+ ```
123
+
124
+ Fixture format:
125
+
126
+ ```json
127
+ {
128
+ "fixtures": [
129
+ {
130
+ "name": "triangle (cyclic) identity",
131
+ "self_loops": true,
132
+ "query": [[[1, 2], [], [0]], ["T0", "T0", "T0"]],
133
+ "data": [[[1, 2], [], [0]], ["T0", "T0", "T0"]]
134
+ },
135
+ {
136
+ "name": "DAG identity",
137
+ "self_loops": false,
138
+ "expect_count": 2,
139
+ "query": [[[1, 2], [], []], ["A", "A", "A"]],
140
+ "data": [[[1, 2], [], []], ["A", "A", "A"]]
141
+ }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ - `query` / `data` (required) — graphs in `[adjacency, labels]` form.
147
+ - `self_loops` (optional, default `true`) — passed through to the matcher.
148
+ - `expect_count` (optional) — asserts the number of matches in addition to the
149
+ oracle comparison.
150
+
151
+ You do **not** need to provide expected matches; the oracle computes them.
152
+
153
+ ## Known issues
154
+
155
+ - `Graphmatcher` (DualIso) returns **zero matches for cyclic queries** — including
156
+ the identity mapping of a simple triangle. `GraphmatcherVF2pp` handles these
157
+ correctly. If your queries contain cycles, use the VF2++ implementation.
158
+ - `spec/regression.rb` uses a positional constructor
159
+ (`Graphmatcher.new(graph, query, limit)`) while the library uses keyword
160
+ arguments; that spec does not currently run against the shipped API.
161
+
162
+ ## Development
163
+
164
+ After checking out the repo, run `bundle` to install dependencies, then
165
+ `just test` (or `rake spec`) to run the tests.
166
+
167
+ To install this gem onto your local machine, run `bundle exec rake install`. To
168
+ release a new version, update the version number in `lib/graphmatcher.rb`, and
169
+ then run `bundle exec rake release`.
170
+
171
+ ## Contributing
172
+
173
+ Bug reports and merge requests are welcome on GitLab at
174
+ https://gitlab.com/forvelin/graphmatcher.
175
+
176
+ ## License
177
+
178
+ The gem is available as open source under the terms of the
179
+ [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,224 @@
1
+ require 'logger'
2
+ #require 'ruby-prof'
3
+ class Graphmatcher
4
+
5
+ VERSION = "0.4.0"
6
+
7
+ @@logger = Logger.new(STDOUT)
8
+ @@logger.level = Logger::INFO
9
+
10
+ ####################################################################################################
11
+ # Instantiation with query_graph and data_graph for Graphmatcher class. #
12
+ # #
13
+ # @query_graph & @data_graph: Arrays which are represented as an #
14
+ # array such as g=[[[1,2],[3],[3],[]],['x','y','y','z']]. g[0] is #
15
+ # adjacency array which points children of given vertex of array index, #
16
+ # -e.g. vertex 0 has 1 and 2 as children- and g[1] is labels for vertices. #
17
+ # -e.g. vertex 0 has label 'x'. #
18
+ # #
19
+ # @limit: Upper limit for number of matches. Procedure terminates after #
20
+ # reaching to an upper limit. #
21
+ # @max_allowed_time: Maximum allowed time for procedure to run. #
22
+ # @self_loops: Boolean value for representing if query is cyclic or not. #
23
+ ####################################################################################################
24
+
25
+
26
+ def initialize(args)
27
+
28
+ @query_graph = args[:query].to_a
29
+ @data_graph = args[:data].to_a
30
+ @limit = args[:limit].to_i
31
+ @max_allowed_time = args[:max_allowed_time].to_f
32
+ @self_loops = args[:self_loops]
33
+ validate!
34
+
35
+ end
36
+
37
+ def getQueryGraph
38
+ @query_graph
39
+ end
40
+
41
+ def getDataGraph
42
+ @data_graph
43
+ end
44
+
45
+
46
+ ########################################################################
47
+ # Public interface for Graphmatcher class. #
48
+ # #
49
+ # @matches: Array of matching indices of query graph in data graph. #
50
+ ########################################################################
51
+
52
+ def getMatches
53
+ @matches = []
54
+ @t0 = Time.now.to_f
55
+ phi = self.labelMatch()
56
+ self.dualIso(self.dualSim(phi),0)
57
+ @@logger.info("FINISH matches=#{@matches}")
58
+ @matches
59
+ end
60
+
61
+ #Function for generating feasible matches for query
62
+ #graph based on labels of vertices of data graph.
63
+
64
+ def labelMatch()
65
+ data_labels=@data_graph[1]
66
+ query_labels=@query_graph[1]
67
+ feasible = query_labels.map.with_index { |ql,index|
68
+ data_labels.each_index.select {|i| data_labels[i] == ql }}
69
+ @@logger.info('Label matches(phi) are: '+feasible.to_s)
70
+ feasible
71
+ end
72
+
73
+
74
+ #INFO: Function that uses parameter phi -which is generated by labelMatch-
75
+ #to determine which matches of data graph have expected relations in query graph.
76
+ #phi = ...
77
+
78
+ def dualSim(phi)
79
+
80
+ # One directional adjacency array for data graph and query graphs.
81
+ data_children = @data_graph[0]
82
+ query_children = @query_graph[0]
83
+
84
+ @@logger.info("Data children: #{data_children.to_s}")
85
+ @@logger.info("Query children: #{query_children.to_s}")
86
+
87
+ changed = true
88
+ while (changed)
89
+ if (Time.now.to_f-@t0) > @max_allowed_time then return nil end
90
+ changed = false
91
+ # children = query_edges
92
+ # q_index = query_vertex_index
93
+ query_children.each_with_index { |children,q_index| ##loop 1
94
+ #query_child = query_edge_target
95
+ children.each { |query_child| ##loop 2
96
+ # Create a temporary phi object.
97
+ newPhi = []
98
+ # Loop over candidates of each vertex in data graph.
99
+
100
+ to_delete = []
101
+ phi[q_index].map { |child| #loop 3
102
+
103
+ @@logger.debug("Find intersection for u=#{q_index}, u_c=#{query_child}, child=#{child}")
104
+ @@logger.debug("LOOP OF #{q_index}-#{query_child}-#{child}")
105
+ # Find intersection of children of 'child' in data graph and
106
+ # candidates of 'query child' in data graph.
107
+ phiInt = data_children[child] & phi[query_child]
108
+ @@logger.debug("datachildren[child]=#{data_children[child]}")
109
+ @@logger.debug("phi[query_child]=#{phi[query_child]}")
110
+ @@logger.debug("Intersection=#{phiInt}")
111
+
112
+ if (phiInt.nil? || phiInt.empty?)
113
+ @@logger.debug("Before delete=#{phi[q_index]}")
114
+ phi[q_index].delete (child)
115
+ to_delete = [q_index,child]
116
+ @@logger.debug("After delete=#{phi[q_index]}")
117
+ @@logger.debug("After delete phi=#{phi}")
118
+ if phi[q_index].empty? then return phi end
119
+ changed = true
120
+ end
121
+ newPhi = newPhi | phiInt
122
+ } ##phi[q_index].each
123
+ if !to_delete.empty?
124
+ phi[to_delete[0]].delete(to_delete[1])
125
+ to_delete = []
126
+ end
127
+ if newPhi.flatten.empty? then ; return phi end
128
+ if newPhi.size < phi[query_child].size then
129
+ @@logger.debug("#{newPhi.flatten} <- newphi and #{phi[query_child]} <- phi_query_child - #{newPhi.flatten.size} vs #{phi[query_child].size}")
130
+ changed = true
131
+ end
132
+ if (@self_loops && query_child == q_index)
133
+ phi[query_child] &= newPhi;
134
+ else
135
+ @@logger.debug("phi=#{phi} and phi[#{query_child}]=#{newPhi}")
136
+ phi[query_child] = newPhi;
137
+ end
138
+ } ##children.each { |query_child|
139
+ }##query_children.each_with_index { |children,q_index|
140
+ end ##while
141
+ @@logger.info("Returning phi=#{phi}")
142
+ return phi
143
+ end
144
+
145
+ # INFO: Function call to collect matches from phi object.
146
+ # phi = ...
147
+ # depth = ...
148
+ # matches = ...
149
+
150
+ def dualIso(phi,depth)
151
+ if depth == @query_graph[0].length
152
+ @@logger.info("STATUS depth=#{depth}, matches=#{@matches}")
153
+ if not (phi.nil? || phi.empty?)
154
+ if phi.include?([]) #Unable to match this vertex in graph.
155
+ @matches << [nil]
156
+ else
157
+ @matches << phi
158
+ end
159
+ end
160
+ elsif not (phi.nil? || phi.empty?)
161
+ phi[depth].each_with_index { |value,index|
162
+ if not contains(phi,depth,value)
163
+ #keys are indices 0...n, values are possible values for that index
164
+ phicopy = phi.map { |i| i.clone }
165
+ @@logger.info("INSERT phicopy=#{phicopy},depth=#{depth},value=#{value},matches=#{@matches}")
166
+ phicopy[depth] = [value]
167
+ if (@matches.length >= @limit)
168
+ @@logger.info("FINISH matches=#{@matches}")
169
+ return @matches
170
+ end
171
+ dualIso(dualSim(phicopy),depth+1)
172
+ end
173
+ }
174
+ end
175
+ end
176
+
177
+ # INFO: Function that checks if vertex J is contained in any of previous matches.
178
+ # TODO: Change with find method.
179
+
180
+ def contains(phi,depth,j)
181
+ if not depth > 0 then false end
182
+ (0..depth-1).each { |i|
183
+ @@logger.info("phi[#{i}]=#{phi[i]},depth=#{depth},j=#{j}")
184
+ if phi[i].include?(j)
185
+ return true
186
+ end
187
+ }
188
+ return false
189
+ end
190
+
191
+ #EXPERIMENTAL
192
+ #INFO: Produce a GraphViz-compliant directed graph syntax.
193
+ #INFO: Needs dot/graphviz tools installed as a dependency.
194
+ #TODO: It is not able to handle multiple results, color each result different.
195
+ #Indices are IDs, labels are labels adjencency array is outgoing edges.
196
+
197
+ def dotGraph(data,subgraph=nil,prefix='')
198
+ tstamp = Time.now.to_i
199
+ output = ["digraph {"]
200
+ data.transpose.each_with_index { |node,id|
201
+ output << "#{id} [label=\"#{node[1]}\"]" #vertex
202
+ output << "#{id}->{#{node[0].join(' ')}}" #edge
203
+ }
204
+ if subgraph
205
+ subgraph.transpose.each_with_index { |node,id|
206
+ output << "#{node.flatten.first} [fontcolor=\"Red\"]" #vertex
207
+ }
208
+ end
209
+ output << "}"
210
+ File.write("#{prefix}#{tstamp}.dot",output.join("\n"))
211
+ dot_produce = ["dot","-Tpng","#{prefix}#{tstamp}.dot","-o","#{prefix}#{tstamp}.png"].join(" ")
212
+ `#{dot_produce}`
213
+ end
214
+
215
+ def validate!
216
+ if !(@query_graph.length>=2 && @data_graph.length>=2)
217
+ raise ArgumentError.new("Input graphs must have at least two dimensions !")
218
+ end
219
+ if !(@query_graph.map {|s| s.length}.uniq.length==1 && @data_graph.map {|s|s.length}.uniq.length==1)
220
+ raise ArgumentError.new("Input graphs adjencency and label arrays must be sized equal !")
221
+ end
222
+ end
223
+
224
+ end
@@ -0,0 +1,217 @@
1
+ require 'logger'
2
+ require 'set'
3
+
4
+ # GraphmatcherVF2pp
5
+ #
6
+ # A drop-in alternative to Graphmatcher (the DualIso implementation) that solves
7
+ # the same labeled subgraph-isomorphism problem using the VF2++ algorithm
8
+ # (Jüttner & Madarasi, 2018), which is itself a refinement of VF2
9
+ # (Cordella et al., 2004).
10
+ #
11
+ # -------------------------------------------------------------------------------
12
+ # I/O CONTRACT (identical to Graphmatcher)
13
+ # -------------------------------------------------------------------------------
14
+ # Construction (keyword args):
15
+ # GraphmatcherVF2pp.new(query:, data:, limit:, max_allowed_time:, self_loops:)
16
+ #
17
+ # query / data : [adjacency, labels] (an optional 3rd row of ids is ignored).
18
+ # adjacency[i] = array of OUTGOING edge targets of vertex i.
19
+ # labels[i] = label of vertex i (compared with ==).
20
+ # limit : upper bound on number of matches to return (0/nil => unlimited).
21
+ # max_allowed_time : wall-clock budget in seconds (0/nil => unlimited).
22
+ # self_loops : accepted for API compatibility; VF2++ handles self-loops and
23
+ # cycles natively regardless of this flag.
24
+ #
25
+ # getMatches returns an Array of matches. Each match has one entry per query
26
+ # vertex, narrowed to a single data vertex:
27
+ # [[d0],[d1],...,[dn]] meaning query vertex i -> data vertex di.
28
+ # This is byte-compatible with the successful output shape of Graphmatcher.
29
+ #
30
+ # SEMANTICS: subgraph isomorphism under an injective mapping f where
31
+ # * labels match: query_labels[u] == data_labels[f(u)]
32
+ # * every query edge u->v maps to a data edge f(u)->f(v)
33
+ # (Extra edges in the data graph are allowed; this matches the DAG behaviour of
34
+ # the original Graphmatcher, but — unlike it — also works on cyclic queries.)
35
+ class GraphmatcherVF2pp
36
+
37
+ VERSION = "0.1.0".freeze
38
+
39
+ @@logger = Logger.new(STDOUT)
40
+ @@logger.level = Logger::INFO
41
+
42
+ def self.logger
43
+ @@logger
44
+ end
45
+
46
+ def initialize(args)
47
+ @query_graph = args[:query].to_a
48
+ @data_graph = args[:data].to_a
49
+ @limit = args[:limit].to_i
50
+ @max_allowed_time = args[:max_allowed_time].to_f
51
+ @self_loops = args[:self_loops]
52
+ validate!
53
+
54
+ @q_adj = @query_graph[0]
55
+ @q_lab = @query_graph[1]
56
+ @d_adj = @data_graph[0]
57
+ @d_lab = @data_graph[1]
58
+ @qn = @q_adj.length
59
+ @dn = @d_adj.length
60
+
61
+ # Reverse adjacency (incoming edges) for both graphs — VF2++ checks
62
+ # consistency in both directions.
63
+ @q_pred = build_predecessors(@q_adj, @qn)
64
+ @d_pred = build_predecessors(@d_adj, @dn)
65
+
66
+ # Out/in degree of data vertices, used as a cheap candidate filter.
67
+ @d_outdeg = @d_adj.map(&:length)
68
+ @d_indeg = @d_pred.map(&:length)
69
+ end
70
+
71
+ def getMatches
72
+ @matches = []
73
+ @t0 = Time.now.to_f
74
+
75
+ # Precompute per-query-vertex candidate lists (label + degree pruning).
76
+ @candidates = precompute_candidates
77
+ # If any query vertex has no candidate, there can be no match.
78
+ return @matches if @candidates.any?(&:empty?)
79
+
80
+ # VF2++ matching order: process the most constrained query vertices first.
81
+ @order = matching_order
82
+
83
+ # core_q[u] = data vertex mapped to query vertex u (or nil).
84
+ # core_d[d] = query vertex mapped to data vertex d (or nil).
85
+ @core_q = Array.new(@qn, nil)
86
+ @core_d = Array.new(@dn, nil)
87
+
88
+ backtrack(0)
89
+ @@logger.info("FINISH matches=#{@matches.size}")
90
+ @matches
91
+ end
92
+
93
+ private
94
+
95
+ def timed_out?
96
+ @max_allowed_time > 0 && (Time.now.to_f - @t0) > @max_allowed_time
97
+ end
98
+
99
+ def limit_reached?
100
+ @limit > 0 && @matches.length >= @limit
101
+ end
102
+
103
+ def build_predecessors(adj, n)
104
+ pred = Array.new(n) { [] }
105
+ adj.each_with_index do |children, u|
106
+ children.each { |v| pred[v] << u }
107
+ end
108
+ pred
109
+ end
110
+
111
+ # Candidate set for each query vertex: same label, and enough out/in degree
112
+ # in the data graph to possibly satisfy the query vertex's edges.
113
+ def precompute_candidates
114
+ @q_adj.each_index.map do |u|
115
+ qout = @q_adj[u].length
116
+ qin = @q_pred[u].length
117
+ ql = @q_lab[u]
118
+ (0...@dn).select do |d|
119
+ @d_lab[d] == ql && @d_outdeg[d] >= qout && @d_indeg[d] >= qin
120
+ end
121
+ end
122
+ end
123
+
124
+ # VF2++ node ordering: greedily build an order that keeps each next vertex
125
+ # maximally connected to the already-ordered set, breaking ties by fewest
126
+ # candidates then highest degree. This front-loads the most constraining
127
+ # vertices and prunes the search tree early.
128
+ def matching_order
129
+ remaining = (0...@qn).to_a
130
+ order = []
131
+ connectivity = Array.new(@qn, 0) # edges (either direction) to ordered set
132
+
133
+ until remaining.empty?
134
+ best = remaining.min_by do |u|
135
+ [
136
+ -connectivity[u], # most connections to ordered set first
137
+ @candidates[u].length, # fewest candidates first
138
+ -(@q_adj[u].length + @q_pred[u].length) # highest total degree first
139
+ ]
140
+ end
141
+ order << best
142
+ remaining.delete(best)
143
+ # Increase connectivity of best's neighbours still to be ordered.
144
+ (@q_adj[best] + @q_pred[best]).each do |nb|
145
+ connectivity[nb] += 1 if remaining.include?(nb)
146
+ end
147
+ end
148
+ order
149
+ end
150
+
151
+ def backtrack(depth)
152
+ return if timed_out? || limit_reached?
153
+
154
+ if depth == @qn
155
+ record_match
156
+ return
157
+ end
158
+
159
+ u = @order[depth]
160
+ @candidates[u].each do |d|
161
+ break if timed_out? || limit_reached?
162
+ next unless @core_d[d].nil? # injective: d not already used
163
+ next unless feasible?(u, d)
164
+
165
+ @core_q[u] = d
166
+ @core_d[d] = u
167
+ backtrack(depth + 1)
168
+ @core_q[u] = nil
169
+ @core_d[d] = nil
170
+ end
171
+ end
172
+
173
+ # Feasibility: with u tentatively mapped to d, every query edge incident to u
174
+ # whose other endpoint is already mapped must have a corresponding data edge.
175
+ def feasible?(u, d)
176
+ # Self-loop consistency.
177
+ if @q_adj[u].include?(u)
178
+ return false unless @d_adj[d].include?(d)
179
+ end
180
+
181
+ # Outgoing query edges u -> v (v already mapped) require d -> f(v).
182
+ @q_adj[u].each do |v|
183
+ next if v == u
184
+ fv = @core_q[v]
185
+ next if fv.nil?
186
+ return false unless @d_adj[d].include?(fv)
187
+ end
188
+
189
+ # Incoming query edges w -> u (w already mapped) require f(w) -> d.
190
+ @q_pred[u].each do |w|
191
+ next if w == u
192
+ fw = @core_q[w]
193
+ next if fw.nil?
194
+ return false unless @d_adj[fw].include?(d)
195
+ end
196
+
197
+ true
198
+ end
199
+
200
+ def record_match
201
+ # Emit in the Graphmatcher phi shape: one [d] per query vertex, indexed by
202
+ # query vertex (NOT by matching order).
203
+ phi = Array.new(@qn) { |u| [@core_q[u]] }
204
+ @@logger.info("INSERT match=#{phi}")
205
+ @matches << phi
206
+ end
207
+
208
+ def validate!
209
+ if !(@query_graph.length >= 2 && @data_graph.length >= 2)
210
+ raise ArgumentError.new("Input graphs must have at least two dimensions !")
211
+ end
212
+ if !(@query_graph.map { |s| s.length }.uniq.length == 1 && @data_graph.map { |s| s.length }.uniq.length == 1)
213
+ raise ArgumentError.new("Input graphs adjencency and label arrays must be sized equal !")
214
+ end
215
+ end
216
+
217
+ end # GraphmatcherVF2pp
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphmatcher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emre Unlu
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-20 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -16,69 +16,92 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.10'
19
+ version: '2.0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.10'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '10.0'
33
+ version: '13.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '10.0'
40
+ version: '13.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: '3.0'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - ">="
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
54
+ version: '3.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: ruby-prof
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - ">="
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.4'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.4'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-prof
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '0'
75
+ version: 0.0.7
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
- - - ">="
80
+ - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '0'
69
- description: An effective subgraph matching gem based on DualIso algorithm of M. Saltz
70
- et al.
82
+ version: 0.0.7
83
+ description: 'A subgraph matching library offering two interchangeable matchers over
84
+ the same [adjacency, labels] input: a DualIso implementation (after M. Saltz et
85
+ al.) and a VF2++ implementation (Juttner & Madarasi) that also handles cyclic queries
86
+ and self-loops.'
71
87
  email:
72
88
  - emre@eunlu.com
73
89
  executables: []
74
90
  extensions: []
75
91
  extra_rdoc_files: []
76
- files: []
77
- homepage: https://github.com/forvelin/graphmatcher
92
+ files:
93
+ - LICENSE.txt
94
+ - README.md
95
+ - lib/graphmatcher.rb
96
+ - lib/graphmatcher_vf2pp.rb
97
+ homepage: https://gitlab.com/forvelin/graphmatcher
78
98
  licenses:
79
99
  - MIT
80
- metadata: {}
81
- post_install_message:
100
+ metadata:
101
+ homepage_uri: https://gitlab.com/forvelin/graphmatcher
102
+ source_code_uri: https://gitlab.com/forvelin/graphmatcher
103
+ bug_tracker_uri: https://gitlab.com/forvelin/graphmatcher/-/issues
104
+ post_install_message:
82
105
  rdoc_options: []
83
106
  require_paths:
84
107
  - lib
@@ -86,15 +109,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
109
  requirements:
87
110
  - - ">="
88
111
  - !ruby/object:Gem::Version
89
- version: '0'
112
+ version: '2.6'
90
113
  required_rubygems_version: !ruby/object:Gem::Requirement
91
114
  requirements:
92
115
  - - ">="
93
116
  - !ruby/object:Gem::Version
94
117
  version: '0'
95
118
  requirements: []
96
- rubygems_version: 3.1.4
97
- signing_key:
119
+ rubygems_version: 3.4.9
120
+ signing_key:
98
121
  specification_version: 4
99
- summary: Subgraph matching based on DualIso algorithm.
122
+ summary: Labeled subgraph matching (DualIso and VF2++).
100
123
  test_files: []