orfeas_petri_flow 0.6.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 +7 -0
- data/CHANGELOG.md +80 -0
- data/MIT-LICENSE +22 -0
- data/README.md +592 -0
- data/Rakefile +28 -0
- data/lib/petri_flow/colored/arc_expression.rb +163 -0
- data/lib/petri_flow/colored/color.rb +40 -0
- data/lib/petri_flow/colored/colored_net.rb +146 -0
- data/lib/petri_flow/colored/guard.rb +104 -0
- data/lib/petri_flow/core/arc.rb +63 -0
- data/lib/petri_flow/core/marking.rb +64 -0
- data/lib/petri_flow/core/net.rb +121 -0
- data/lib/petri_flow/core/place.rb +54 -0
- data/lib/petri_flow/core/token.rb +55 -0
- data/lib/petri_flow/core/transition.rb +88 -0
- data/lib/petri_flow/export/cpn_tools_exporter.rb +322 -0
- data/lib/petri_flow/export/json_exporter.rb +224 -0
- data/lib/petri_flow/export/pnml_exporter.rb +229 -0
- data/lib/petri_flow/export/yaml_exporter.rb +246 -0
- data/lib/petri_flow/export.rb +193 -0
- data/lib/petri_flow/generators/adapters/aasm_adapter.rb +69 -0
- data/lib/petri_flow/generators/adapters/state_machines_adapter.rb +83 -0
- data/lib/petri_flow/generators/state_machine_adapter.rb +47 -0
- data/lib/petri_flow/generators/workflow_generator.rb +176 -0
- data/lib/petri_flow/matrix/analyzer.rb +151 -0
- data/lib/petri_flow/matrix/causation.rb +126 -0
- data/lib/petri_flow/matrix/correlation.rb +79 -0
- data/lib/petri_flow/matrix/crud_event_mapping.rb +74 -0
- data/lib/petri_flow/matrix/lineage.rb +113 -0
- data/lib/petri_flow/matrix/reachability.rb +128 -0
- data/lib/petri_flow/railtie.rb +41 -0
- data/lib/petri_flow/registry.rb +85 -0
- data/lib/petri_flow/simulation/simulator.rb +188 -0
- data/lib/petri_flow/simulation/trace.rb +119 -0
- data/lib/petri_flow/tasks/petri_flow.rake +229 -0
- data/lib/petri_flow/verification/boundedness_checker.rb +127 -0
- data/lib/petri_flow/verification/invariant_checker.rb +144 -0
- data/lib/petri_flow/verification/liveness_checker.rb +153 -0
- data/lib/petri_flow/verification/reachability_analyzer.rb +152 -0
- data/lib/petri_flow/verification_runner.rb +287 -0
- data/lib/petri_flow/version.rb +5 -0
- data/lib/petri_flow/visualization/graphviz.rb +220 -0
- data/lib/petri_flow/visualization/mermaid.rb +191 -0
- data/lib/petri_flow/workflow.rb +228 -0
- data/lib/petri_flow.rb +164 -0
- metadata +174 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module PetriFlow
|
|
7
|
+
# Runs verification on registered workflows and generates reports.
|
|
8
|
+
# Reports are saved to reports/petri_flow_<timestamp>/ directory.
|
|
9
|
+
#
|
|
10
|
+
# @example Run verification for all workflows
|
|
11
|
+
# runner = PetriFlow::VerificationRunner.new
|
|
12
|
+
# runner.run_all
|
|
13
|
+
#
|
|
14
|
+
# @example Run for a specific workflow
|
|
15
|
+
# runner.run_workflow(RefundRequestWorkflow)
|
|
16
|
+
#
|
|
17
|
+
# @example Run by workflow name
|
|
18
|
+
# runner.run_by_name("RefundRequestWorkflow")
|
|
19
|
+
#
|
|
20
|
+
class VerificationRunner
|
|
21
|
+
attr_reader :reports_dir, :results
|
|
22
|
+
|
|
23
|
+
# @param base_dir [String] Base directory for reports (default: Rails.root or current dir)
|
|
24
|
+
# @param output [IO] Output stream for console messages (default: $stdout)
|
|
25
|
+
def initialize(base_dir: nil, output: $stdout)
|
|
26
|
+
@base_dir = base_dir || default_base_dir
|
|
27
|
+
@timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
28
|
+
@reports_dir = File.join(@base_dir, "reports", "petri_flow_#{@timestamp}")
|
|
29
|
+
@output = output
|
|
30
|
+
@results = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run verification for all registered workflows
|
|
34
|
+
# @return [Hash] Results for all workflows
|
|
35
|
+
def run_all
|
|
36
|
+
ensure_reports_dir
|
|
37
|
+
print_header
|
|
38
|
+
|
|
39
|
+
Registry.all.each do |workflow_class|
|
|
40
|
+
run_workflow(workflow_class)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
generate_summary_report
|
|
44
|
+
print_footer
|
|
45
|
+
|
|
46
|
+
@results
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Run verification for a specific workflow class
|
|
50
|
+
# @param workflow_class [Class] The workflow class to verify
|
|
51
|
+
# @return [Hash] Verification results
|
|
52
|
+
def run_workflow(workflow_class)
|
|
53
|
+
workflow = workflow_class.new
|
|
54
|
+
workflow_id = workflow.workflow_id
|
|
55
|
+
|
|
56
|
+
puts "\n" + "=" * 70
|
|
57
|
+
puts " #{workflow_class.name.upcase}"
|
|
58
|
+
puts "=" * 70
|
|
59
|
+
|
|
60
|
+
results = workflow.verify!
|
|
61
|
+
@results[workflow_id] = {
|
|
62
|
+
class_name: workflow_class.name,
|
|
63
|
+
workflow_name: workflow.workflow_name,
|
|
64
|
+
verification: results,
|
|
65
|
+
terminal_reachability: workflow.terminal_reachability.dup
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export_workflow_diagrams(workflow)
|
|
69
|
+
print_verification_summary(workflow)
|
|
70
|
+
|
|
71
|
+
results
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Run verification for a workflow by name
|
|
75
|
+
# @param name [String] The workflow class name
|
|
76
|
+
# @return [Hash] Verification results
|
|
77
|
+
# @raise [RuntimeError] If workflow not found
|
|
78
|
+
def run_by_name(name)
|
|
79
|
+
workflow_class = Registry.get(name)
|
|
80
|
+
raise "Workflow '#{name}' not found in registry" unless workflow_class
|
|
81
|
+
|
|
82
|
+
ensure_reports_dir
|
|
83
|
+
print_header
|
|
84
|
+
run_workflow(workflow_class)
|
|
85
|
+
generate_summary_report
|
|
86
|
+
print_footer
|
|
87
|
+
|
|
88
|
+
@results
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def default_base_dir
|
|
94
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
95
|
+
Rails.root.to_s
|
|
96
|
+
else
|
|
97
|
+
Dir.pwd
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ensure_reports_dir
|
|
102
|
+
FileUtils.mkdir_p(@reports_dir)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def puts(message = "")
|
|
106
|
+
@output.puts(message)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def print_header
|
|
110
|
+
puts
|
|
111
|
+
puts "╔═══════════════════════════════════════════════════════════════════════╗"
|
|
112
|
+
puts "║ ║"
|
|
113
|
+
puts "║ PETRIFLOW VERIFICATION ║"
|
|
114
|
+
puts "║ Formal Verification of Business Workflows ║"
|
|
115
|
+
puts "║ ║"
|
|
116
|
+
puts "╚═══════════════════════════════════════════════════════════════════════╝"
|
|
117
|
+
puts
|
|
118
|
+
puts " PetriFlow Version: #{PetriFlow::VERSION}"
|
|
119
|
+
puts " Timestamp: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
120
|
+
puts " Workflows: #{Registry.count}"
|
|
121
|
+
puts " Output: #{@reports_dir}"
|
|
122
|
+
puts
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def print_footer
|
|
126
|
+
puts
|
|
127
|
+
puts "╔═══════════════════════════════════════════════════════════════════════╗"
|
|
128
|
+
puts "║ VERIFICATION COMPLETE ║"
|
|
129
|
+
puts "║ ║"
|
|
130
|
+
puts "║ Reports saved to: #{@reports_dir.split('/').last.ljust(49)}║"
|
|
131
|
+
puts "╚═══════════════════════════════════════════════════════════════════════╝"
|
|
132
|
+
puts
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def print_verification_summary(workflow)
|
|
136
|
+
results = workflow.verification_results
|
|
137
|
+
reachability = results[:reachability]
|
|
138
|
+
boundedness = results[:boundedness]
|
|
139
|
+
liveness = results[:liveness]
|
|
140
|
+
|
|
141
|
+
puts "\n┌─ Verification Results ──────────────────────────────────────────────┐"
|
|
142
|
+
puts "│ Reachable states: #{reachability[:total_reachable_states].to_s.ljust(50)}│"
|
|
143
|
+
puts "│ Terminal states: #{reachability[:terminal_states].to_s.ljust(51)}│"
|
|
144
|
+
puts "│ Is safe (1-bounded): #{boundedness[:is_safe].to_s.ljust(47)}│"
|
|
145
|
+
puts "│ Deadlock-free: #{liveness[:deadlock_free].to_s.ljust(53)}│"
|
|
146
|
+
puts "└─────────────────────────────────────────────────────────────────────┘"
|
|
147
|
+
|
|
148
|
+
puts "\n Terminal State Reachability:"
|
|
149
|
+
workflow.terminal_reachability.each do |state, reachable|
|
|
150
|
+
status = reachable ? "✓ REACHABLE" : "✗ UNREACHABLE"
|
|
151
|
+
puts " #{state.to_s.ljust(20)} #{status}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def export_workflow_diagrams(workflow)
|
|
156
|
+
workflow_id = workflow.workflow_id
|
|
157
|
+
ensure_reports_dir
|
|
158
|
+
|
|
159
|
+
# Mermaid
|
|
160
|
+
mermaid_file = File.join(@reports_dir, "#{workflow_id}_petri.mmd")
|
|
161
|
+
File.write(mermaid_file, workflow.to_mermaid)
|
|
162
|
+
|
|
163
|
+
# DOT
|
|
164
|
+
dot_file = File.join(@reports_dir, "#{workflow_id}_petri.dot")
|
|
165
|
+
File.write(dot_file, workflow.to_dot)
|
|
166
|
+
|
|
167
|
+
# Generate images if GraphViz available
|
|
168
|
+
generate_graphviz_images(dot_file, workflow_id) if graphviz_available?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def graphviz_available?
|
|
172
|
+
system("which dot > /dev/null 2>&1")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def generate_graphviz_images(dot_file, workflow_id)
|
|
176
|
+
%w[png pdf svg].each do |format|
|
|
177
|
+
output_file = File.join(@reports_dir, "#{workflow_id}_petri.#{format}")
|
|
178
|
+
system("dot -T#{format} #{dot_file} -o #{output_file} 2>/dev/null")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def generate_summary_report
|
|
183
|
+
# Generate individual report for each workflow
|
|
184
|
+
@results.each do |workflow_id, data|
|
|
185
|
+
generate_workflow_report(workflow_id, data)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Generate summary report
|
|
189
|
+
report_file = File.join(@reports_dir, "verification_report.md")
|
|
190
|
+
content = build_summary_report
|
|
191
|
+
File.write(report_file, content)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def generate_workflow_report(workflow_id, data)
|
|
195
|
+
report_file = File.join(@reports_dir, "#{workflow_id}.md")
|
|
196
|
+
content = build_workflow_report(workflow_id, data)
|
|
197
|
+
File.write(report_file, content)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_workflow_report(workflow_id, data)
|
|
201
|
+
verification = data[:verification]
|
|
202
|
+
all_reachable = data[:terminal_reachability].values.all?
|
|
203
|
+
status_icon = all_reachable ? "✓" : "✗"
|
|
204
|
+
|
|
205
|
+
lines = []
|
|
206
|
+
lines << "# #{data[:workflow_name]}"
|
|
207
|
+
lines << ""
|
|
208
|
+
lines << "**Generated:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
209
|
+
lines << "**PetriFlow Version:** #{PetriFlow::VERSION}"
|
|
210
|
+
lines << "**Status:** #{status_icon} #{all_reachable ? 'All terminal states reachable' : 'UNREACHABLE STATES DETECTED'}"
|
|
211
|
+
lines << ""
|
|
212
|
+
lines << "---"
|
|
213
|
+
lines << ""
|
|
214
|
+
lines << "## Verification Results"
|
|
215
|
+
lines << ""
|
|
216
|
+
lines << "| Property | Value |"
|
|
217
|
+
lines << "|----------|-------|"
|
|
218
|
+
lines << "| Reachable states | #{verification[:reachability][:total_reachable_states]} |"
|
|
219
|
+
lines << "| Terminal states | #{verification[:reachability][:terminal_states]} |"
|
|
220
|
+
lines << "| Is bounded | #{verification[:boundedness][:is_bounded]} |"
|
|
221
|
+
lines << "| Is safe (1-bounded) | #{verification[:boundedness][:is_safe]} |"
|
|
222
|
+
lines << "| Deadlock-free | #{verification[:liveness][:deadlock_free]} |"
|
|
223
|
+
lines << ""
|
|
224
|
+
lines << "---"
|
|
225
|
+
lines << ""
|
|
226
|
+
lines << "## Terminal State Reachability"
|
|
227
|
+
lines << ""
|
|
228
|
+
|
|
229
|
+
data[:terminal_reachability].each do |state, reachable|
|
|
230
|
+
icon = reachable ? "✓" : "✗"
|
|
231
|
+
status = reachable ? "Reachable" : "**UNREACHABLE**"
|
|
232
|
+
lines << "| #{icon} | **#{state}** | #{status} |"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
lines << ""
|
|
236
|
+
|
|
237
|
+
unless all_reachable
|
|
238
|
+
lines << ""
|
|
239
|
+
lines << "> **WARNING:** Some terminal states are unreachable from the initial state."
|
|
240
|
+
lines << "> This indicates a workflow design issue that should be investigated."
|
|
241
|
+
lines << ""
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
lines << "---"
|
|
245
|
+
lines << ""
|
|
246
|
+
lines << "## Petri Net Diagram"
|
|
247
|
+
lines << ""
|
|
248
|
+
lines << "![#{data[:workflow_name]} Petri Net](#{workflow_id}_petri.png)"
|
|
249
|
+
lines << ""
|
|
250
|
+
lines << "---"
|
|
251
|
+
lines << ""
|
|
252
|
+
lines << "*Generated by PetriFlow verification tool*"
|
|
253
|
+
lines.join("\n")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def build_summary_report
|
|
257
|
+
lines = []
|
|
258
|
+
lines << "# PetriFlow Verification Summary"
|
|
259
|
+
lines << ""
|
|
260
|
+
lines << "**Generated:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
261
|
+
lines << "**PetriFlow Version:** #{PetriFlow::VERSION}"
|
|
262
|
+
lines << "**Workflows Verified:** #{@results.count}"
|
|
263
|
+
lines << ""
|
|
264
|
+
lines << "---"
|
|
265
|
+
lines << ""
|
|
266
|
+
lines << "## Results"
|
|
267
|
+
lines << ""
|
|
268
|
+
lines << "| Workflow | States | Safe | Terminals Reachable | Report |"
|
|
269
|
+
lines << "|----------|--------|------|---------------------|--------|"
|
|
270
|
+
|
|
271
|
+
@results.each do |workflow_id, data|
|
|
272
|
+
reachability = data[:verification][:reachability]
|
|
273
|
+
boundedness = data[:verification][:boundedness]
|
|
274
|
+
all_reachable = data[:terminal_reachability].values.all?
|
|
275
|
+
status_icon = all_reachable ? "✓" : "✗"
|
|
276
|
+
|
|
277
|
+
lines << "| #{data[:workflow_name]} | #{reachability[:total_reachable_states]} | #{boundedness[:is_safe]} | #{status_icon} #{all_reachable} | [View](#{workflow_id}.md) |"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
lines << ""
|
|
281
|
+
lines << "---"
|
|
282
|
+
lines << ""
|
|
283
|
+
lines << "*Generated by PetriFlow verification tool*"
|
|
284
|
+
lines.join("\n")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module PetriFlow
|
|
6
|
+
module Visualization
|
|
7
|
+
# GraphViz visualization for Petri nets
|
|
8
|
+
# Generates DOT format for rendering with Graphviz
|
|
9
|
+
class Graphviz
|
|
10
|
+
attr_reader :net, :options
|
|
11
|
+
|
|
12
|
+
DEFAULT_OPTIONS = {
|
|
13
|
+
place_color: "lightblue",
|
|
14
|
+
transition_color: "lightgreen",
|
|
15
|
+
marked_place_color: "yellow",
|
|
16
|
+
enabled_transition_color: "lightcoral",
|
|
17
|
+
show_token_count: true,
|
|
18
|
+
show_arc_weights: true,
|
|
19
|
+
show_pattern_labels: true, # Label fork/choice patterns
|
|
20
|
+
layout: "dot", # dot, neato, fdp, circo
|
|
21
|
+
rankdir: "TB" # TB (top-bottom), LR (left-right)
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(net, options = {})
|
|
25
|
+
@net = net
|
|
26
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
27
|
+
analyze_patterns if @options[:show_pattern_labels]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generate DOT format
|
|
31
|
+
def to_dot
|
|
32
|
+
dot = []
|
|
33
|
+
dot << "digraph #{sanitize_name(@net.name)} {"
|
|
34
|
+
dot << " rankdir=#{@options[:rankdir]};"
|
|
35
|
+
dot << " node [fontname=\"Helvetica\"];"
|
|
36
|
+
dot << " edge [fontname=\"Helvetica\"];"
|
|
37
|
+
dot << ""
|
|
38
|
+
|
|
39
|
+
# Places
|
|
40
|
+
dot << " // Places"
|
|
41
|
+
@net.places.each do |id, place|
|
|
42
|
+
dot << place_node(id, place)
|
|
43
|
+
end
|
|
44
|
+
dot << ""
|
|
45
|
+
|
|
46
|
+
# Transitions
|
|
47
|
+
dot << " // Transitions"
|
|
48
|
+
@net.transitions.each do |id, transition|
|
|
49
|
+
dot << transition_node(id, transition)
|
|
50
|
+
end
|
|
51
|
+
dot << ""
|
|
52
|
+
|
|
53
|
+
# Arcs
|
|
54
|
+
dot << " // Arcs"
|
|
55
|
+
@net.arcs.each do |arc|
|
|
56
|
+
dot << arc_edge(arc)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
dot << "}"
|
|
60
|
+
dot.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Save DOT file
|
|
64
|
+
def save_dot(filename)
|
|
65
|
+
File.write(filename, to_dot)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generate and render to image (requires graphviz installed)
|
|
69
|
+
def render(output_file, format: "png")
|
|
70
|
+
require 'open3'
|
|
71
|
+
|
|
72
|
+
dot_content = to_dot
|
|
73
|
+
cmd = "#{@options[:layout]} -T#{format} -o #{output_file}"
|
|
74
|
+
|
|
75
|
+
stdout, stderr, status = Open3.capture3(cmd, stdin_data: dot_content)
|
|
76
|
+
|
|
77
|
+
unless status.success?
|
|
78
|
+
raise "Graphviz rendering failed: #{stderr}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
output_file
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Generate ASCII art representation
|
|
85
|
+
def to_ascii
|
|
86
|
+
ascii = []
|
|
87
|
+
ascii << "=" * 60
|
|
88
|
+
ascii << "Petri Net: #{@net.name}"
|
|
89
|
+
ascii << "=" * 60
|
|
90
|
+
ascii << ""
|
|
91
|
+
|
|
92
|
+
ascii << "Places:"
|
|
93
|
+
@net.places.each do |id, place|
|
|
94
|
+
tokens = "●" * place.tokens
|
|
95
|
+
ascii << " #{place.name} [#{id}]: #{tokens} (#{place.tokens} token#{'s' unless place.tokens == 1})"
|
|
96
|
+
end
|
|
97
|
+
ascii << ""
|
|
98
|
+
|
|
99
|
+
ascii << "Transitions:"
|
|
100
|
+
@net.transitions.each do |id, transition|
|
|
101
|
+
enabled = transition.enabled? ? "✓" : "✗"
|
|
102
|
+
ascii << " #{enabled} #{transition.name} [#{id}]"
|
|
103
|
+
transition.input_arcs.each do |arc|
|
|
104
|
+
ascii << " ← #{arc.source.name} (weight: #{arc.weight})"
|
|
105
|
+
end
|
|
106
|
+
transition.output_arcs.each do |arc|
|
|
107
|
+
ascii << " → #{arc.target.name} (weight: #{arc.weight})"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
ascii << ""
|
|
111
|
+
|
|
112
|
+
ascii << "=" * 60
|
|
113
|
+
ascii.join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def place_node(id, place)
|
|
119
|
+
tokens = place.tokens
|
|
120
|
+
color = tokens > 0 ? @options[:marked_place_color] : @options[:place_color]
|
|
121
|
+
|
|
122
|
+
label = if @options[:show_token_count] && tokens > 0
|
|
123
|
+
"#{place.name}\\n(#{tokens})"
|
|
124
|
+
else
|
|
125
|
+
place.name
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
" #{node_id(:place, id)} [shape=circle, label=\"#{label}\", style=filled, fillcolor=\"#{color}\"];"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def transition_node(id, transition)
|
|
132
|
+
enabled = transition.enabled?
|
|
133
|
+
color = enabled ? @options[:enabled_transition_color] : @options[:transition_color]
|
|
134
|
+
|
|
135
|
+
label = transition.name
|
|
136
|
+
label += "\\n[enabled]" if enabled && @options[:show_enabled]
|
|
137
|
+
|
|
138
|
+
" #{node_id(:transition, id)} [shape=box, label=\"#{label}\", style=filled, fillcolor=\"#{color}\"];"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Analyze the net to identify fork and choice patterns
|
|
142
|
+
def analyze_patterns
|
|
143
|
+
@fork_transitions = Set.new
|
|
144
|
+
@choice_places = Set.new
|
|
145
|
+
|
|
146
|
+
# Find fork transitions: transitions with multiple output arcs
|
|
147
|
+
@net.transitions.each do |id, transition|
|
|
148
|
+
output_arcs = @net.arcs.select { |arc| arc.source == transition }
|
|
149
|
+
if output_arcs.size > 1
|
|
150
|
+
@fork_transitions.add(id)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Find choice places: places with multiple outgoing transitions
|
|
155
|
+
@net.places.each do |id, place|
|
|
156
|
+
outgoing_arcs = @net.arcs.select { |arc| arc.source == place }
|
|
157
|
+
if outgoing_arcs.size > 1
|
|
158
|
+
@choice_places.add(id)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def arc_edge(arc)
|
|
164
|
+
source_id = if arc.source.is_a?(Core::Place)
|
|
165
|
+
node_id(:place, arc.source.id)
|
|
166
|
+
else
|
|
167
|
+
node_id(:transition, arc.source.id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
target_id = if arc.target.is_a?(Core::Place)
|
|
171
|
+
node_id(:place, arc.target.id)
|
|
172
|
+
else
|
|
173
|
+
node_id(:transition, arc.target.id)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
label = arc_label(arc)
|
|
177
|
+
|
|
178
|
+
" #{source_id} -> #{target_id}#{label};"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def arc_label(arc)
|
|
182
|
+
labels = []
|
|
183
|
+
|
|
184
|
+
# Check for fork/choice patterns
|
|
185
|
+
if @options[:show_pattern_labels]
|
|
186
|
+
if arc.source.is_a?(Core::Transition) && @fork_transitions&.include?(arc.source.id)
|
|
187
|
+
# Fork: show target place name
|
|
188
|
+
target_name = arc.target.name.to_s.split('_').map(&:capitalize).join(' ')
|
|
189
|
+
labels << "∥ #{target_name}"
|
|
190
|
+
elsif arc.source.is_a?(Core::Place) && @choice_places&.include?(arc.source.id)
|
|
191
|
+
# Choice: show where this transition leads
|
|
192
|
+
transition = arc.target
|
|
193
|
+
output_arcs = @net.arcs.select { |a| a.source == transition }
|
|
194
|
+
if output_arcs.any?
|
|
195
|
+
targets = output_arcs.map { |a| a.target.name.to_s.split('_').map(&:capitalize).join(' ') }
|
|
196
|
+
labels << "⊕ → #{targets.join(', ')}"
|
|
197
|
+
else
|
|
198
|
+
labels << "⊕"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check for arc weights
|
|
204
|
+
if @options[:show_arc_weights] && arc.weight > 1
|
|
205
|
+
labels << arc.weight.to_s
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
labels.empty? ? "" : " [label=\"#{labels.join(' ')}\"]"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def node_id(type, id)
|
|
212
|
+
"#{type}_#{sanitize_name(id)}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def sanitize_name(name)
|
|
216
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module PetriFlow
|
|
6
|
+
module Visualization
|
|
7
|
+
# Mermaid diagram visualization for Petri nets
|
|
8
|
+
# Generates Mermaid syntax for embedding in Markdown/documentation
|
|
9
|
+
class Mermaid
|
|
10
|
+
attr_reader :net, :options
|
|
11
|
+
|
|
12
|
+
DEFAULT_OPTIONS = {
|
|
13
|
+
direction: "TB", # TB, BT, LR, RL
|
|
14
|
+
show_tokens: true,
|
|
15
|
+
show_weights: true,
|
|
16
|
+
show_pattern_labels: true # Label fork/choice patterns
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(net, options = {})
|
|
20
|
+
@net = net
|
|
21
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
22
|
+
analyze_patterns if @options[:show_pattern_labels]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Generate Mermaid flowchart
|
|
26
|
+
def to_mermaid
|
|
27
|
+
mermaid = []
|
|
28
|
+
mermaid << "flowchart #{@options[:direction]}"
|
|
29
|
+
|
|
30
|
+
# Places
|
|
31
|
+
@net.places.each do |id, place|
|
|
32
|
+
mermaid << place_node(id, place)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Transitions
|
|
36
|
+
@net.transitions.each do |id, transition|
|
|
37
|
+
mermaid << transition_node(id, transition)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Arcs
|
|
41
|
+
@net.arcs.each do |arc|
|
|
42
|
+
mermaid << arc_edge(arc)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Styling
|
|
46
|
+
mermaid << ""
|
|
47
|
+
mermaid << style_definitions
|
|
48
|
+
|
|
49
|
+
mermaid.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Generate state diagram (alternative representation)
|
|
53
|
+
def to_state_diagram
|
|
54
|
+
mermaid = []
|
|
55
|
+
mermaid << "stateDiagram-v2"
|
|
56
|
+
|
|
57
|
+
@net.transitions.each do |id, transition|
|
|
58
|
+
transition.input_arcs.each do |input_arc|
|
|
59
|
+
transition.output_arcs.each do |output_arc|
|
|
60
|
+
source = input_arc.source.name
|
|
61
|
+
target = output_arc.target.name
|
|
62
|
+
label = escape_label(transition.name)
|
|
63
|
+
|
|
64
|
+
mermaid << " #{sanitize(source)} --> #{sanitize(target)}: #{label}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
mermaid.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def place_node(id, place)
|
|
75
|
+
node_id = sanitize(id)
|
|
76
|
+
label = escape_label(place.name)
|
|
77
|
+
|
|
78
|
+
if @options[:show_tokens] && place.tokens > 0
|
|
79
|
+
label = "#{label} x#{place.tokens}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
" #{node_id}((\"#{label}\"))"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def transition_node(id, transition)
|
|
86
|
+
node_id = sanitize(id)
|
|
87
|
+
label = escape_label(transition.name)
|
|
88
|
+
enabled = transition.enabled? ? " *" : ""
|
|
89
|
+
|
|
90
|
+
" #{node_id}[\"#{label}#{enabled}\"]"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Analyze the net to identify fork and choice patterns
|
|
94
|
+
def analyze_patterns
|
|
95
|
+
@fork_transitions = Set.new
|
|
96
|
+
@choice_places = Set.new
|
|
97
|
+
|
|
98
|
+
# Find fork transitions: transitions with multiple output arcs
|
|
99
|
+
@net.transitions.each do |id, transition|
|
|
100
|
+
output_arcs = @net.arcs.select { |arc| arc.source == transition }
|
|
101
|
+
if output_arcs.size > 1
|
|
102
|
+
@fork_transitions.add(id)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Find choice places: places with multiple outgoing transitions
|
|
107
|
+
@net.places.each do |id, place|
|
|
108
|
+
outgoing_arcs = @net.arcs.select { |arc| arc.source == place }
|
|
109
|
+
if outgoing_arcs.size > 1
|
|
110
|
+
@choice_places.add(id)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def arc_edge(arc)
|
|
116
|
+
source_id = sanitize(arc.source.id)
|
|
117
|
+
target_id = sanitize(arc.target.id)
|
|
118
|
+
|
|
119
|
+
label = arc_label(arc)
|
|
120
|
+
|
|
121
|
+
if label
|
|
122
|
+
" #{source_id} -->|#{label}| #{target_id}"
|
|
123
|
+
elsif @options[:show_weights] && arc.weight > 1
|
|
124
|
+
" #{source_id} -->|#{arc.weight}| #{target_id}"
|
|
125
|
+
else
|
|
126
|
+
" #{source_id} --> #{target_id}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def arc_label(arc)
|
|
131
|
+
return nil unless @options[:show_pattern_labels]
|
|
132
|
+
|
|
133
|
+
# Fork pattern: transition → multiple places (parallel outputs)
|
|
134
|
+
# Show target place name to distinguish parallel paths
|
|
135
|
+
if arc.source.is_a?(PetriFlow::Core::Transition)
|
|
136
|
+
transition_id = arc.source.id
|
|
137
|
+
if @fork_transitions&.include?(transition_id)
|
|
138
|
+
target_name = arc.target.name.to_s.split('_').map(&:capitalize).join(' ')
|
|
139
|
+
return "∥ #{target_name}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Choice pattern: place → multiple transitions (exclusive choice)
|
|
144
|
+
# Show where each choice leads (the transition's output place)
|
|
145
|
+
if arc.source.is_a?(PetriFlow::Core::Place)
|
|
146
|
+
place_id = arc.source.id
|
|
147
|
+
if @choice_places&.include?(place_id)
|
|
148
|
+
# Find where this transition leads
|
|
149
|
+
transition = arc.target
|
|
150
|
+
output_arcs = @net.arcs.select { |a| a.source == transition }
|
|
151
|
+
if output_arcs.any?
|
|
152
|
+
targets = output_arcs.map { |a| a.target.name.to_s.split('_').map(&:capitalize).join(' ') }
|
|
153
|
+
return "⊕ → #{targets.join(', ')}"
|
|
154
|
+
end
|
|
155
|
+
return "⊕"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def style_definitions
|
|
163
|
+
styles = []
|
|
164
|
+
|
|
165
|
+
# Style places (circles) - light blue
|
|
166
|
+
place_ids = @net.places.keys.map { |id| sanitize(id) }
|
|
167
|
+
if place_ids.any?
|
|
168
|
+
styles << " classDef placeClass fill:#add8e6,stroke:#333,stroke-width:2px"
|
|
169
|
+
styles << " class #{place_ids.join(',')} placeClass"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Style transitions (rectangles) - light green
|
|
173
|
+
transition_ids = @net.transitions.keys.map { |id| sanitize(id) }
|
|
174
|
+
if transition_ids.any?
|
|
175
|
+
styles << " classDef transitionClass fill:#90ee90,stroke:#333,stroke-width:2px"
|
|
176
|
+
styles << " class #{transition_ids.join(',')} transitionClass"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
styles.join("\n")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def sanitize(name)
|
|
183
|
+
name.to_s.gsub(/[^a-zA-Z0-9]/, '_')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def escape_label(text)
|
|
187
|
+
text.to_s.gsub('"', "'").gsub(/[<>]/, '')
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|