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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'export/pnml_exporter'
4
+ require_relative 'export/cpn_tools_exporter'
5
+ require_relative 'export/json_exporter'
6
+ require_relative 'export/yaml_exporter'
7
+
8
+ module PetriFlow
9
+ # Export functionality for Petri nets and Colored Petri Nets
10
+ # Supports multiple formats: PNML, CPN Tools XML, JSON, and YAML
11
+ module Export
12
+ # Export a net to the specified format
13
+ # @param net [Core::Net, Colored::ColoredNet] The net to export
14
+ # @param format [Symbol] The export format (:pnml, :cpn, :json, :yaml)
15
+ # @param options [Hash] Format-specific options
16
+ # @return [String] The exported representation
17
+ def self.export(net, format:, **options)
18
+ exporter = exporter_for(net, format)
19
+
20
+ case format
21
+ when :pnml
22
+ exporter.to_pnml
23
+ when :cpn, :cpn_tools
24
+ exporter.to_cpn
25
+ when :json
26
+ exporter.to_json(**options)
27
+ when :yaml, :yml
28
+ exporter.to_yaml
29
+ else
30
+ raise ArgumentError, "Unknown export format: #{format}. Supported: :pnml, :cpn, :json, :yaml"
31
+ end
32
+ end
33
+
34
+ # Save a net to a file in the specified format
35
+ # @param net [Core::Net, Colored::ColoredNet] The net to export
36
+ # @param filename [String] The output filename
37
+ # @param format [Symbol] The export format (auto-detected from filename if not specified)
38
+ # @param options [Hash] Format-specific options
39
+ def self.save(net, filename, format: nil, **options)
40
+ format ||= detect_format(filename)
41
+
42
+ exporter = exporter_for(net, format)
43
+
44
+ case format
45
+ when :pnml
46
+ exporter.save_pnml(filename)
47
+ when :cpn, :cpn_tools
48
+ exporter.save_cpn(filename)
49
+ when :json
50
+ exporter.save_json(filename, **options)
51
+ when :yaml, :yml
52
+ exporter.save_yaml(filename)
53
+ else
54
+ raise ArgumentError, "Unknown export format: #{format}"
55
+ end
56
+
57
+ filename
58
+ end
59
+
60
+ # Export a net to hash (useful for JSON/YAML serialization)
61
+ # @param net [Core::Net, Colored::ColoredNet] The net to export
62
+ # @return [Hash] Hash representation of the net
63
+ def self.to_hash(net)
64
+ JsonExporter.new(net).to_hash
65
+ end
66
+
67
+ # Detect format from filename extension
68
+ # @param filename [String] The filename
69
+ # @return [Symbol] The detected format
70
+ def self.detect_format(filename)
71
+ ext = File.extname(filename).downcase
72
+ case ext
73
+ when '.pnml', '.xml'
74
+ # Check if filename suggests CPN Tools
75
+ if filename.downcase.include?('cpn')
76
+ :cpn
77
+ else
78
+ :pnml
79
+ end
80
+ when '.cpn'
81
+ :cpn
82
+ when '.json'
83
+ :json
84
+ when '.yaml', '.yml'
85
+ :yaml
86
+ else
87
+ raise ArgumentError, "Cannot detect format from filename: #{filename}. " \
88
+ "Please specify format explicitly."
89
+ end
90
+ end
91
+
92
+ # Get the appropriate exporter for a net and format
93
+ # @param net [Core::Net, Colored::ColoredNet] The net
94
+ # @param format [Symbol] The export format
95
+ # @return [PnmlExporter, CpnToolsExporter, JsonExporter, YamlExporter]
96
+ def self.exporter_for(net, format)
97
+ case format
98
+ when :pnml
99
+ PnmlExporter.new(net)
100
+ when :cpn, :cpn_tools
101
+ CpnToolsExporter.new(net)
102
+ when :json
103
+ JsonExporter.new(net)
104
+ when :yaml, :yml
105
+ YamlExporter.new(net)
106
+ else
107
+ raise ArgumentError, "Unknown export format: #{format}"
108
+ end
109
+ end
110
+
111
+ # List all available export formats
112
+ # @return [Array<Symbol>] Available formats
113
+ def self.formats
114
+ [:pnml, :cpn, :json, :yaml]
115
+ end
116
+
117
+ # Get information about export formats
118
+ # @return [Hash] Format information
119
+ def self.format_info
120
+ {
121
+ pnml: {
122
+ name: 'PNML (Petri Net Markup Language)',
123
+ description: 'ISO/IEC 15909 standard format for Petri nets',
124
+ extension: '.pnml',
125
+ supports_colored: true,
126
+ interoperability: 'High - works with many Petri net tools'
127
+ },
128
+ cpn: {
129
+ name: 'CPN Tools XML',
130
+ description: 'Native format for CPN Tools software',
131
+ extension: '.cpn',
132
+ supports_colored: true,
133
+ interoperability: 'Medium - specific to CPN Tools'
134
+ },
135
+ json: {
136
+ name: 'JSON',
137
+ description: 'Human-readable, API-friendly JSON format',
138
+ extension: '.json',
139
+ supports_colored: true,
140
+ interoperability: 'High - universal format'
141
+ },
142
+ yaml: {
143
+ name: 'YAML',
144
+ description: 'Most human-readable format, ideal for documentation',
145
+ extension: '.yaml',
146
+ supports_colored: true,
147
+ interoperability: 'High - universal format'
148
+ }
149
+ }
150
+ end
151
+ end
152
+ end
153
+
154
+ # Convenience methods added to Net classes
155
+ module PetriFlow
156
+ module Core
157
+ class Net
158
+ # Export this net to a string in the specified format
159
+ def export(format:, **options)
160
+ PetriFlow::Export.export(self, format: format, **options)
161
+ end
162
+
163
+ # Save this net to a file
164
+ def export_to_file(filename, format: nil, **options)
165
+ PetriFlow::Export.save(self, filename, format: format, **options)
166
+ end
167
+
168
+ # Export to hash
169
+ def to_export_hash
170
+ PetriFlow::Export.to_hash(self)
171
+ end
172
+ end
173
+ end
174
+
175
+ module Colored
176
+ class ColoredNet
177
+ # Export this colored net to a string in the specified format
178
+ def export(format:, **options)
179
+ PetriFlow::Export.export(self, format: format, **options)
180
+ end
181
+
182
+ # Save this colored net to a file
183
+ def export_to_file(filename, format: nil, **options)
184
+ PetriFlow::Export.save(self, filename, format: format, **options)
185
+ end
186
+
187
+ # Export to hash
188
+ def to_export_hash
189
+ PetriFlow::Export.to_hash(self)
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Generators
5
+ module Adapters
6
+ # Adapter for AASM (acts_as_state_machine) gem
7
+ # @see https://github.com/aasm/aasm
8
+ class AasmAdapter < StateMachineAdapter
9
+ def self.library_name
10
+ "aasm"
11
+ end
12
+
13
+ def self.supports?(model_class)
14
+ model_class.respond_to?(:aasm)
15
+ end
16
+
17
+ def extract
18
+ aasm = model_class.aasm(state_attribute)
19
+ raise ArgumentError, "No AASM state machine for :#{state_attribute} on #{model_class}" unless aasm
20
+
21
+ states = extract_states(aasm)
22
+ initial = extract_initial_state(aasm)
23
+ transitions = extract_transitions(aasm)
24
+ terminal = find_terminal_states(states, transitions)
25
+
26
+ {
27
+ states: states,
28
+ initial_state: initial,
29
+ terminal_states: terminal,
30
+ transitions: transitions,
31
+ library: self.class.library_name
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def extract_states(aasm)
38
+ aasm.states.map(&:name)
39
+ end
40
+
41
+ def extract_initial_state(aasm)
42
+ aasm.initial_state
43
+ end
44
+
45
+ def extract_transitions(aasm)
46
+ transitions = []
47
+
48
+ aasm.events.each do |event|
49
+ event.transitions.each do |transition|
50
+ from_states = Array(transition.from)
51
+ to_state = transition.to
52
+
53
+ from_states.each do |from|
54
+ transitions << {
55
+ name: "#{event.name}_from_#{from}".to_sym,
56
+ event: event.name,
57
+ from: from,
58
+ to: to_state
59
+ }
60
+ end
61
+ end
62
+ end
63
+
64
+ transitions
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Generators
5
+ module Adapters
6
+ # Adapter for state_machines-activerecord gem
7
+ # @see https://github.com/state-machines/state_machines-activerecord
8
+ class StateMachinesAdapter < StateMachineAdapter
9
+ def self.library_name
10
+ "state_machines-activerecord"
11
+ end
12
+
13
+ def self.supports?(model_class)
14
+ model_class.respond_to?(:state_machines)
15
+ end
16
+
17
+ def extract
18
+ sm = model_class.state_machines[state_attribute]
19
+ raise ArgumentError, "No state machine for :#{state_attribute} on #{model_class}" unless sm
20
+
21
+ states = extract_states(sm)
22
+ initial = extract_initial_state(sm)
23
+ transitions = extract_transitions(sm)
24
+ terminal = find_terminal_states(states, transitions)
25
+
26
+ {
27
+ states: states,
28
+ initial_state: initial,
29
+ terminal_states: terminal,
30
+ transitions: transitions,
31
+ library: self.class.library_name
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def extract_states(sm)
38
+ sm.states.map(&:name).compact
39
+ end
40
+
41
+ def extract_initial_state(sm)
42
+ # Try to get initial state from a new instance
43
+ initial = sm.initial_state(model_class.new)
44
+ initial.try(:name) || extract_states(sm).first
45
+ end
46
+
47
+ def extract_transitions(sm)
48
+ transitions = []
49
+
50
+ sm.events.each do |event|
51
+ event.branches.each do |branch|
52
+ branch.state_requirements.each do |req|
53
+ from_states = extract_state_values(req[:from])
54
+ to_states = extract_state_values(req[:to])
55
+
56
+ from_states.each do |from|
57
+ to_states.each do |to|
58
+ transitions << {
59
+ name: "#{event.name}_from_#{from}".to_sym,
60
+ event: event.name,
61
+ from: from,
62
+ to: to
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ transitions
71
+ end
72
+
73
+ def extract_state_values(state_matcher)
74
+ if state_matcher.respond_to?(:values)
75
+ state_matcher.values
76
+ else
77
+ [state_matcher]
78
+ end.compact
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Generators
5
+ # Base class for state machine adapters.
6
+ # Subclasses extract states and transitions from different state machine libraries.
7
+ #
8
+ # @abstract Subclass and implement {#extract}
9
+ class StateMachineAdapter
10
+ attr_reader :model_class, :state_attribute
11
+
12
+ # @param model_class [Class] ActiveRecord model class
13
+ # @param state_attribute [Symbol] The state attribute name (default: :state)
14
+ def initialize(model_class, state_attribute = :state)
15
+ @model_class = model_class
16
+ @state_attribute = state_attribute.to_sym
17
+ end
18
+
19
+ # Check if this adapter can handle the given model
20
+ # @return [Boolean]
21
+ def self.supports?(model_class)
22
+ raise NotImplementedError, "Subclasses must implement .supports?"
23
+ end
24
+
25
+ # Extract state machine definition
26
+ # @return [Hash] with :states, :initial_state, :terminal_states, :transitions
27
+ def extract
28
+ raise NotImplementedError, "Subclasses must implement #extract"
29
+ end
30
+
31
+ # Human-readable name of the state machine library
32
+ # @return [String]
33
+ def self.library_name
34
+ raise NotImplementedError, "Subclasses must implement .library_name"
35
+ end
36
+
37
+ protected
38
+
39
+ # Find terminal states (states with no outgoing transitions)
40
+ def find_terminal_states(states, transitions)
41
+ states_with_outgoing = Set.new(transitions.map { |t| t[:from] })
42
+ terminal = states.reject { |s| states_with_outgoing.include?(s) }
43
+ terminal.empty? ? [states.last] : terminal
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Generators
5
+ # Generates PetriFlow workflows from ActiveRecord state machines.
6
+ #
7
+ # Supports multiple state machine libraries:
8
+ # - state_machines-activerecord (Solidus, many Rails apps)
9
+ # - AASM (acts_as_state_machine)
10
+ #
11
+ # @example Generate a workflow from a model
12
+ # generator = PetriFlow::Generators::WorkflowGenerator.new(Order, :state)
13
+ # generator.generate!
14
+ # # => Creates app/workflows/order_workflow.rb
15
+ #
16
+ # @example Get workflow data without writing file
17
+ # data = generator.extract
18
+ # # => { states: [...], transitions: [...], ... }
19
+ #
20
+ class WorkflowGenerator
21
+ ADAPTERS = [
22
+ Adapters::StateMachinesAdapter,
23
+ Adapters::AasmAdapter
24
+ ].freeze
25
+
26
+ attr_reader :model_class, :state_attribute, :adapter
27
+
28
+ # @param model_class [Class] ActiveRecord model class with state machine
29
+ # @param state_attribute [Symbol] The state attribute name (default: :state)
30
+ # @raise [ArgumentError] if no supported state machine is found
31
+ def initialize(model_class, state_attribute = :state)
32
+ @model_class = model_class
33
+ @state_attribute = state_attribute.to_sym
34
+ @adapter = find_adapter
35
+ end
36
+
37
+ # Extract state machine data
38
+ # @return [Hash] extracted state machine definition
39
+ def extract
40
+ adapter.extract
41
+ end
42
+
43
+ # Generate workflow class code
44
+ # @return [String] Ruby code for the workflow class
45
+ def generate_code
46
+ data = extract
47
+ generate_workflow_code(data)
48
+ end
49
+
50
+ # Generate workflow file
51
+ # @param output_dir [String, Pathname] directory to write the file
52
+ # @return [String] path to generated file
53
+ def generate!(output_dir: nil)
54
+ output_dir ||= default_output_dir
55
+ FileUtils.mkdir_p(output_dir)
56
+
57
+ filepath = File.join(output_dir, filename)
58
+ File.write(filepath, generate_code)
59
+ filepath
60
+ end
61
+
62
+ # Build a workflow class dynamically (without writing file)
63
+ # @return [Class] the generated workflow class
64
+ def build_workflow_class
65
+ data = extract
66
+ class_name = workflow_class_name
67
+
68
+ Class.new(PetriFlow::Workflow) do
69
+ workflow_name data[:workflow_name] || "#{class_name} Workflow"
70
+ places(*data[:states])
71
+ initial_place data[:initial_state]
72
+ terminal_places(*data[:terminal_states])
73
+
74
+ data[:transitions].each do |t|
75
+ transition t[:name], from: t[:from], to: t[:to]
76
+ end
77
+ end
78
+ end
79
+
80
+ # Verify the generated workflow
81
+ # @return [Hash] verification results
82
+ def verify
83
+ workflow = build_workflow_class.new
84
+ workflow.verify!
85
+ end
86
+
87
+ # Class name for the generated workflow
88
+ # @return [String]
89
+ def workflow_class_name
90
+ "#{model_class.name.demodulize}Workflow"
91
+ end
92
+
93
+ # Filename for the generated workflow
94
+ # @return [String]
95
+ def filename
96
+ "#{model_class.name.demodulize.underscore}_workflow.rb"
97
+ end
98
+
99
+ # List of supported state machine libraries
100
+ # @return [Array<String>]
101
+ def self.supported_libraries
102
+ ADAPTERS.map(&:library_name)
103
+ end
104
+
105
+ # Check if a model has a supported state machine
106
+ # @param model_class [Class]
107
+ # @return [Boolean]
108
+ def self.supports?(model_class)
109
+ ADAPTERS.any? { |adapter| adapter.supports?(model_class) }
110
+ end
111
+
112
+ # Detect which state machine library a model uses
113
+ # @param model_class [Class]
114
+ # @return [String, nil] library name or nil
115
+ def self.detect_library(model_class)
116
+ adapter = ADAPTERS.find { |a| a.supports?(model_class) }
117
+ adapter&.library_name
118
+ end
119
+
120
+ private
121
+
122
+ def find_adapter
123
+ adapter_class = ADAPTERS.find { |a| a.supports?(model_class) }
124
+
125
+ unless adapter_class
126
+ raise ArgumentError,
127
+ "#{model_class} does not have a supported state machine. " \
128
+ "Supported: #{self.class.supported_libraries.join(', ')}"
129
+ end
130
+
131
+ adapter_class.new(model_class, state_attribute)
132
+ end
133
+
134
+ def default_output_dir
135
+ if defined?(Rails)
136
+ Rails.root.join("app", "workflows")
137
+ else
138
+ File.expand_path("app/workflows", Dir.pwd)
139
+ end
140
+ end
141
+
142
+ def generate_workflow_code(data)
143
+ class_name = workflow_class_name
144
+ workflow_name = "#{model_class.name.demodulize} State Workflow"
145
+
146
+ lines = []
147
+ lines << "# frozen_string_literal: true"
148
+ lines << "# Auto-generated from #{model_class.name} state machine (#{data[:library]})"
149
+ lines << "# Generated at: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}"
150
+ lines << ""
151
+ lines << "# #{workflow_name}"
152
+ lines << "# Models the state transitions for #{model_class.name}"
153
+ lines << "class #{class_name} < PetriFlow::Workflow"
154
+ lines << " workflow_name #{workflow_name.inspect}"
155
+ lines << ""
156
+ lines << " # States from #{model_class.name}"
157
+ lines << " places #{data[:states].map(&:inspect).join(', ')}"
158
+ lines << " initial_place #{data[:initial_state].inspect}"
159
+ lines << " terminal_places #{data[:terminal_states].map(&:inspect).join(', ')}"
160
+ lines << ""
161
+ lines << " # Transitions from state machine events"
162
+
163
+ data[:transitions].each do |t|
164
+ lines << " transition #{t[:name].inspect},"
165
+ lines << " from: #{t[:from].inspect},"
166
+ lines << " to: #{t[:to].inspect},"
167
+ lines << " trigger: #{t[:event].to_s.inspect}"
168
+ lines << ""
169
+ end
170
+
171
+ lines << "end"
172
+ lines.join("\n")
173
+ end
174
+ end
175
+ end
176
+ end