bparity 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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +146 -0
  5. data/Rakefile +36 -0
  6. data/docs/application_example.md +25 -0
  7. data/docs/formal_assurance_limits.md +21 -0
  8. data/exe/bparity +7 -0
  9. data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
  10. data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
  11. data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
  12. data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
  13. data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
  14. data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
  15. data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
  16. data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
  17. data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
  18. data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
  19. data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
  20. data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
  21. data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
  22. data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
  23. data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
  24. data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
  25. data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
  26. data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
  27. data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
  28. data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
  29. data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
  30. data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
  31. data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
  32. data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
  33. data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
  34. data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
  35. data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
  36. data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
  37. data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
  38. data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
  39. data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
  40. data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
  41. data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
  42. data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
  43. data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
  44. data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
  45. data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
  46. data/lib/bparity/adapter.rb +115 -0
  47. data/lib/bparity/adequacy.rb +78 -0
  48. data/lib/bparity/boundary.rb +97 -0
  49. data/lib/bparity/cli/formal_commands.rb +428 -0
  50. data/lib/bparity/cli/verification_commands.rb +242 -0
  51. data/lib/bparity/cli.rb +262 -0
  52. data/lib/bparity/corpus.rb +45 -0
  53. data/lib/bparity/errors.rb +12 -0
  54. data/lib/bparity/formal/assumptions.rb +131 -0
  55. data/lib/bparity/formal/bounded.rb +543 -0
  56. data/lib/bparity/formal/contract.rb +81 -0
  57. data/lib/bparity/formal/deductive.rb +481 -0
  58. data/lib/bparity/formal/lts.rb +344 -0
  59. data/lib/bparity/formal/result.rb +50 -0
  60. data/lib/bparity/formal.rb +8 -0
  61. data/lib/bparity/recording.rb +412 -0
  62. data/lib/bparity/reporting.rb +128 -0
  63. data/lib/bparity/spec_bundle.rb +208 -0
  64. data/lib/bparity/synthesis.rb +487 -0
  65. data/lib/bparity/verification.rb +310 -0
  66. data/lib/bparity/version.rb +5 -0
  67. data/lib/bparity.rb +42 -0
  68. metadata +125 -0
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bparity
6
+ module Formal
7
+ module LtsEncoding
8
+ module_function
9
+
10
+ def output(outcome)
11
+ if outcome.is_a?(Hash) && outcome["kind"] == "raise"
12
+ return "!#{outcome['class']}:#{JSON.generate(normalize(outcome.except('kind', 'class')))}"
13
+ end
14
+
15
+ JSON.generate(normalize(outcome))
16
+ end
17
+
18
+ def normalize(value)
19
+ case value
20
+ when Hash
21
+ value.compact.sort_by { |key, _item| key.to_s }
22
+ .to_h { |key, item| [key.to_s, normalize(item)] }
23
+ when Array then value.map { |item| normalize(item) }
24
+ else value
25
+ end
26
+ end
27
+ end
28
+
29
+ Transition = Struct.new(:from, :input, :output, :to, keyword_init: true) do
30
+ def to_h = { "from" => from, "input" => input, "output" => output, "to" => to }
31
+ end
32
+
33
+ class LTS
34
+ attr_reader :initial, :transitions
35
+
36
+ def initialize(initial:, transitions: [])
37
+ @initial = initial
38
+ @transitions = transitions.map do |transition|
39
+ transition.is_a?(Transition) ? transition : Transition.new(**transition.transform_keys(&:to_sym))
40
+ end
41
+ end
42
+
43
+ def states = ([initial] + transitions.flat_map { |transition| [transition.from, transition.to] }).uniq.sort
44
+ def alphabet = transitions.map(&:input).uniq.sort
45
+ def outgoing(state) = transitions.select { |transition| transition.from == state }
46
+ def step(state, input) = outgoing(state).find { |transition| transition.input == input }
47
+
48
+ def deterministic?
49
+ transitions.group_by { |transition| [transition.from, transition.input] }.values.none? do |items|
50
+ items.map { |transition| [transition.output, transition.to] }.uniq.length > 1
51
+ end
52
+ end
53
+
54
+ def to_h
55
+ { "initial" => initial, "states" => states, "alphabet" => alphabet,
56
+ "transitions" => transitions.map(&:to_h) }
57
+ end
58
+
59
+ def self.from_h(value)
60
+ new(initial: value.fetch("initial"), transitions: value.fetch("transitions", []))
61
+ end
62
+ end
63
+
64
+ class PassiveLearner
65
+ def learn(records)
66
+ usable = records.select { |record| record.key?("pre_state") && record.key?("post_state") }
67
+ if usable.empty?
68
+ raise ConfigurationError,
69
+ "The corpus has no state projections. Add a state block to the boundary."
70
+ end
71
+
72
+ labels = usable.flat_map { |record| [state_label(record["pre_state"]), state_label(record["post_state"])] }.uniq
73
+ names = labels.sort.each_with_index.to_h { |label, index| [label, "s#{index}"] }
74
+ transitions = usable.map do |record|
75
+ Transition.new(from: names.fetch(state_label(record["pre_state"])), input: record.fetch("operation"),
76
+ output: output_label(record.fetch("outcome")),
77
+ to: names.fetch(state_label(record["post_state"])))
78
+ end.uniq(&:to_h)
79
+ LTS.new(initial: names.fetch(state_label(usable.first["pre_state"])), transitions:)
80
+ end
81
+
82
+ private
83
+
84
+ def state_label(state) = JSON.generate(state)
85
+ def output_label(outcome) = LtsEncoding.output(outcome)
86
+ end
87
+
88
+ LearnedModel = Struct.new(:lts, :complete, :query_count, :algorithm, keyword_init: true)
89
+ ObservedOutput = Struct.new(:value)
90
+
91
+ class ActiveLearner
92
+ def initialize(factory:, state_projection:, operations:, state_limit: 500)
93
+ @factory = factory
94
+ @state_projection = state_projection
95
+ @operations = operations
96
+ @state_limit = state_limit
97
+ end
98
+
99
+ def learn
100
+ initial_subject = @factory.call
101
+ initial_label = state_label(initial_subject)
102
+ paths = { initial_label => [] }
103
+ queue = [initial_label]
104
+ transitions = []
105
+ queries = 0
106
+ until queue.empty?
107
+ from = queue.shift
108
+ @operations.each do |name, operation|
109
+ subject = replay(paths.fetch(from))
110
+ output = observe(subject, operation)
111
+ queries += 1
112
+ target = state_label(subject)
113
+ transitions << Transition.new(from:, input: name, output:, to: target)
114
+ next if paths.key?(target)
115
+ return learned(initial_label, transitions, false, queries) if paths.length >= @state_limit
116
+
117
+ paths[target] = paths.fetch(from) + [name]
118
+ queue << target
119
+ end
120
+ end
121
+ learned(initial_label, transitions, true, queries)
122
+ end
123
+
124
+ private
125
+
126
+ def replay(path)
127
+ subject = @factory.call
128
+ path.each { |name| observe(subject, @operations.fetch(name)) }
129
+ subject
130
+ end
131
+
132
+ def observe(subject, operation)
133
+ value = operation.call(subject)
134
+ return LtsEncoding.output(value.value) if value.is_a?(ObservedOutput)
135
+
136
+ LtsEncoding.output("kind" => "return", "value" => Recording::Serializer.dump(value))
137
+ rescue StandardError => e
138
+ LtsEncoding.output("kind" => "raise", "class" => e.class.name,
139
+ "message" => Bparity.exception_message(e))
140
+ end
141
+
142
+ def state_label(subject) = JSON.generate(Recording::Serializer.dump(@state_projection.call(subject)))
143
+
144
+ def learned(initial, transitions, complete, queries)
145
+ labels = ([initial] + transitions.flat_map { |transition| [transition.from, transition.to] }).uniq.sort
146
+ names = labels.each_with_index.to_h { |label, index| [label, "s#{index}"] }
147
+ normalized = transitions.map do |transition|
148
+ Transition.new(from: names.fetch(transition.from), input: transition.input,
149
+ output: transition.output, to: names.fetch(transition.to))
150
+ end
151
+ LearnedModel.new(lts: LTS.new(initial: names.fetch(initial), transitions: normalized), complete:,
152
+ query_count: queries, algorithm: "active state exploration")
153
+ end
154
+ end
155
+
156
+ class LtsEquivalence
157
+ def compare(old_lts, new_lts, relation: :trace, assumptions: %i[h1 h6 h7], exact: true)
158
+ unless %i[trace bisim].include?(relation.to_sym)
159
+ raise ConfigurationError, "Unsupported LTS relation #{relation}. Use trace or bisim."
160
+ end
161
+ unless old_lts.deterministic? && new_lts.deterministic?
162
+ return Result.new(level: :f3, verdict: :inconclusive,
163
+ scope: Scope.new(size: [old_lts.states.length, new_lts.states.length].max,
164
+ depth: nil, cases: 0, exhaustive: false),
165
+ assumptions:, out_of_scope: ["nondeterministic model equivalence"],
166
+ details: details(old_lts, new_lts, relation, false).merge(
167
+ "reason" => "The built-in F3 checker requires deterministic LTS models."
168
+ ))
169
+ end
170
+
171
+ counterexample = distinguishing_sequence(old_lts, new_lts)
172
+ verdict = if counterexample then :difference_found
173
+ elsif exact then :no_difference_found
174
+ else :inconclusive
175
+ end
176
+ Result.new(level: :f3, verdict:,
177
+ scope: Scope.new(size: [old_lts.states.length, new_lts.states.length].max,
178
+ depth: nil, cases: visited_count, exhaustive: exact),
179
+ assumptions:, out_of_scope: ["operations outside the learned alphabet", "unprojected state"],
180
+ counterexample: counterexample && { "sequence" => counterexample },
181
+ details: details(old_lts, new_lts, relation, exact))
182
+ end
183
+
184
+ private
185
+
186
+ def distinguishing_sequence(old_lts, new_lts)
187
+ @visited = {}
188
+ queue = [[old_lts.initial, new_lts.initial, []]]
189
+ until queue.empty?
190
+ old_state, new_state, path = queue.shift
191
+ next if @visited[[old_state, new_state]]
192
+
193
+ @visited[[old_state, new_state]] = true
194
+ inputs = (old_lts.outgoing(old_state).map(&:input) | new_lts.outgoing(new_state).map(&:input)).sort
195
+ inputs.each do |input|
196
+ old_transition = old_lts.step(old_state, input)
197
+ new_transition = new_lts.step(new_state, input)
198
+ return path + [input] unless old_transition && new_transition
199
+ return path + [input] unless old_transition.output == new_transition.output
200
+
201
+ queue << [old_transition.to, new_transition.to, path + [input]]
202
+ end
203
+ end
204
+ nil
205
+ end
206
+
207
+ def visited_count = @visited.length
208
+
209
+ def details(old_lts, new_lts, relation, exact)
210
+ { "relation" => relation.to_s,
211
+ "lts_o" => model_details(old_lts), "lts_n" => model_details(new_lts),
212
+ "equivalence_query" => { "method" => "paired-state breadth-first search", "exact" => exact },
213
+ "caveat" => "This result applies to the learned models, not to the complete implementations." }
214
+ end
215
+
216
+ def model_details(lts)
217
+ { "states" => lts.states.length, "transitions" => lts.transitions.length, "alphabet" => lts.alphabet }
218
+ end
219
+ end
220
+
221
+ module AldebaranExporter
222
+ module_function
223
+
224
+ def call(lts)
225
+ states = lts.states.each_with_index.to_h
226
+ lines = ["des (#{states.fetch(lts.initial)},#{lts.transitions.length},#{states.length})"]
227
+ lts.transitions.each do |transition|
228
+ label = JSON.generate("#{transition.input}/#{transition.output}")
229
+ lines << "(#{states.fetch(transition.from)},#{label},#{states.fetch(transition.to)})"
230
+ end
231
+ lines.join("\n") << "\n"
232
+ end
233
+ end
234
+
235
+ module CounterexampleRSpec
236
+ module_function
237
+
238
+ def call(lts:, sequence:, subject_name:)
239
+ expected = outputs(lts, sequence)
240
+ <<~RUBY
241
+ # frozen_string_literal: true
242
+
243
+ require "bparity"
244
+ load ENV.fetch("BPARITY_ADAPTER")
245
+
246
+ RSpec.describe "F3 distinguishing sequence for #{subject_name}" do
247
+ it "matches the learned legacy model for #{sequence.join(' -> ')}" do
248
+ binding = Bparity.adapter_definition.subjects.fetch(#{subject_name.inspect})
249
+ subject = binding.build({})
250
+ sequence = #{sequence.inspect}
251
+ actual = sequence.map do |name|
252
+ operation = binding.operations.fetch(name)
253
+ begin
254
+ value = operation.invoke(subject, [], {})
255
+ Bparity::Formal::LtsEncoding.output({ "kind" => "return", "value" =>
256
+ Bparity::Recording::Serializer.dump(operation.map_return(value)) })
257
+ rescue StandardError => error
258
+ Bparity::Formal::LtsEncoding.output({ "kind" => "raise", **operation.map_error(error) })
259
+ end
260
+ end
261
+ expect(actual).to eq(#{expected.inspect})
262
+ end
263
+ end
264
+ RUBY
265
+ end
266
+
267
+ def outputs(lts, sequence)
268
+ state = lts.initial
269
+ sequence.each_with_object([]) do |input, outputs|
270
+ transition = lts.step(state, input)
271
+ unless transition
272
+ outputs << LtsEncoding.output("kind" => "missing_transition", "input" => input)
273
+ break
274
+ end
275
+
276
+ state = transition.to
277
+ outputs << transition.output
278
+ end
279
+ end
280
+ private_class_method :outputs
281
+ end
282
+
283
+ class WMethod
284
+ def initialize(lts)
285
+ @lts = lts
286
+ end
287
+
288
+ def sequences(additional_states: 0)
289
+ cover = transition_cover
290
+ middle = (0..additional_states).flat_map { |length| @lts.alphabet.repeated_permutation(length).to_a }
291
+ suffixes = characterization_set
292
+ cover.product(middle, suffixes).map(&:flatten).uniq.sort_by { |sequence| [sequence.length, sequence] }
293
+ end
294
+
295
+ private
296
+
297
+ def transition_cover
298
+ paths = state_paths
299
+ (paths.values + @lts.transitions.map { |transition| paths.fetch(transition.from) + [transition.input] }).uniq
300
+ end
301
+
302
+ def state_paths
303
+ paths = { @lts.initial => [] }
304
+ queue = [@lts.initial]
305
+ until queue.empty?
306
+ state = queue.shift
307
+ @lts.outgoing(state).each do |transition|
308
+ next if paths.key?(transition.to)
309
+
310
+ paths[transition.to] = paths.fetch(state) + [transition.input]
311
+ queue << transition.to
312
+ end
313
+ end
314
+ paths
315
+ end
316
+
317
+ def characterization_set
318
+ pairs = @lts.states.combination(2)
319
+ sequences = pairs.filter_map { |left, right| distinguish(left, right) }
320
+ sequences.empty? ? [[]] : sequences.uniq
321
+ end
322
+
323
+ def distinguish(left, right)
324
+ queue = [[left, right, []]]
325
+ visited = {}
326
+ until queue.empty?
327
+ state_a, state_b, path = queue.shift
328
+ next if visited[[state_a, state_b]]
329
+
330
+ visited[[state_a, state_b]] = true
331
+ @lts.alphabet.each do |input|
332
+ transition_a = @lts.step(state_a, input)
333
+ transition_b = @lts.step(state_b, input)
334
+ return path + [input] unless transition_a && transition_b
335
+ return path + [input] unless transition_a.output == transition_b.output
336
+
337
+ queue << [transition_a.to, transition_b.to, path + [input]]
338
+ end
339
+ end
340
+ nil
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bparity
4
+ module Formal
5
+ class Scope
6
+ attr_reader :size, :depth, :cases, :exhaustive, :timebox
7
+
8
+ def initialize(size:, depth:, cases:, exhaustive:, timebox: nil)
9
+ @size = size
10
+ @depth = depth
11
+ @cases = cases
12
+ @exhaustive = exhaustive
13
+ @timebox = timebox
14
+ end
15
+
16
+ def to_h
17
+ { "size" => size, "depth" => depth, "cases" => cases, "exhaustive" => exhaustive,
18
+ "timebox_seconds" => timebox }
19
+ end
20
+ end
21
+
22
+ class Result
23
+ VERDICTS = %i[no_difference_found difference_found inconclusive].freeze
24
+
25
+ attr_reader :level, :verdict, :scope, :assumptions, :out_of_scope, :counterexample, :details
26
+
27
+ def initialize(level:, verdict:, scope:, assumptions:, out_of_scope:, counterexample: nil, details: {})
28
+ raise ConfigurationError, "A formal result requires a scope." unless scope
29
+ raise ConfigurationError, "A formal result requires verification assumptions." if assumptions.nil?
30
+ raise ConfigurationError, "Invalid formal verdict: #{verdict}." unless VERDICTS.include?(verdict)
31
+
32
+ @level = level.to_s.upcase
33
+ @verdict = verdict
34
+ @scope = scope
35
+ @assumptions = assumptions
36
+ @out_of_scope = out_of_scope
37
+ @counterexample = counterexample
38
+ @details = details
39
+ end
40
+
41
+ def to_h
42
+ { "level" => level, "verdict" => verdict.to_s, "scope" => scope.to_h,
43
+ "assumptions" => assumptions.map(&:to_s), "out_of_scope" => out_of_scope,
44
+ "counterexample" => counterexample, "details" => details }
45
+ end
46
+
47
+ def success? = verdict == :no_difference_found
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formal/result"
4
+ require_relative "formal/assumptions"
5
+ require_relative "formal/contract"
6
+ require_relative "formal/bounded"
7
+ require_relative "formal/lts"
8
+ require_relative "formal/deductive"