state_machine_checker 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +4 -0
  4. data/.travis.yml +7 -0
  5. data/CHANGELOG.md +0 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +67 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +102 -0
  11. data/Rakefile +6 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/lib/state_machine_checker/adapters/state_machines.rb +40 -0
  15. data/lib/state_machine_checker/check_result.rb +40 -0
  16. data/lib/state_machine_checker/ctl/a_f.rb +20 -0
  17. data/lib/state_machine_checker/ctl/a_g.rb +20 -0
  18. data/lib/state_machine_checker/ctl/a_u.rb +23 -0
  19. data/lib/state_machine_checker/ctl/a_x.rb +20 -0
  20. data/lib/state_machine_checker/ctl/and.rb +42 -0
  21. data/lib/state_machine_checker/ctl/api.rb +61 -0
  22. data/lib/state_machine_checker/ctl/atom.rb +62 -0
  23. data/lib/state_machine_checker/ctl/binary_formula.rb +23 -0
  24. data/lib/state_machine_checker/ctl/e_f.rb +34 -0
  25. data/lib/state_machine_checker/ctl/e_g.rb +139 -0
  26. data/lib/state_machine_checker/ctl/e_u.rb +42 -0
  27. data/lib/state_machine_checker/ctl/e_x.rb +40 -0
  28. data/lib/state_machine_checker/ctl/formula.rb +42 -0
  29. data/lib/state_machine_checker/ctl/implication.rb +19 -0
  30. data/lib/state_machine_checker/ctl/not.rb +36 -0
  31. data/lib/state_machine_checker/ctl/or.rb +40 -0
  32. data/lib/state_machine_checker/ctl/unary_operator.rb +22 -0
  33. data/lib/state_machine_checker/finite_state_machine.rb +120 -0
  34. data/lib/state_machine_checker/labeled_machine.rb +51 -0
  35. data/lib/state_machine_checker/labeling.rb +48 -0
  36. data/lib/state_machine_checker/rspec_matchers.rb +60 -0
  37. data/lib/state_machine_checker/state_result.rb +57 -0
  38. data/lib/state_machine_checker/transition.rb +49 -0
  39. data/lib/state_machine_checker/version.rb +3 -0
  40. data/lib/state_machine_checker.rb +43 -0
  41. data/state_machine_checker.gemspec +47 -0
  42. metadata +186 -0
