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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/MIT-LICENSE +22 -0
  4. data/README.md +592 -0
  5. data/Rakefile +28 -0
  6. data/lib/petri_flow/colored/arc_expression.rb +163 -0
  7. data/lib/petri_flow/colored/color.rb +40 -0
  8. data/lib/petri_flow/colored/colored_net.rb +146 -0
  9. data/lib/petri_flow/colored/guard.rb +104 -0
  10. data/lib/petri_flow/core/arc.rb +63 -0
  11. data/lib/petri_flow/core/marking.rb +64 -0
  12. data/lib/petri_flow/core/net.rb +121 -0
  13. data/lib/petri_flow/core/place.rb +54 -0
  14. data/lib/petri_flow/core/token.rb +55 -0
  15. data/lib/petri_flow/core/transition.rb +88 -0
  16. data/lib/petri_flow/export/cpn_tools_exporter.rb +322 -0
  17. data/lib/petri_flow/export/json_exporter.rb +224 -0
  18. data/lib/petri_flow/export/pnml_exporter.rb +229 -0
  19. data/lib/petri_flow/export/yaml_exporter.rb +246 -0
  20. data/lib/petri_flow/export.rb +193 -0
  21. data/lib/petri_flow/generators/adapters/aasm_adapter.rb +69 -0
  22. data/lib/petri_flow/generators/adapters/state_machines_adapter.rb +83 -0
  23. data/lib/petri_flow/generators/state_machine_adapter.rb +47 -0
  24. data/lib/petri_flow/generators/workflow_generator.rb +176 -0
  25. data/lib/petri_flow/matrix/analyzer.rb +151 -0
  26. data/lib/petri_flow/matrix/causation.rb +126 -0
  27. data/lib/petri_flow/matrix/correlation.rb +79 -0
  28. data/lib/petri_flow/matrix/crud_event_mapping.rb +74 -0
  29. data/lib/petri_flow/matrix/lineage.rb +113 -0
  30. data/lib/petri_flow/matrix/reachability.rb +128 -0
  31. data/lib/petri_flow/railtie.rb +41 -0
  32. data/lib/petri_flow/registry.rb +85 -0
  33. data/lib/petri_flow/simulation/simulator.rb +188 -0
  34. data/lib/petri_flow/simulation/trace.rb +119 -0
  35. data/lib/petri_flow/tasks/petri_flow.rake +229 -0
  36. data/lib/petri_flow/verification/boundedness_checker.rb +127 -0
  37. data/lib/petri_flow/verification/invariant_checker.rb +144 -0
  38. data/lib/petri_flow/verification/liveness_checker.rb +153 -0
  39. data/lib/petri_flow/verification/reachability_analyzer.rb +152 -0
  40. data/lib/petri_flow/verification_runner.rb +287 -0
  41. data/lib/petri_flow/version.rb +5 -0
  42. data/lib/petri_flow/visualization/graphviz.rb +220 -0
  43. data/lib/petri_flow/visualization/mermaid.rb +191 -0
  44. data/lib/petri_flow/workflow.rb +228 -0
  45. data/lib/petri_flow.rb +164 -0
  46. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ VERSION = "0.6.0"
5
+ 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