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,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
|