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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Verification
5
+ # Boundedness checker for Petri nets
6
+ # Checks if token count in places is bounded
7
+ class BoundednessChecker
8
+ attr_reader :net, :reachability_analyzer
9
+
10
+ def initialize(net, reachability_analyzer = nil)
11
+ @net = net
12
+ @reachability_analyzer = reachability_analyzer ||
13
+ ReachabilityAnalyzer.new(net)
14
+ end
15
+
16
+ # Check if the net is bounded
17
+ # A net is k-bounded if no place ever contains more than k tokens
18
+ def bounded?(k = nil, max_states: 10000)
19
+ # First compute reachability
20
+ @reachability_analyzer.analyze(max_states: max_states)
21
+
22
+ if k.nil?
23
+ # Check if there's any bound
24
+ check_general_boundedness
25
+ else
26
+ # Check if bounded by k
27
+ check_k_boundedness(k)
28
+ end
29
+ end
30
+
31
+ # Find the bound for each place
32
+ def place_bounds
33
+ bounds = {}
34
+
35
+ @reachability_analyzer.reachable_markings.each do |marking|
36
+ marking.to_h.each do |place_id, token_count|
37
+ bounds[place_id] ||= 0
38
+ bounds[place_id] = [bounds[place_id], token_count].max
39
+ end
40
+ end
41
+
42
+ bounds
43
+ end
44
+
45
+ # Check if net is safe (1-bounded)
46
+ def safe?
47
+ bounded?(1)
48
+ end
49
+
50
+ # Check if net is structurally bounded
51
+ # (bounded regardless of initial marking)
52
+ def structurally_bounded?
53
+ # This requires computing the incidence matrix
54
+ # and checking if the system is structurally bounded
55
+ # For now, we'll return a simplified check
56
+ incidence_matrix = compute_incidence_matrix
57
+
58
+ # Check if the rank of incidence matrix indicates structural boundedness
59
+ # This is a simplified heuristic
60
+ places_count = @net.places.size
61
+ transitions_count = @net.transitions.size
62
+
63
+ # If places >= transitions, likely bounded
64
+ places_count >= transitions_count
65
+ end
66
+
67
+ # Generate boundedness report
68
+ def report
69
+ {
70
+ is_bounded: bounded?,
71
+ is_safe: safe?,
72
+ place_bounds: place_bounds,
73
+ max_tokens: place_bounds.values.max || 0,
74
+ unbounded_places: unbounded_places,
75
+ structurally_bounded: structurally_bounded?
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def check_general_boundedness
82
+ bounds = place_bounds
83
+
84
+ # Check if any place has "infinite" tokens (we use 1000 as threshold)
85
+ bounds.values.all? { |bound| bound < 1000 }
86
+ end
87
+
88
+ def check_k_boundedness(k)
89
+ bounds = place_bounds
90
+ bounds.values.all? { |bound| bound <= k }
91
+ end
92
+
93
+ def unbounded_places
94
+ bounds = place_bounds
95
+ bounds.select { |_place, bound| bound >= 1000 }.keys
96
+ end
97
+
98
+ def compute_incidence_matrix
99
+ places = @net.places.keys
100
+ transitions = @net.transitions.keys
101
+
102
+ matrix = {}
103
+ places.each do |place_id|
104
+ matrix[place_id] = {}
105
+ transitions.each do |transition_id|
106
+ matrix[place_id][transition_id] = 0
107
+ end
108
+ end
109
+
110
+ # Calculate incidence matrix: C = C+ - C-
111
+ @net.arcs.each do |arc|
112
+ if arc.input_arc? # place -> transition
113
+ place_id = arc.source.id
114
+ transition_id = arc.target.id
115
+ matrix[place_id][transition_id] -= arc.weight
116
+ elsif arc.output_arc? # transition -> place
117
+ place_id = arc.target.id
118
+ transition_id = arc.source.id
119
+ matrix[place_id][transition_id] += arc.weight
120
+ end
121
+ end
122
+
123
+ matrix
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Verification
5
+ # Invariant checker for Petri nets
6
+ # Verifies properties that should hold across all reachable states
7
+ class InvariantChecker
8
+ attr_reader :net, :reachability_analyzer, :invariants
9
+
10
+ def initialize(net, reachability_analyzer = nil)
11
+ @net = net
12
+ @reachability_analyzer = reachability_analyzer ||
13
+ ReachabilityAnalyzer.new(net)
14
+ @invariants = []
15
+ end
16
+
17
+ # Add a custom invariant
18
+ # @param name [String] Name of the invariant
19
+ # @param checker [Proc] Block that takes marking and returns true/false
20
+ def add_invariant(name, &checker)
21
+ @invariants << { name: name, checker: checker }
22
+ end
23
+
24
+ # Check all invariants across all reachable markings
25
+ def check_all_invariants
26
+ results = {}
27
+
28
+ @invariants.each do |invariant|
29
+ results[invariant[:name]] = check_invariant(invariant)
30
+ end
31
+
32
+ results
33
+ end
34
+
35
+ # Check a specific invariant
36
+ def check_invariant(invariant)
37
+ violations = []
38
+
39
+ @reachability_analyzer.reachable_markings.each do |marking|
40
+ @net.set_marking(marking)
41
+
42
+ unless invariant[:checker].call(marking, @net)
43
+ violations << marking.dup
44
+ end
45
+ end
46
+
47
+ {
48
+ name: invariant[:name],
49
+ holds: violations.empty?,
50
+ violations: violations,
51
+ violation_count: violations.size
52
+ }
53
+ end
54
+
55
+ # Common invariant: Token conservation
56
+ # Total tokens in system remains constant
57
+ def check_token_conservation(expected_total)
58
+ add_invariant("Token Conservation (#{expected_total})") do |marking|
59
+ total_tokens = marking.to_h.values.sum
60
+ total_tokens == expected_total
61
+ end
62
+
63
+ check_invariant(@invariants.last)
64
+ end
65
+
66
+ # Common invariant: Mutual exclusion
67
+ # At most one of specified places has token
68
+ def check_mutual_exclusion(place_ids)
69
+ add_invariant("Mutual Exclusion (#{place_ids.join(', ')})") do |marking|
70
+ tokens_in_places = place_ids.sum { |pid| marking.tokens_at(pid) }
71
+ tokens_in_places <= 1
72
+ end
73
+
74
+ check_invariant(@invariants.last)
75
+ end
76
+
77
+ # Common invariant: Place bound
78
+ # A place never exceeds a token limit
79
+ def check_place_bound(place_id, max_tokens)
80
+ add_invariant("Place Bound (#{place_id} <= #{max_tokens})") do |marking|
81
+ marking.tokens_at(place_id) <= max_tokens
82
+ end
83
+
84
+ check_invariant(@invariants.last)
85
+ end
86
+
87
+ # Check liveness invariant: no deadlocks
88
+ def check_no_deadlocks
89
+ add_invariant("No Deadlocks") do |_marking, net|
90
+ !net.deadlocked?
91
+ end
92
+
93
+ check_invariant(@invariants.last)
94
+ end
95
+
96
+ # Custom invariants for Lyra CRUD-Event mapping
97
+
98
+ # Invariant: PII always detected before storage
99
+ def check_pii_always_detected(pii_detected_place, storage_place)
100
+ add_invariant("PII Always Detected Before Storage") do |marking|
101
+ # If there are tokens in storage, there must have been tokens in PII detected
102
+ # This is a simplified check - in practice we'd track causation
103
+ marking.tokens_at(storage_place) <= marking.tokens_at(pii_detected_place)
104
+ end
105
+
106
+ check_invariant(@invariants.last)
107
+ end
108
+
109
+ # Invariant: Event-ORM consistency
110
+ def check_event_orm_consistency(event_place, orm_place)
111
+ add_invariant("Event-ORM Consistency") do |marking|
112
+ # In Monitor mode, event count should match ORM count
113
+ marking.tokens_at(event_place) == marking.tokens_at(orm_place)
114
+ end
115
+
116
+ check_invariant(@invariants.last)
117
+ end
118
+
119
+ # Generate comprehensive invariant report
120
+ def report
121
+ results = check_all_invariants
122
+
123
+ {
124
+ total_invariants: @invariants.size,
125
+ passed: results.count { |_, r| r[:holds] },
126
+ failed: results.count { |_, r| !r[:holds] },
127
+ results: results,
128
+ summary: generate_summary(results)
129
+ }
130
+ end
131
+
132
+ private
133
+
134
+ def generate_summary(results)
135
+ if results.all? { |_, r| r[:holds] }
136
+ "✓ All #{results.size} invariants hold"
137
+ else
138
+ failed = results.select { |_, r| !r[:holds] }
139
+ "⚠ #{failed.size} invariant(s) violated: #{failed.keys.join(', ')}"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Verification
5
+ # Liveness checker for Petri nets
6
+ # Checks various liveness properties
7
+ class LivenessChecker
8
+ attr_reader :net, :reachability_analyzer
9
+
10
+ # Liveness levels:
11
+ # L0 (Dead): Never fires
12
+ # L1 (Potentially firable): Can fire at least once
13
+ # L2 (Potentially firable k times): Can fire k times
14
+ # L3 (Potentially firable infinitely): Can fire infinitely
15
+ # L4 (Live): Can always eventually fire
16
+
17
+ def initialize(net, reachability_analyzer = nil)
18
+ @net = net
19
+ @reachability_analyzer = reachability_analyzer ||
20
+ ReachabilityAnalyzer.new(net)
21
+ end
22
+
23
+ # Check if transition is dead (L0)
24
+ def dead?(transition_id)
25
+ !firable?(transition_id)
26
+ end
27
+
28
+ # Check if transition is potentially firable (L1)
29
+ def firable?(transition_id)
30
+ @reachability_analyzer.reachable_markings.any? do |marking|
31
+ @net.set_marking(marking)
32
+ transition = @net.transition(transition_id)
33
+ transition&.enabled?
34
+ end
35
+ end
36
+
37
+ # Check if transition is live (L4)
38
+ # A transition is live if from every reachable marking,
39
+ # it can eventually fire
40
+ def live?(transition_id, max_depth: 100)
41
+ @reachability_analyzer.reachable_markings.all? do |marking|
42
+ can_eventually_fire?(transition_id, marking, max_depth: max_depth)
43
+ end
44
+ end
45
+
46
+ # Check if net is deadlock-free
47
+ def deadlock_free?
48
+ @reachability_analyzer.reachable_markings.all? do |marking|
49
+ @net.set_marking(marking)
50
+ !@net.deadlocked?
51
+ end
52
+ end
53
+
54
+ # Find dead transitions
55
+ def dead_transitions
56
+ @net.transitions.keys.select { |tid| dead?(tid) }
57
+ end
58
+
59
+ # Find live transitions
60
+ def live_transitions(max_depth: 100)
61
+ @net.transitions.keys.select { |tid| live?(tid, max_depth: max_depth) }
62
+ end
63
+
64
+ # Classify all transitions by liveness level
65
+ def classify_transitions
66
+ classification = {}
67
+
68
+ @net.transitions.each_key do |transition_id|
69
+ classification[transition_id] = if dead?(transition_id)
70
+ :dead # L0
71
+ elsif live?(transition_id)
72
+ :live # L4
73
+ elsif firable?(transition_id)
74
+ :potentially_firable # L1
75
+ else
76
+ :unknown
77
+ end
78
+ end
79
+
80
+ classification
81
+ end
82
+
83
+ # Generate liveness report
84
+ def report
85
+ classification = classify_transitions
86
+
87
+ {
88
+ deadlock_free: deadlock_free?,
89
+ dead_transitions: classification.select { |_, v| v == :dead }.keys,
90
+ live_transitions: classification.select { |_, v| v == :live }.keys,
91
+ potentially_firable: classification.select { |_, v| v == :potentially_firable }.keys,
92
+ classification: classification,
93
+ liveness_score: calculate_liveness_score(classification)
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def can_eventually_fire?(transition_id, from_marking, max_depth:)
100
+ # BFS to find if transition can fire within max_depth steps
101
+ queue = [[from_marking, 0]]
102
+ visited = Set.new([marking_to_key(from_marking)])
103
+
104
+ while queue.any?
105
+ current_marking, depth = queue.shift
106
+
107
+ return false if depth > max_depth
108
+
109
+ @net.set_marking(current_marking)
110
+
111
+ # Check if target transition is enabled
112
+ transition = @net.transition(transition_id)
113
+ return true if transition&.enabled?
114
+
115
+ # Try all enabled transitions
116
+ @net.enabled_transitions.each do |enabled_transition|
117
+ saved_marking = @net.current_marking.dup
118
+
119
+ begin
120
+ enabled_transition.fire!
121
+ new_marking = @net.current_marking.dup
122
+ new_key = marking_to_key(new_marking)
123
+
124
+ unless visited.include?(new_key)
125
+ visited.add(new_key)
126
+ queue << [new_marking, depth + 1]
127
+ end
128
+ ensure
129
+ @net.set_marking(saved_marking)
130
+ end
131
+ end
132
+ end
133
+
134
+ false
135
+ end
136
+
137
+ def marking_to_key(marking)
138
+ marking.to_h.sort.to_s
139
+ end
140
+
141
+ def calculate_liveness_score(classification)
142
+ total = classification.size
143
+ return 0 if total.zero?
144
+
145
+ live_count = classification.count { |_, v| v == :live }
146
+ firable_count = classification.count { |_, v| v == :potentially_firable }
147
+
148
+ # Score: (live * 1.0 + firable * 0.5) / total
149
+ ((live_count * 1.0) + (firable_count * 0.5)) / total
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetriFlow
4
+ module Verification
5
+ # Reachability analysis for Petri nets
6
+ # Determines which states are reachable from initial state
7
+ #
8
+ # Supports P/T abstraction mode for colored nets: when enabled,
9
+ # guards are ignored and all transitions fire non-deterministically
10
+ # based only on token availability. This is sound for structural
11
+ # properties (boundedness, liveness, reachability).
12
+ class ReachabilityAnalyzer
13
+ attr_reader :net, :initial_marking, :reachable_markings, :state_graph, :pt_abstraction
14
+
15
+ # @param net [PetriFlow::Core::Net] The Petri net to analyze
16
+ # @param initial_marking [PetriFlow::Core::Marking] Initial marking (default: net's current marking)
17
+ # @param pt_abstraction [Boolean] Enable P/T abstraction (ignore guards)
18
+ def initialize(net, initial_marking = nil, pt_abstraction: false)
19
+ @net = net
20
+ @initial_marking = initial_marking || net.current_marking
21
+ @reachable_markings = Set.new
22
+ @state_graph = {} # marking => { transition => next_marking }
23
+ @pt_abstraction = pt_abstraction
24
+ end
25
+
26
+ # Compute all reachable markings using BFS
27
+ def analyze(max_states: 10000)
28
+ @reachable_markings.clear
29
+ @state_graph.clear
30
+
31
+ queue = [@initial_marking.dup]
32
+ visited = Set.new
33
+
34
+ iteration = 0
35
+ while queue.any? && iteration < max_states
36
+ current_marking = queue.shift
37
+ marking_key = marking_to_key(current_marking)
38
+
39
+ next if visited.include?(marking_key)
40
+ visited.add(marking_key)
41
+
42
+ @reachable_markings.add(current_marking.dup)
43
+ @state_graph[marking_key] = {}
44
+
45
+ # Set net to current marking
46
+ @net.set_marking(current_marking)
47
+
48
+ # Try firing each enabled transition
49
+ # In P/T abstraction mode, ignore guards (non-deterministic choice)
50
+ context = @pt_abstraction ? { ignore_guards: true } : {}
51
+ @net.enabled_transitions(context).each do |transition|
52
+ # Save current state
53
+ saved_marking = @net.current_marking.dup
54
+
55
+ # Fire transition (pass context for P/T abstraction)
56
+ begin
57
+ transition.fire!(context)
58
+ new_marking = @net.current_marking.dup
59
+ new_marking_key = marking_to_key(new_marking)
60
+
61
+ # Record state transition
62
+ @state_graph[marking_key][transition.id] = new_marking_key
63
+
64
+ # Add to queue if not visited
65
+ queue << new_marking unless visited.include?(new_marking_key)
66
+ ensure
67
+ # Restore marking for next iteration
68
+ @net.set_marking(saved_marking)
69
+ end
70
+ end
71
+
72
+ iteration += 1
73
+ end
74
+
75
+ # Restore initial marking
76
+ @net.set_marking(@initial_marking)
77
+
78
+ {
79
+ total_states: @reachable_markings.size,
80
+ state_graph_size: @state_graph.size,
81
+ max_states_reached: iteration >= max_states
82
+ }
83
+ end
84
+
85
+ # Check if a specific marking is reachable
86
+ def reachable?(target_marking)
87
+ target_key = marking_to_key(target_marking)
88
+ @reachable_markings.any? { |m| marking_to_key(m) == target_key }
89
+ end
90
+
91
+ # Find path from initial marking to target marking
92
+ def find_path(target_marking)
93
+ target_key = marking_to_key(target_marking)
94
+ initial_key = marking_to_key(@initial_marking)
95
+
96
+ return [] if initial_key == target_key
97
+
98
+ # BFS to find shortest path
99
+ queue = [[initial_key]]
100
+ visited = Set.new([initial_key])
101
+
102
+ while queue.any?
103
+ path = queue.shift
104
+ current_key = path.last
105
+
106
+ next unless @state_graph[current_key]
107
+
108
+ @state_graph[current_key].each do |transition_id, next_key|
109
+ return path + [transition_id, next_key] if next_key == target_key
110
+
111
+ unless visited.include?(next_key)
112
+ visited.add(next_key)
113
+ queue << (path + [transition_id, next_key])
114
+ end
115
+ end
116
+ end
117
+
118
+ nil # No path found
119
+ end
120
+
121
+ # Check for terminal states (no outgoing transitions)
122
+ def terminal_states
123
+ @state_graph.select { |_marking, transitions| transitions.empty? }.keys
124
+ end
125
+
126
+ # Generate reachability report
127
+ def report
128
+ {
129
+ initial_marking: @initial_marking.to_h,
130
+ total_reachable_states: @reachable_markings.size,
131
+ terminal_states: terminal_states.size,
132
+ state_graph_edges: @state_graph.values.map(&:size).sum,
133
+ is_bounded: bounded?,
134
+ deadlock_states: terminal_states,
135
+ pt_abstraction: @pt_abstraction
136
+ }
137
+ end
138
+
139
+ private
140
+
141
+ def marking_to_key(marking)
142
+ marking.to_h.sort.to_s
143
+ end
144
+
145
+ def bounded?
146
+ # Check if token count in any place exceeds a reasonable bound
147
+ max_tokens = @reachable_markings.flat_map { |m| m.to_h.values }.max || 0
148
+ max_tokens < 1000 # Arbitrary bound
149
+ end
150
+ end
151
+ end
152
+ end