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.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +36 -0
- data/docs/application_example.md +25 -0
- data/docs/formal_assurance_limits.md +21 -0
- data/exe/bparity +7 -0
- data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
- data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
- data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
- data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
- data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
- data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
- data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
- data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
- data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
- data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
- data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
- data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
- data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
- data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
- data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
- data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
- data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
- data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
- data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
- data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
- data/lib/bparity/adapter.rb +115 -0
- data/lib/bparity/adequacy.rb +78 -0
- data/lib/bparity/boundary.rb +97 -0
- data/lib/bparity/cli/formal_commands.rb +428 -0
- data/lib/bparity/cli/verification_commands.rb +242 -0
- data/lib/bparity/cli.rb +262 -0
- data/lib/bparity/corpus.rb +45 -0
- data/lib/bparity/errors.rb +12 -0
- data/lib/bparity/formal/assumptions.rb +131 -0
- data/lib/bparity/formal/bounded.rb +543 -0
- data/lib/bparity/formal/contract.rb +81 -0
- data/lib/bparity/formal/deductive.rb +481 -0
- data/lib/bparity/formal/lts.rb +344 -0
- data/lib/bparity/formal/result.rb +50 -0
- data/lib/bparity/formal.rb +8 -0
- data/lib/bparity/recording.rb +412 -0
- data/lib/bparity/reporting.rb +128 -0
- data/lib/bparity/spec_bundle.rb +208 -0
- data/lib/bparity/synthesis.rb +487 -0
- data/lib/bparity/verification.rb +310 -0
- data/lib/bparity/version.rb +5 -0
- data/lib/bparity.rb +42 -0
- metadata +125 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "prism"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Bparity
|
|
8
|
+
module Formal
|
|
9
|
+
module Deductive
|
|
10
|
+
class Term
|
|
11
|
+
attr_reader :smt, :sort
|
|
12
|
+
|
|
13
|
+
def initialize(smt, sort, &evaluator)
|
|
14
|
+
@smt = smt
|
|
15
|
+
@sort = sort
|
|
16
|
+
@evaluator = evaluator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def evaluate(context) = @evaluator.call(context)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Translation = Struct.new(:parameters, :term, :source, :method_name, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
class FragmentChecker
|
|
25
|
+
FORBIDDEN_NODES = %i[class_variable_write_node global_variable_write_node instance_variable_write_node
|
|
26
|
+
call_and_write_node call_operator_write_node].freeze
|
|
27
|
+
FORBIDDEN_CALLS = %i[eval binding send public_send method_missing define_method rand sleep].freeze
|
|
28
|
+
|
|
29
|
+
def check_file(path, method_name)
|
|
30
|
+
result = Prism.parse_file(path)
|
|
31
|
+
return ["syntax error"] unless result.success?
|
|
32
|
+
|
|
33
|
+
method = nodes(result.value).find { |node| node.type == :def_node && node.name.to_s == method_name.to_s }
|
|
34
|
+
return ["method #{method_name} was not found"] unless method
|
|
35
|
+
|
|
36
|
+
nodes(method).filter_map do |node|
|
|
37
|
+
if FORBIDDEN_NODES.include?(node.type)
|
|
38
|
+
"#{node.type} at line #{node.location.start_line}"
|
|
39
|
+
elsif node.type == :call_node && FORBIDDEN_CALLS.include?(node.name)
|
|
40
|
+
"#{node.name} at line #{node.location.start_line}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def nodes(root, &)
|
|
48
|
+
return enum_for(__method__, root) unless block_given?
|
|
49
|
+
|
|
50
|
+
yield root
|
|
51
|
+
root.compact_child_nodes.each { |child| nodes(child, &) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class RubyToSmt
|
|
56
|
+
SORTS = { "Integer" => "Int", "Boolean" => "Bool", "String" => "String" }.freeze
|
|
57
|
+
OPERATORS = { :+ => "+", :- => "-", :* => "*", :== => "=", :!= => "distinct",
|
|
58
|
+
:> => ">", :>= => ">=", :< => "<", :<= => "<=" }.freeze
|
|
59
|
+
|
|
60
|
+
def translate_file(path, method_name, parameter_types:)
|
|
61
|
+
result = Prism.parse_file(path)
|
|
62
|
+
raise ConfigurationError, "Cannot parse #{path}. Fix its Ruby syntax." unless result.success?
|
|
63
|
+
|
|
64
|
+
method = find_method(result.value, method_name)
|
|
65
|
+
raise ConfigurationError, "Method #{method_name} was not found in #{path}." unless method
|
|
66
|
+
|
|
67
|
+
parameters = method.parameters.requireds.map(&:name)
|
|
68
|
+
if parameters.length != parameter_types.length
|
|
69
|
+
message = "Method #{method_name} has #{parameters.length} parameters, " \
|
|
70
|
+
"but #{parameter_types.length} types were given."
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
message
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
environment = parameters.each_with_index.to_h do |name, index|
|
|
76
|
+
sort = SORTS.fetch(parameter_types[index]) do
|
|
77
|
+
raise ConfigurationError,
|
|
78
|
+
"F4 does not support #{parameter_types[index]}. Use Integer, Boolean, or String."
|
|
79
|
+
end
|
|
80
|
+
[name, Term.new("arg#{index}", sort) { |context| context.fetch(index) }]
|
|
81
|
+
end
|
|
82
|
+
body = translate_statements(method.body, environment)
|
|
83
|
+
Translation.new(parameters: parameter_types.each_with_index.map do |type, index|
|
|
84
|
+
["arg#{index}", SORTS.fetch(type)]
|
|
85
|
+
end,
|
|
86
|
+
term: body, source: path, method_name: method_name.to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def find_method(root, name)
|
|
92
|
+
return root if root.type == :def_node && root.name.to_s == name.to_s
|
|
93
|
+
|
|
94
|
+
root.compact_child_nodes.filter_map { |child| find_method(child, name) }.first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def translate_statements(node, environment)
|
|
98
|
+
statements = node.type == :statements_node ? node.body : [node]
|
|
99
|
+
final = nil
|
|
100
|
+
statements.each do |statement|
|
|
101
|
+
final = if statement.type == :local_variable_write_node
|
|
102
|
+
environment[statement.name] = translate(statement.value, environment)
|
|
103
|
+
else
|
|
104
|
+
translate(statement, environment)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
final || Term.new("true", "Bool") { true }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def translate(node, environment)
|
|
111
|
+
case node.type
|
|
112
|
+
when :local_variable_read_node then environment.fetch(node.name)
|
|
113
|
+
when :integer_node then literal(node.value, "Int")
|
|
114
|
+
when :string_node then string_literal(node.unescaped)
|
|
115
|
+
when :true_node, :false_node then literal(node.type == :true_node, "Bool")
|
|
116
|
+
when :parentheses_node then translate_statements(node.body, environment.dup)
|
|
117
|
+
when :and_node then translate_boolean(node.left, node.right, environment, "and")
|
|
118
|
+
when :or_node then translate_boolean(node.left, node.right, environment, "or")
|
|
119
|
+
when :if_node then translate_if(node, environment)
|
|
120
|
+
when :return_node then translate(node.arguments.arguments.first, environment)
|
|
121
|
+
when :call_node then translate_call(node, environment)
|
|
122
|
+
else unsupported!(node)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def translate_if(node, environment)
|
|
127
|
+
unsupported!(node) unless node.subsequent
|
|
128
|
+
|
|
129
|
+
condition = translate(node.predicate, environment)
|
|
130
|
+
unless condition.sort == "Bool"
|
|
131
|
+
raise ConfigurationError,
|
|
132
|
+
"F4 condition at line #{node.location.start_line} must be Boolean. Update the pure fragment."
|
|
133
|
+
end
|
|
134
|
+
truthy = translate_statements(node.statements, environment.dup)
|
|
135
|
+
falsy = translate_else(node.subsequent, environment.dup)
|
|
136
|
+
ensure_sort!(truthy, falsy, node)
|
|
137
|
+
Term.new("(ite #{condition.smt} #{truthy.smt} #{falsy.smt})", truthy.sort) do |context|
|
|
138
|
+
condition.evaluate(context) ? truthy.evaluate(context) : falsy.evaluate(context)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def translate_else(node, environment)
|
|
143
|
+
return translate_if(node, environment) if node.type == :if_node
|
|
144
|
+
|
|
145
|
+
translate_statements(node.statements, environment)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def translate_call(node, environment)
|
|
149
|
+
receiver = translate(node.receiver, environment)
|
|
150
|
+
arguments = node.arguments&.arguments&.map { |argument| translate(argument, environment) } || []
|
|
151
|
+
return translate_not(receiver) if node.name == :! && arguments.empty?
|
|
152
|
+
if receiver.sort == "String" && !%i[== !=].include?(node.name)
|
|
153
|
+
return translate_string_call(node.name, receiver, arguments, node)
|
|
154
|
+
end
|
|
155
|
+
return translate_operator(node.name, receiver, arguments.first, node) if OPERATORS.key?(node.name)
|
|
156
|
+
|
|
157
|
+
unsupported!(node)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def translate_boolean(left_node, right_node, environment, operator)
|
|
161
|
+
left = translate(left_node, environment)
|
|
162
|
+
right = translate(right_node, environment)
|
|
163
|
+
unless left.sort == "Bool" && right.sort == "Bool"
|
|
164
|
+
raise ConfigurationError, "F4 #{operator} operands must be Boolean. Update the pure fragment."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Term.new("(#{operator} #{left.smt} #{right.smt})", "Bool") do |context|
|
|
168
|
+
if operator == "and"
|
|
169
|
+
left.evaluate(context) && right.evaluate(context)
|
|
170
|
+
else
|
|
171
|
+
left.evaluate(context) || right.evaluate(context)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def translate_not(term)
|
|
177
|
+
raise ConfigurationError, "F4 ! operand must be Boolean. Update the pure fragment." unless term.sort == "Bool"
|
|
178
|
+
|
|
179
|
+
Term.new("(not #{term.smt})", "Bool") { |context| !term.evaluate(context) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def translate_operator(name, left, right, node)
|
|
183
|
+
ensure_sort!(left, right, node)
|
|
184
|
+
if !%i[== !=].include?(name) && left.sort != "Int"
|
|
185
|
+
raise ConfigurationError,
|
|
186
|
+
"F4 arithmetic at line #{node.location.start_line} requires Integer operands."
|
|
187
|
+
end
|
|
188
|
+
output_sort = %i[== != > >= < <=].include?(name) ? "Bool" : left.sort
|
|
189
|
+
Term.new("(#{OPERATORS.fetch(name)} #{left.smt} #{right.smt})", output_sort) do |context|
|
|
190
|
+
left.evaluate(context).public_send(name, right.evaluate(context))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def translate_string_call(name, receiver, arguments, node)
|
|
195
|
+
case name
|
|
196
|
+
when :+
|
|
197
|
+
argument = arguments.fetch(0)
|
|
198
|
+
ensure_sort!(receiver, argument, node)
|
|
199
|
+
Term.new("(str.++ #{receiver.smt} #{argument.smt})", "String") do |context|
|
|
200
|
+
receiver.evaluate(context) + argument.evaluate(context)
|
|
201
|
+
end
|
|
202
|
+
when :size, :length
|
|
203
|
+
Term.new("(str.len #{receiver.smt})", "Int") { |context| receiver.evaluate(context).length }
|
|
204
|
+
when :empty?
|
|
205
|
+
Term.new("(= #{receiver.smt} \"\")", "Bool") { |context| receiver.evaluate(context).empty? }
|
|
206
|
+
when :start_with?, :end_with?, :include?
|
|
207
|
+
string_predicate(name, receiver, arguments.fetch(0))
|
|
208
|
+
else unsupported!(node)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def string_predicate(name, receiver, argument)
|
|
213
|
+
smt_name = { start_with?: "str.prefixof", end_with?: "str.suffixof", include?: "str.contains" }.fetch(name)
|
|
214
|
+
smt_args = name == :include? ? [receiver, argument] : [argument, receiver]
|
|
215
|
+
Term.new("(#{smt_name} #{smt_args.map(&:smt).join(' ')})", "Bool") do |context|
|
|
216
|
+
receiver.evaluate(context).public_send(name, argument.evaluate(context))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def literal(value, sort)
|
|
221
|
+
smt = case value
|
|
222
|
+
when true then "true"
|
|
223
|
+
when false then "false"
|
|
224
|
+
else value.negative? ? "(- #{value.abs})" : value.to_s
|
|
225
|
+
end
|
|
226
|
+
Term.new(smt, sort) { value }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def string_literal(value)
|
|
230
|
+
escaped = value.gsub('"', '""')
|
|
231
|
+
Term.new(%("#{escaped}"), "String") { value }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def ensure_sort!(left, right, node)
|
|
235
|
+
return if left.sort == right.sort
|
|
236
|
+
|
|
237
|
+
raise ConfigurationError,
|
|
238
|
+
"F4 sort mismatch at line #{node.location.start_line}. Add compatible parameter types."
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def unsupported!(node)
|
|
242
|
+
message = "F4 does not support #{node.type} at line #{node.location.start_line}. " \
|
|
243
|
+
"Use F2 or simplify the pure fragment."
|
|
244
|
+
raise ConfigurationError,
|
|
245
|
+
message
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
module ProductProgram
|
|
250
|
+
module_function
|
|
251
|
+
|
|
252
|
+
def call(old_translation, new_translation)
|
|
253
|
+
unless old_translation.parameters == new_translation.parameters &&
|
|
254
|
+
old_translation.term.sort == new_translation.term.sort
|
|
255
|
+
raise ConfigurationError, "The F4 translations have incompatible input or output sorts."
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
declarations = old_translation.parameters.map { |name, sort| "(declare-const #{name} #{sort})" }
|
|
259
|
+
(["(set-logic ALL)"] + declarations +
|
|
260
|
+
["(assert (not (= #{old_translation.term.smt} #{new_translation.term.smt})))", "(check-sat)",
|
|
261
|
+
"(get-model)"]).join("\n") << "\n"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
Validation = Struct.new(:valid, :cases, :mismatch, keyword_init: true)
|
|
266
|
+
|
|
267
|
+
class TranslationValidator
|
|
268
|
+
def validate(translation, callable, inputs)
|
|
269
|
+
inputs.each_with_index do |input, index|
|
|
270
|
+
ruby_value = observe { callable.call(*input) }
|
|
271
|
+
context = input.each_with_index.to_h { |value, position| [position, value] }
|
|
272
|
+
translated_value = observe { translation.term.evaluate(context) }
|
|
273
|
+
next if ruby_value == translated_value
|
|
274
|
+
|
|
275
|
+
mismatch = { "input" => input, "ruby" => ruby_value, "translation" => translated_value }
|
|
276
|
+
return Validation.new(valid: false, cases: index + 1, mismatch:)
|
|
277
|
+
end
|
|
278
|
+
Validation.new(valid: true, cases: inputs.length)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def observe
|
|
284
|
+
{ "kind" => "return", "value" => yield }
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
{ "kind" => "raise", "class" => e.class.name, "message" => Bparity.exception_message(e) }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
class Z3
|
|
291
|
+
def initialize(timeout: 300, executable: ENV.fetch("BPARITY_Z3", "z3"))
|
|
292
|
+
@timeout = timeout
|
|
293
|
+
@executable = executable
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def available?
|
|
297
|
+
_out, _err, status = Open3.capture3(@executable, "--version")
|
|
298
|
+
status.success?
|
|
299
|
+
rescue Errno::ENOENT
|
|
300
|
+
false
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def solve(script)
|
|
304
|
+
output = error = status = nil
|
|
305
|
+
Open3.popen3(@executable, "-in") do |stdin, stdout, stderr, wait|
|
|
306
|
+
stdin.write(script)
|
|
307
|
+
stdin.close
|
|
308
|
+
Timeout.timeout(@timeout) do
|
|
309
|
+
output = stdout.read
|
|
310
|
+
error = stderr.read
|
|
311
|
+
status = wait.value
|
|
312
|
+
end
|
|
313
|
+
rescue Timeout::Error
|
|
314
|
+
Process.kill("TERM", wait.pid) unless wait.join(0.1)
|
|
315
|
+
return { verdict: :unknown, output: "z3 timed out after #{@timeout} seconds" }
|
|
316
|
+
end
|
|
317
|
+
first = output.lines.first&.strip
|
|
318
|
+
verdict = { "unsat" => :unsat, "sat" => :sat }.fetch(first, :unknown)
|
|
319
|
+
return { verdict:, output: output } unless verdict == :unknown
|
|
320
|
+
|
|
321
|
+
{ verdict: :unknown, output: status.success? ? output : error }
|
|
322
|
+
rescue Errno::ENOENT
|
|
323
|
+
{ verdict: :unavailable, output: "z3 executable not found" }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
module ModelParser
|
|
328
|
+
module_function
|
|
329
|
+
|
|
330
|
+
def call(output)
|
|
331
|
+
expressions = parse_all(output.scan(/"(?:[^"]|"")*"|[()]|[^\s()]+/))
|
|
332
|
+
definitions = expressions.flat_map { |expression| find_definitions(expression) }
|
|
333
|
+
definitions.to_h do |definition|
|
|
334
|
+
[definition.fetch(1), ruby_value(definition.fetch(3), definition.fetch(4))]
|
|
335
|
+
end
|
|
336
|
+
rescue StandardError
|
|
337
|
+
{}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def parse_all(tokens)
|
|
341
|
+
values = []
|
|
342
|
+
values << parse(tokens) until tokens.empty?
|
|
343
|
+
values
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def parse(tokens)
|
|
347
|
+
token = tokens.shift
|
|
348
|
+
return token unless token == "("
|
|
349
|
+
|
|
350
|
+
values = []
|
|
351
|
+
values << parse(tokens) until tokens.first == ")" || tokens.empty?
|
|
352
|
+
tokens.shift
|
|
353
|
+
values
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def find_definitions(value)
|
|
357
|
+
return [] unless value.is_a?(Array)
|
|
358
|
+
|
|
359
|
+
found = value.first == "define-fun" ? [value] : []
|
|
360
|
+
found + value.flat_map { |child| find_definitions(child) }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def ruby_value(sort, value)
|
|
364
|
+
case sort
|
|
365
|
+
when "Int" then value.is_a?(Array) ? -Integer(value.fetch(1), 10) : Integer(value, 10)
|
|
366
|
+
when "Bool" then value == "true"
|
|
367
|
+
when "String" then value.delete_prefix('"').delete_suffix('"').gsub('""', '"')
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
class Runner
|
|
373
|
+
def initialize(old_translation:, new_translation:, old_callable:, new_callable:, validation_inputs:,
|
|
374
|
+
assumptions: %i[h1 h3], solver: Z3.new, validation_scope: nil)
|
|
375
|
+
@old_translation = old_translation
|
|
376
|
+
@new_translation = new_translation
|
|
377
|
+
@old_callable = old_callable
|
|
378
|
+
@new_callable = new_callable
|
|
379
|
+
@validation_inputs = validation_inputs
|
|
380
|
+
@assumptions = assumptions
|
|
381
|
+
@solver = solver
|
|
382
|
+
@validation_scope = validation_scope || { "cases" => validation_inputs.length,
|
|
383
|
+
"exhaustive" => true,
|
|
384
|
+
"domain" => "provided bounded validation inputs" }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def run
|
|
388
|
+
validations = validate_translations
|
|
389
|
+
return result(:inconclusive, validations, "translation validation failed") unless validations.all?(&:valid)
|
|
390
|
+
return result(:inconclusive, validations, "z3 executable not found") unless @solver.available?
|
|
391
|
+
|
|
392
|
+
solved = @solver.solve(ProductProgram.call(@old_translation, @new_translation))
|
|
393
|
+
case solved[:verdict]
|
|
394
|
+
when :unsat then result(:no_difference_found, validations, "Z3 returned unsat")
|
|
395
|
+
when :sat then result(:difference_found, validations, "Z3 returned sat", counterexample(solved[:output]))
|
|
396
|
+
else result(:inconclusive, validations, "Z3 returned unknown", solved[:output])
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
private
|
|
401
|
+
|
|
402
|
+
def validate_translations
|
|
403
|
+
validator = TranslationValidator.new
|
|
404
|
+
[validator.validate(@old_translation, @old_callable, @validation_inputs),
|
|
405
|
+
validator.validate(@new_translation, @new_callable, @validation_inputs)]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def result(verdict, validations, reason, counterexample = nil)
|
|
409
|
+
cases = validations.map(&:cases).min
|
|
410
|
+
Result.new(level: :f4, verdict:,
|
|
411
|
+
scope: Scope.new(size: "unbounded", depth: nil, cases:,
|
|
412
|
+
exhaustive: verdict == :no_difference_found),
|
|
413
|
+
assumptions: @assumptions,
|
|
414
|
+
out_of_scope: ["Ruby outside the declared pure fragment", "unsupported library semantics"],
|
|
415
|
+
counterexample:,
|
|
416
|
+
details: { "reason" => reason, "translation_validation" => validations.map(&:to_h),
|
|
417
|
+
"translation_validation_scope" => @validation_scope,
|
|
418
|
+
"translated_methods" => translated_methods })
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def translated_methods
|
|
422
|
+
[@old_translation, @new_translation].map do |translation|
|
|
423
|
+
{ "source" => translation.source, "method" => translation.method_name,
|
|
424
|
+
"input_sorts" => translation.parameters.map(&:last), "output_sort" => translation.term.sort }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def counterexample(output)
|
|
429
|
+
model = ModelParser.call(output)
|
|
430
|
+
input = @old_translation.parameters.map { |name, _sort| model[name] }
|
|
431
|
+
data = { "solver_model" => output }
|
|
432
|
+
return data if input.any?(&:nil?)
|
|
433
|
+
|
|
434
|
+
data.merge("input" => input, "expected" => observe(@old_callable, input),
|
|
435
|
+
"actual" => observe(@new_callable, input))
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def observe(callable, input)
|
|
439
|
+
{ "kind" => "return", "value" => Recording::Serializer.dump(callable.call(*input)) }
|
|
440
|
+
rescue StandardError => e
|
|
441
|
+
{ "kind" => "raise", "class" => e.class.name, "message" => Bparity.exception_message(e),
|
|
442
|
+
"cause" => e.cause&.class&.name }
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
module CounterexampleRSpec
|
|
447
|
+
module_function
|
|
448
|
+
|
|
449
|
+
def call(result:, subject_name:, operation_name:)
|
|
450
|
+
counterexample = result.counterexample
|
|
451
|
+
input = counterexample.fetch("input")
|
|
452
|
+
expected = counterexample.fetch("expected")
|
|
453
|
+
<<~RUBY
|
|
454
|
+
# frozen_string_literal: true
|
|
455
|
+
|
|
456
|
+
require "bparity"
|
|
457
|
+
require ENV["BPARITY_REPLACEMENT"] if ENV["BPARITY_REPLACEMENT"]
|
|
458
|
+
load ENV.fetch("BPARITY_ADAPTER")
|
|
459
|
+
|
|
460
|
+
RSpec.describe "F4 counterexample for #{subject_name}#{operation_name}" do
|
|
461
|
+
it "reproduces solver input #{input.inspect}" do
|
|
462
|
+
binding = Bparity.adapter_definition.subjects.fetch(#{subject_name.inspect})
|
|
463
|
+
operation = binding.operations.fetch(#{operation_name.inspect})
|
|
464
|
+
begin
|
|
465
|
+
value = operation.invoke(binding.build({}), #{input.inspect}, {})
|
|
466
|
+
value = operation.map_return(value)
|
|
467
|
+
actual = { "kind" => "return", "value" => Bparity::Recording::Serializer.dump(value) }
|
|
468
|
+
rescue StandardError => error
|
|
469
|
+
mapped = operation.map_error(error).transform_keys(&:to_s)
|
|
470
|
+
mapped["cause"] = error.cause&.class&.name unless mapped.key?("cause")
|
|
471
|
+
actual = { "kind" => "raise", **mapped }
|
|
472
|
+
end
|
|
473
|
+
expect(actual).to eq(#{expected.inspect})
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
RUBY
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|