@@ -0,0 +1,23 @@
1
+ require_relative "formula"
2
+
3
+ module StateMachineChecker
4
+ module CTL
5
+ class BinaryFormula < Formula
6
+ def initialize(subformula1, subformula2)
7
+ @subformula1 = subformula1
8
+ @subformula2 = subformula2
9
+ end
10
+
11
+ # Return an enumerator over the atoms of the sub-formulae.
12
+ #
13
+ # @return [Enumerator<Atom>]
14
+ def atoms
15
+ subformula1.atoms + subformula2.atoms
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :subformula1, :subformula2
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,34 @@
1
+ require_relative "unary_operator"
2
+
3
+ module StateMachineChecker
4
+ module CTL
5
+ # The existential eventually operator.
6
+ class EF < UnaryOperator
7
+ # Check which states of the model have as a successor a state
8
+ # satisfying the subformula.
9
+ #
10
+ # @param [LabeledMachine] model
11
+ # @return [CheckResult]
12
+ def check(model)
13
+ subresult = subformula.check(model)
14
+ result = subresult.to_h
15
+ model.states.each do |state|
16
+ sub_state_result = subresult.for_state(state)
17
+
18
+ if sub_state_result.satisfied? # Mark predecessors as satisfied.
19
+ model.traverse(state, reverse: true) do |s, transitions|
20
+ witness = transitions + sub_state_result.witness
21
+ result[s] = StateResult.new(true, witness)
22
+ end
23
+ end
24
+ end
25
+
26
+ CheckResult.new(result)
27
+ end
28
+
29
+ def to_s
30
+ "EF(#{subformula})"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,139 @@
1
+ module StateMachineChecker
2
+ module CTL
3
+ # The existential universal operator.
4
+ class EG < UnaryOperator
5
+ # Check which states of the model have a path for which the subformula is
6
+ # always satisfied
7
+ #
8
+ # @param [LabeledMachine] model
9
+ # @return [CheckResult]
10
+ def check(model)
11
+ subresult = subformula.check(model)
12
+ projection = subformula_projection(model, subresult)
13
+ scc = strongly_connected_components(projection)
14
+
15
+ # Components must have more than one element, a self loop, or no
16
+ # transitions in the original fsm.
17
+ # TODO: this being necessary probably means we're doing something wrong.
18
+ scc.select! do |states|
19
+ states.length > 1 ||
20
+ begin
21
+ c = states.first
22
+ transitions = model.transitions_from(c)
23
+
24
+ transitions.empty? ||
25
+ transitions.any? { |t| t.to == c }
26
+ end
27
+ end
28
+
29
+ build_check_result(scc, model, projection)
30
+ end
31
+
32
+ def to_s
33
+ "EG(#{subformula})"
34
+ end
35
+
36
+ private
37
+
38
+ # A graph containing only states for which the subformula is true.
39
+ def subformula_projection(model, subresult)
40
+ transitions = model.transitions.select { |t|
41
+ subresult.for_state(t.from).satisfied? &&
42
+ subresult.for_state(t.to).satisfied?
43
+ }
44
+
45
+ FiniteStateMachine.new(model.initial_state, transitions)
46
+ end
47
+
48
+ # Implements Kosaraju's algorithm.
49
+ def strongly_connected_components(projection)
50
+ visited = Set.new
51
+ l = []
52
+
53
+ projection.states.each do |s|
54
+ visit(s, visited, l, projection)
55
+ end
56
+
57
+ assigned = Set.new
58
+ assignments = {} # root -> set of states in component
59
+ l.reverse_each do |s|
60
+ assign(s, s, assigned, assignments, projection)
61
+ end
62
+
63
+ assignments.values
64
+ end
65
+
66
+ def build_check_result(scc, model, projection)
67
+ # Initialize hash with every state unsatisfied.
68
+ result = model.states.each_with_object({}) { |s, h|
69
+ h[s] = StateResult.new(false, [])
70
+ }
71
+
72
+ scc.each do |component_states|
73
+ # For each state of the component search backwards.
74
+ component_states.each do |state|
75
+ loop_witness = scc_loop(component_states, state, projection)
76
+
77
+ projection.traverse(state, reverse: true) do |s, transitions|
78
+ # Ignore other states in the component.
79
+ if s == state || !component_states.include?(s)
80
+ result[s] = StateResult.new(true, transitions + loop_witness)
81
+ else
82
+ false
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ CheckResult.new(result)
89
+ end
90
+
91
+ # Find a series of transitions within the component_states which start and
92
+ # end with the given state.
93
+ def scc_loop(component_states, start, model)
94
+ model.traverse(start) do |state, path|
95
+ # Only search within the component states.
96
+ if component_states.include?(state)
97
+ transitions = model.transitions_from(state)
98
+ to_start = transitions.find { |t| t.to == start }
99
+
100
+ if to_start
101
+ return path.push(to_start.name)
102
+ else
103
+ true # continue
104
+ end
105
+ else
106
+ false
107
+ end
108
+ end
109
+
110
+ []
111
+ end
112
+
113
+ def visit(s, visited, l, projection)
114
+ unless visited.include?(s)
115
+ visited << s
116
+ projection.transitions_from(s).each do |transition|
117
+ visit(transition.to, visited, l, projection)
118
+ end
119
+ l << s
120
+ end
121
+ end
122
+
123
+ def assign(s, root, assigned, assignments, projection)
124
+ unless assigned.include?(s)
125
+ assigned << s
126
+
127
+ if assignments[root].nil?
128
+ assignments[root] = Set.new
129
+ end
130
+ assignments[root] << s
131
+
132
+ projection.transitions_to(s).each do |transition|
133
+ assign(transition.from, root, assigned, assignments, projection)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,42 @@
1
+ require_relative "binary_formula"
2
+
3
+ module StateMachineChecker
4
+ module CTL
5
+ # The existential until operator. This is the "strong" until, it is only
6
+ # satisfied if the second sub-formula is eventually satisifed.
7
+ class EU < BinaryFormula
8
+ # Check which states of the model have a path for which the first
9
+ # subformula is satisifed until the second subformula is.
10
+ #
11
+ # @param [LabeledMachine] model
12
+ # @return [CheckResult]
13
+ def check(model)
14
+ subresult1 = subformula1.check(model)
15
+ subresult2 = subformula2.check(model)
16
+
17
+ result = subresult2.to_h # States satisfying sub-formula2 satisfy this.
18
+
19
+ model.states.lazy.select { |s| subresult2.for_state(s).satisfied? }.each do |end_state|
20
+ model.traverse(end_state, reverse: true) do |state, path|
21
+ if state == end_state || subresult1.for_state(state).satisfied?
22
+ # Don't update states that are already satisfied, to keep the
23
+ # simpler witness.
24
+ unless result[state].satisfied?
25
+ result[state] = StateResult.new(true, path)
26
+ end
27
+ true
28
+ else
29
+ false
30
+ end
31
+ end
32
+ end
33
+
34
+ CheckResult.new(result)
35
+ end
36
+
37
+ def to_s
38
+ "(#{subformula1}) EU (#{subformula2})"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "unary_operator"
2
+ require "state_machine_checker/check_result"
3
+ require "state_machine_checker/state_result"
4
+
5
+ module StateMachineChecker
6
+ module CTL
7
+ # The existential next operator.
8
+ class EX < UnaryOperator
9
+ # Check which states of the model have as a direct successor a state
10
+ # satisfying the subformula.
11
+ #
12
+ # @param [LabeledMachine] model
13
+ # @return [CheckResult]
14
+ def check(model)
15
+ # Initialize hash with every state unsatisfied.
16
+ result = model.states.each_with_object({}) { |s, h|
17
+ h[s] = StateResult.new(false, [])
18
+ }
19
+
20
+ subresult = subformula.check(model)
21
+ model.states.each do |state|
22
+ sub_state_result = subresult.for_state(state)
23
+
24
+ if sub_state_result.satisfied? # Mark direct predecessors as satisfied.
25
+ model.transitions_to(state).each do |transition|
26
+ witness = [transition.name] + sub_state_result.witness
27
+ result[transition.from] = StateResult.new(true, witness)
28
+ end
29
+ end
30
+ end
31
+
32
+ CheckResult.new(result)
33
+ end
34
+
35
+ def to_s
36
+ "EX(#{subformula})"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ module StateMachineChecker
2
+ module CTL
3
+ # Abstract base class for CTL formulae.
4
+ class Formula
5
+ # The logical conjuction of this formula with others.
6
+ def and(*other_subformulae)
7
+ other_subformulae.map! { |f| atom_or_formula(f) }
8
+ And.new(other_subformulae << self)
9
+ end
10
+
11
+ # The logical disjunction of this formula with others.
12
+ def or(*other_subformulae)
13
+ other_subformulae.map! { |f| atom_or_formula(f) }
14
+ Or.new(other_subformulae << self)
15
+ end
16
+
17
+ # Logical implication
18
+ def implies(other_subformula)
19
+ Implication.new(self, atom_or_formula(other_subformula))
20
+ end
21
+
22
+ # The existential until operator.
23
+ def EU(end_formula) # rubocop:disable Naming/MethodName
24
+ EU.new(self, atom_or_formula(end_formula))
25
+ end
26
+
27
+ def AU(end_formula) # rubocop:disable Naming/MethodName
28
+ AU.new(self, atom_or_formula(end_formula))
29
+ end
30
+
31
+ private
32
+
33
+ def atom_or_formula(subformula)
34
+ if subformula.is_a? Formula
35
+ subformula
36
+ else
37
+ Atom.new(subformula)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "binary_formula"
2
+ require_relative "not"
3
+
4
+ module StateMachineChecker
5
+ module CTL
6
+ # Logical implication.
7
+ class Implication < BinaryFormula
8
+ # @param [LabeledMachine] model
9
+ # @return [CheckResult]
10
+ def check(model)
11
+ subformula1.and(subformula2).or(Not.new(subformula1)).check(model)
12
+ end
13
+
14
+ def to_s
15
+ "(#{subformula1}) ⇒ (#{subformula2})"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "unary_operator"
2
+ require "state_machine_checker/check_result"
3
+ require "state_machine_checker/state_result"
4
+
5
+ module StateMachineChecker
6
+ module CTL
7
+ # The logical negation of a formula.
8
+ class Not < UnaryOperator
9
+ # Check whether each state is not satisfied by the subformula.
10
+ #
11
+ # @param [LabeledMachine] model
12
+ # @return [CheckResult]
13
+ def check(model)
14
+ subresult = subformula.check(model)
15
+
16
+ result = model.states.each_with_object({}) { |state, h|
17
+ state_result = subresult.for_state(state)
18
+
19
+ # Negate whether it was satisfied, keep the same path.
20
+ path = if state_result.satisfied?
21
+ state_result.witness
22
+ else
23
+ state_result.counterexample
24
+ end
25
+ h[state] = StateResult.new(!state_result.satisfied?, path)
26
+ }
27
+
28
+ CheckResult.new(result)
29
+ end
30
+
31
+ def to_s
32
+ "¬(#{subformula})"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "formula"
2
+
3
+ module StateMachineChecker
4
+ module CTL
5
+ # The logical disjunction of two or more sub-formulae.
6
+ class Or < Formula
7
+ # Disjoin several formulae.
8
+ #
9
+ # @example
10
+ # Or.new([Atom.new(:even?), Atom.new(:positive?)])
11
+ def initialize(subformulae)
12
+ @subformulae = subformulae
13
+ end
14
+
15
+ # Return an enumerator over the atoms of all sub-formulae.
16
+ #
17
+ # @return [Enumerator]
18
+ def atoms
19
+ subformulae.lazy.flat_map(&:atoms)
20
+ end
21
+
22
+ # Check which states of the model are satisfied by at least one subformulae.
23
+ #
24
+ # @param [LabeledMachine] model
25
+ # @return [CheckResult]
26
+ def check(model)
27
+ sub_results = subformulae.lazy.map { |f| f.check(model) }
28
+ sub_results.reduce(&:union)
29
+ end
30
+
31
+ def to_s
32
+ subformulae.map(&:to_s).map { |s| "(#{s})" }.join(" ∨ ")
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :subformulae
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,22 @@
1
+ module StateMachineChecker
2
+ module CTL
3
+ # Abstract base class for operators with a single sub-formula.
4
+ class UnaryOperator < Formula
5
+ # @param [Formula] subformula
6
+ def initialize(subformula)
7
+ @subformula = subformula
8
+ end
9
+
10
+ # Return an enumerator over the atoms of the sub-formula
11
+ #
12
+ # @return [Enumerator<Atom>]
13
+ def atoms
14
+ subformula.atoms
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :subformula
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,120 @@
1
+ module StateMachineChecker
2
+ # Represents a finite state machine: a set of states, an initial state, and
3
+ # transitions among them.
4
+ #
5
+ # This class is used to limit the dependency on any particular state machine
6
+ # library.
7
+ class FiniteStateMachine
8
+ attr_reader :initial_state, :transitions
9
+
10
+ # @param [Symbol] initial_state the name of the initial state.
11
+ # @param [Array<Transition>] transitions the transitions of the FSM.
12
+ def initialize(initial_state, transitions)
13
+ @initial_state = initial_state
14
+ @transitions = transitions
15
+ end
16
+
17
+ # Enumerate the states and for each provide a path to it.
18
+ #
19
+ # @return [Enumerator<Array(Symbol, Array<Transition>)>] an enumerator where
20
+ # each element is a pair of a state and an array of transitions to reach the
21
+ # state.
22
+ def state_paths
23
+ Enumerator.new do |yielder|
24
+ depth_first_search(Set.new, initial_state, [], yielder)
25
+ end
26
+ end
27
+
28
+ # Enumerate the states.
29
+ #
30
+ # @return [Enumerator<Symbol>] an enumerator over the names of the states.
31
+ def states
32
+ seen = Set.new
33
+
34
+ Enumerator.new do |yielder|
35
+ seen << initial_state
36
+ yielder << initial_state
37
+
38
+ transitions.each do |transition|
39
+ unless seen.include?(transition.from)
40
+ seen << transition.from
41
+ yielder << transition.from
42
+ end
43
+
44
+ unless seen.include?(transition.to)
45
+ seen << transition.to
46
+ yielder << transition.to
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Traverse the graph from the given state. Yield each state and the
53
+ # transitions from it to the from_state. If the result of the block is falsey
54
+ # for any state then the search will not continue to the children of that
55
+ # state.
56
+ #
57
+ # @param [Symbol] from_state
58
+ # @param [true, false] reverse traverse in reverse?
59
+ # @yield [Symbol, Array<Symbol>]
60
+ def traverse(from_state, reverse: false, &block)
61
+ rec_traverse(from_state, [], Set[from_state], reverse, &block)
62
+ end
63
+
64
+ def transitions_from(state)
65
+ transitions.select { |t| t.from == state }
66
+ end
67
+
68
+ def transitions_to(state)
69
+ transitions.select { |t| t.to == state }
70
+ end
71
+
72
+ private
73
+
74
+ # Traverse the graph, maintaining a stack of transitions.
75
+ def rec_traverse(state, stack, seen, reverse, &block)
76
+ # If we are reverse searching than the stack will be in reverse order.
77
+ path = reverse ? stack.reverse : stack.clone
78
+ continue = block.yield(state, path.map(&:name)) != false
79
+
80
+ if continue
81
+ trans = if reverse
82
+ transitions_to(state)
83
+ else
84
+ transitions_from(state)
85
+ end
86
+
87
+ trans.each do |transition|
88
+ next_state = if reverse
89
+ transition.from
90
+ else
91
+ transition.to
92
+ end
93
+
94
+ unless seen.include?(next_state)
95
+ seen.add(next_state)
96
+ stack.push(transition)
97
+
98
+ rec_traverse(next_state, stack, seen, reverse, &block)
99
+
100
+ stack.pop
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def depth_first_search(visited, state, transitions, yielder)
107
+ yielder << [state, transitions]
108
+
109
+ visited.add(state)
110
+
111
+ transitions_from(state).each do |transition|
112
+ unless visited.include?(transition.to)
113
+ new_transitions = transitions.clone
114
+ new_transitions << transition
115
+ depth_first_search(visited, transition.to, new_transitions, yielder)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,51 @@
1
+ module StateMachineChecker
2
+ # A finite state machine where every node is mapped to a set of labels. AKA a
3
+ # Kripke structure.
4
+ class LabeledMachine
5
+ # @param [FiniteStateMachine] fsm
6
+ # @param [Labeling] labeling
7
+ def initialize(fsm, labeling)
8
+ @fsm = fsm
9
+ @labeling = labeling
10
+ end
11
+
12
+ # (see StateMachineChecker::FiniteStateMachine#initial_state)
13
+ def initial_state
14
+ fsm.initial_state
15
+ end
16
+
17
+ # (see StateMachineChecker::FiniteStateMachine#transitions)
18
+ def transitions
19
+ fsm.transitions
20
+ end
21
+
22
+ # (see StateMachineChecker::FiniteStateMachine#states)
23
+ def states
24
+ fsm.states
25
+ end
26
+
27
+ # (see StateMachineChecker::FiniteStateMachine#traverse)
28
+ def traverse(from_state, reverse: false, &block)
29
+ fsm.traverse(from_state, reverse: reverse, &block)
30
+ end
31
+
32
+ # (see StateMachineChecker::FiniteStateMachine#transitions_to)
33
+ def transitions_to(state)
34
+ fsm.transitions_to(state)
35
+ end
36
+
37
+ # (see StateMachineChecker::FiniteStateMachine#transitions_from)
38
+ def transitions_from(state)
39
+ fsm.transitions_from(state)
40
+ end
41
+
42
+ # (see StateMachineChecker::Labeling#for_state)
43
+ def labels_for_state(state)
44
+ labeling.for_state(state)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :fsm, :labeling
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ module StateMachineChecker
2
+ # A mapping from states to the values of each atom.
3
+ class Labeling
4
+ # @param [Enumerator<CTL::Atom>] atoms the atoms which will be the labels.
5
+ # @param [FiniteStateMachine] machine the machine to generate labels for.
6
+ # @param [Proc] instance_generator a nullary function which returns an
7
+ # instance of an object in the initial state.
8
+ def initialize(atoms, machine, instance_generator)
9
+ @labels_by_state = build_map(atoms, machine, instance_generator)
10
+ end
11
+
12
+ # Get the labels for the given state.
13
+ #
14
+ # @param [Symbol] state
15
+ # @return [Set<CTL::Atom>] the atoms which are true in the state
16
+ def for_state(state)
17
+ labels_by_state[state]
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :labels_by_state
23
+
24
+ # Generate a hash of state -> atoms
25
+ def build_map(atoms, machine, instance_generator)
26
+ machine.state_paths.each_with_object({}) { |(state, transitions), states_map|
27
+ instance = instance_from_transitions(transitions, instance_generator)
28
+
29
+ states_map[state] = atoms.each_with_object(Set.new) { |atom, labels|
30
+ if atom.apply(instance)
31
+ labels << atom
32
+ end
33
+ }
34
+ }
35
+ end
36
+
37
+ # Walk an instance through the given transitions.
38
+ def instance_from_transitions(transitions, instance_generator)
39
+ instance = instance_generator.call
40
+
41
+ transitions.each do |transition|
42
+ transition.execute(instance)
43
+ end
44
+
45
+ instance
46
+ end
47
+ end
48
+ end