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 +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/lib/graphmatcher.rb +224 -0
- data/lib/graphmatcher_vf2pp.rb +217 -0
- metadata +48 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 825a8f82e234efc81b2de40f274f2dd60619bdaa797e65c303675a1b5a3c2a69
|
|
4
|
+
data.tar.gz: f9e114404851c37c7cd3bfefc84fe04ec8e3e11c63d21c7dd695ebff1a02e959
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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).
|
data/lib/graphmatcher.rb
ADDED
|
@@ -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.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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:
|
|
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:
|
|
69
|
-
description:
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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.
|
|
97
|
-
signing_key:
|
|
119
|
+
rubygems_version: 3.4.9
|
|
120
|
+
signing_key:
|
|
98
121
|
specification_version: 4
|
|
99
|
-
summary:
|
|
122
|
+
summary: Labeled subgraph matching (DualIso and VF2++).
|
|
100
123
|
test_files: []
|