quint_driver 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/LICENSE +21 -0
- data/README.ja.md +865 -0
- data/README.md +648 -0
- data/examples/loans/loans.qnt +57 -0
- data/examples/loans/loans.rb +62 -0
- data/lib/quint_driver/hegel.rb +141 -0
- data/lib/quint_driver/model.rb +289 -0
- data/lib/quint_driver/quint.rb +376 -0
- data/lib/quint_driver/spec.rb +110 -0
- data/lib/quint_driver/trace.rb +129 -0
- data/lib/quint_driver/version.rb +3 -0
- data/lib/quint_driver.rb +199 -0
- data/skills/quint_driver/SKILL.md +925 -0
- metadata +55 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
require "etc"
|
|
2
|
+
require "json"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module QuintDriver
|
|
8
|
+
# The `quint` command line: the one place this library runs another process.
|
|
9
|
+
module Quint
|
|
10
|
+
# Read on every call, so a caller can set QUINT after requiring the gem.
|
|
11
|
+
def self.command = ENV.fetch("QUINT", "npx --yes @informalsystems/quint@0.33.0")
|
|
12
|
+
|
|
13
|
+
# The spec's JSON IR, frozen. +main+ names the module quint checks;
|
|
14
|
+
# without it, quint checks the module named after the file, and fails when
|
|
15
|
+
# none is. A file compiles once per process and quint command, until its
|
|
16
|
+
# text or the text of a file it imports changes: one compile costs a
|
|
17
|
+
# second or two of node, and every Spec.new asks for one. Its other
|
|
18
|
+
# modules reuse that compile: quint typechecks every module of the file
|
|
19
|
+
# whichever is main, and --main only sets "main" and adds q::init and
|
|
20
|
+
# q::step, which nothing here reads. They are `true` here: quint names
|
|
21
|
+
# init and step there by default, and fails a module that lacks either,
|
|
22
|
+
# though a caller can pass step: another action. Threads that ask for one
|
|
23
|
+
# file at once wait for one compile; other files compile beside it.
|
|
24
|
+
#
|
|
25
|
+
# With +flat+, the IR holds only +main+, with each definition it imports
|
|
26
|
+
# among its own declarations, as quint flattens a module before it runs
|
|
27
|
+
# it. That is one compile per module, so only a module that imports asks
|
|
28
|
+
# for it. quint drops each imported definition that neither q::init, nor
|
|
29
|
+
# q::step, nor a declaration of +main+ reaches, so q::step there names
|
|
30
|
+
# every one it can (imported_names): a module needs no action named step
|
|
31
|
+
# to compile, and a definition only the caller reads stays.
|
|
32
|
+
def self.compile(path, main: nil, flat: false)
|
|
33
|
+
key = [ command, File.expand_path(path), (main if flat) ]
|
|
34
|
+
wanted = main || File.basename(path, ".qnt")
|
|
35
|
+
@locking.synchronize { @locks[key] ||= Mutex.new }.synchronize do
|
|
36
|
+
sources = sources(path) if File.file?(path)
|
|
37
|
+
cached_sources, ir = @compiled[key]
|
|
38
|
+
if sources && sources == cached_sources && ir["modules"].any? { _1["name"] == wanted }
|
|
39
|
+
return ir["main"] == wanted ? ir : ir.merge("main" => wanted).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ir = compile_now(path, main, flat)
|
|
43
|
+
@compiled[key] = [ sources, ir ] if sources
|
|
44
|
+
ir
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
@compiled = {}
|
|
48
|
+
@locks = {}
|
|
49
|
+
@locking = Mutex.new
|
|
50
|
+
|
|
51
|
+
# The text of +path+ and of each file it imports, directly or through
|
|
52
|
+
# another, by path. Quint resolves `from "name"` against the directory of
|
|
53
|
+
# the file that writes it, and adds .qnt to the name, also to one that
|
|
54
|
+
# ends in .qnt, so `from "start.qnt"` reads start.qnt.qnt. A name in
|
|
55
|
+
# a comment reads as an import too, which costs only a file read.
|
|
56
|
+
def self.sources(path, read = {})
|
|
57
|
+
path = File.expand_path(path)
|
|
58
|
+
return read if read.key?(path) || !File.file?(path)
|
|
59
|
+
|
|
60
|
+
read[path] = File.read(path)
|
|
61
|
+
read[path].scan(/\bfrom\s+"([^"]+)"/) do |(name)|
|
|
62
|
+
sources(File.join(File.dirname(path), "#{name}.qnt"), read)
|
|
63
|
+
end
|
|
64
|
+
read
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.compile_now(path, main, flat)
|
|
68
|
+
Dir.mktmpdir("quint") do |dir|
|
|
69
|
+
# Into a file: through a pipe, quint exits before its 64 KB of JSON is
|
|
70
|
+
# out. With --out, quint writes the modules as the file has them; on
|
|
71
|
+
# stdout it prints the main module flattened. The shell makes that file
|
|
72
|
+
# before quint runs, so a flat compile that fails leaves it empty.
|
|
73
|
+
out = File.join(dir, "ir.json")
|
|
74
|
+
words = [ "compile", "--target", "json", path, *([ "--main", main ] if main), "--init", "true" ]
|
|
75
|
+
output, status = if flat
|
|
76
|
+
capture(*words, "--step", "and(true, #{imported_names(compile(path, main:), main).join(', ')})", stdout: out)
|
|
77
|
+
else
|
|
78
|
+
capture(*words, "--step", "true", "--out", out)
|
|
79
|
+
end
|
|
80
|
+
ir = begin
|
|
81
|
+
File.size?(out) ? JSON.parse(File.read(out), freeze: true) : {}
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
# A flat compile that fails to flatten prints the module text
|
|
84
|
+
# instead, and its error with the stack of quint's own code.
|
|
85
|
+
output = "#{output.gsub(/^ +at .*\n?/, '').rstrip}\nquint flattens every module of the file, so the module that lacks the name may be another one"
|
|
86
|
+
{ "stage" => "flattening" }
|
|
87
|
+
end
|
|
88
|
+
return ir if status.success? && ir.key?("modules")
|
|
89
|
+
|
|
90
|
+
# With --out, quint writes its errors into the file and prints nothing;
|
|
91
|
+
# a missing spec leaves no error at all, only the stage it stopped at,
|
|
92
|
+
# and a missing module only the modules the file has. quint lists them
|
|
93
|
+
# in an order an edit to one module can change; it numbers a module
|
|
94
|
+
# after the text it parses, so its id gives the order of the file.
|
|
95
|
+
modules = ir.fetch("modules", []).sort_by { _1["id"] }.map { _1["name"] }
|
|
96
|
+
wanted = main || File.basename(path, ".qnt")
|
|
97
|
+
missing = "no module is named #{wanted}; #{path} has #{modules.join(', ')}" if modules.any? && !modules.include?(wanted)
|
|
98
|
+
reasons = [ output.strip, *ir.fetch("errors", []).map { _1["explanation"] }, missing.to_s ].reject(&:empty?)
|
|
99
|
+
failed "quint compile failed for #{path} at the #{ir.fetch('stage', 'start')} stage\n#{reasons.join("\n")}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Each name +main+ imports, so a flat compile whose --step is `and` of
|
|
104
|
+
# them keeps every definition +main+ imports, not only the ones its own
|
|
105
|
+
# declarations reach. quint 0.33.0 resolves the names of --step there
|
|
106
|
+
# and checks no type, so a value, a run, or a def with parameters stands
|
|
107
|
+
# in it as an action does. An instance adds no name: its definitions
|
|
108
|
+
# would bring its variables, which a trace of `quint run` lacks when
|
|
109
|
+
# nothing of +main+ reaches them, and init would leave them unassigned.
|
|
110
|
+
# An export can name a module by what an import of it calls it, such as
|
|
111
|
+
# A in `import base as A` and `export A.*`.
|
|
112
|
+
def self.imported_names(ir, main)
|
|
113
|
+
modules = ir.fetch("modules").to_h { [ _1["name"], _1["declarations"] ] }
|
|
114
|
+
named = ->(declarations, by) { declarations.select { [ nil, "*", _1["name"] ].include?(by["defName"]) }.map { renamed(_1, by) } }
|
|
115
|
+
shown = lambda do |name|
|
|
116
|
+
declarations = modules.fetch(name, [])
|
|
117
|
+
aliases = declarations.select { _1["kind"] == "import" }.to_h { [ _1["qualifiedName"], _1["protoName"] ] }
|
|
118
|
+
declarations.flat_map do |declaration|
|
|
119
|
+
case declaration["kind"]
|
|
120
|
+
when "def" then [ declaration ]
|
|
121
|
+
when "export" then named.(shown.(aliases.fetch(declaration["protoName"], declaration["protoName"])), declaration)
|
|
122
|
+
else []
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
modules.fetch(main).select { _1["kind"] == "import" }.flat_map { named.(shown.(_1["protoName"]), _1) }.map { _1["name"] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.renamed(declaration, import)
|
|
130
|
+
import["qualifiedName"] ? declaration.merge("name" => "#{import['qualifiedName']}::#{declaration['name']}") : declaration
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Writes +traces+ traces of the spec's +step+ action and yields each file.
|
|
134
|
+
# +main+ names the module to run; without it, quint runs the module named
|
|
135
|
+
# after the file. +reach+ names a definition of the module that holds at
|
|
136
|
+
# the state a test is after: each trace then ends at the first state where
|
|
137
|
+
# it holds, and only the traces that get there are yielded.
|
|
138
|
+
def self.simulate(path, traces:, max_steps:, seed:, main: nil, step: "step", backend: "rust", reach: nil)
|
|
139
|
+
Dir.mktmpdir("quint") do |dir|
|
|
140
|
+
refuse_unfound = runnable!(path, main:, step:, reach:)
|
|
141
|
+
evaluator = self.evaluator if backend == "rust"
|
|
142
|
+
input = evaluator_input(path, traces:, max_steps:, seed:, main:, step:, reach:) if evaluator
|
|
143
|
+
if input
|
|
144
|
+
write_traces(evaluator, input, dir)
|
|
145
|
+
else
|
|
146
|
+
arguments = [ "run", path, "--mbt", "--backend", backend, "--max-steps", max_steps, "--max-samples", traces,
|
|
147
|
+
"--n-traces", traces, "--seed", seed, "--step", step, "--out-itf", "#{dir}/trace_{seq}.itf.json", "--verbosity", 0 ]
|
|
148
|
+
arguments += [ "--main", main ] if main
|
|
149
|
+
# quint ends a trace where its invariant breaks, and exits 1 then.
|
|
150
|
+
arguments += [ "--invariant", "not(#{reach})" ] if reach
|
|
151
|
+
output, status = capture(*arguments)
|
|
152
|
+
refuse_unfound.(output)
|
|
153
|
+
failed "quint run failed:\n#{output}" unless status.success? || reach && output.match?(/^error: invariant violated$/i)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A trace that broke the invariant is one that reached +reach+.
|
|
157
|
+
traces_written = Dir["#{dir}/trace_*.itf.json"].sort_by { _1[/(\d+)\.itf/, 1].to_i }
|
|
158
|
+
traces_written.select! { JSON.parse(File.read(_1)).dig("#meta", "status") == "violation" } if reach
|
|
159
|
+
if reach && traces_written.empty?
|
|
160
|
+
failed "quint: no trace of #{traces} reached #{reach} within #{max_steps} steps; raise max_steps or traces"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
written_by = call("simulate", path, traces:, max_steps:, seed:, main:, step:, backend:, reach:)
|
|
164
|
+
yield_traces(traces_written, seed:, written_by:) { yield _1 }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Raises ArgumentError when +step+ names no action without parameters, or
|
|
169
|
+
# +reach+ no predicate. quint finds that out only while it runs, and its
|
|
170
|
+
# two evaluators each say "Expected boolean" in words of their own. A
|
|
171
|
+
# name the module does not declare may be one it imports, which this IR
|
|
172
|
+
# does not hold, so the check it returns reads quint's output for a name
|
|
173
|
+
# quint did not find either.
|
|
174
|
+
def self.runnable!(path, main:, step:, reach:)
|
|
175
|
+
ir = compile(path, main:)
|
|
176
|
+
declarations = ir.fetch("modules").find { _1["name"] == ir["main"] }.fetch("declarations")
|
|
177
|
+
kinds = declarations.select { _1["name"] }.to_h { [ _1["name"], kind_of(_1, ir.fetch("types")) ] }
|
|
178
|
+
wanted = { "step" => [ step, "an action", "an action without parameters" ],
|
|
179
|
+
"reach" => [ reach, "a predicate", "a predicate, a boolean val or def without parameters" ] }
|
|
180
|
+
refuse = lambda do |argument, what|
|
|
181
|
+
name, kind, takes = wanted.fetch(argument)
|
|
182
|
+
such_as = kinds.select { |of_name, of| of == kind && of_name != "init" && !of_name.start_with?("q::") }.keys.sort.join(", ")
|
|
183
|
+
Agreement.refuse "quint: #{argument} names #{name}, #{what}; #{argument} takes #{takes}, such as #{such_as}"
|
|
184
|
+
end
|
|
185
|
+
wanted.each do |argument, (name, kind)|
|
|
186
|
+
refuse.(argument, "#{kinds[name]} of #{ir['main']}") if kinds.key?(name) && kinds[name] != kind
|
|
187
|
+
end
|
|
188
|
+
lambda do |output|
|
|
189
|
+
unfound = output[/\[QNT404\] Name '(.+)' not found/, 1]
|
|
190
|
+
Agreement.refuse(without_init(ir["main"])) if unfound == "init"
|
|
191
|
+
argument, = wanted.find { |_, (name)| name == unfound } if unfound
|
|
192
|
+
refuse.(argument, "which #{ir['main']} neither declares nor imports") if argument
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Quint's own --init could name another action, but a trace names its
|
|
197
|
+
# first step after that action, the model and the machine step through
|
|
198
|
+
# init, and a driver reads init: one name keeps the four alike.
|
|
199
|
+
def self.without_init(main)
|
|
200
|
+
"quint: #{main} neither declares nor imports an action named init; quint_driver starts every trace " \
|
|
201
|
+
"and every model at init, so declare `action init = ...` with what starts #{main}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# What a declaration is, as a step or a reach has to tell: a variant
|
|
205
|
+
# quint declares as a val, or as a def when it carries a value. A val and
|
|
206
|
+
# a def without parameters both read as a value: the keyword changes
|
|
207
|
+
# nothing a step or a reach needs.
|
|
208
|
+
def self.kind_of(declaration, types)
|
|
209
|
+
expr = declaration["expr"] || {}
|
|
210
|
+
lambda = expr["kind"] == "lambda"
|
|
211
|
+
type = types.dig(declaration["id"].to_s, "type", "kind")
|
|
212
|
+
case
|
|
213
|
+
when declaration["kind"] == "var" then "a variable"
|
|
214
|
+
when declaration["kind"] == "const" then "a constant"
|
|
215
|
+
when declaration["kind"] != "def" then "a type"
|
|
216
|
+
when (lambda ? expr["expr"] : expr)["opcode"] == "variant" then "a case of a sum type"
|
|
217
|
+
when declaration["qualifier"] == "action" then lambda ? "an action with parameters" : "an action"
|
|
218
|
+
when declaration["qualifier"] == "run" then "a run"
|
|
219
|
+
when declaration["qualifier"] == "temporal" then "a temporal formula"
|
|
220
|
+
when lambda then "a def with parameters"
|
|
221
|
+
when type == "bool" then "a predicate"
|
|
222
|
+
else "#{type.match?(/\A[aeiou]/) ? 'an' : 'a'} #{type} value"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Writes the trace of the run named +name+, such as
|
|
227
|
+
# `run lendTest = init.then(...)`, and yields its file. +main+ names the
|
|
228
|
+
# module that holds the run. quint test writes no mbt::actionTaken, so
|
|
229
|
+
# replay it with Trace.replay's +action+: the spec has to record the
|
|
230
|
+
# action each step took, as a Choreo spec does.
|
|
231
|
+
def self.test(path, name, seed:, main: nil)
|
|
232
|
+
Dir.mktmpdir("quint") do |dir|
|
|
233
|
+
arguments = [ "test", path, "--match", "^#{name}$", "--seed", seed, "--max-samples", 1, "--out-itf", "#{dir}/test_{seq}.itf.json", "--verbosity", 1 ]
|
|
234
|
+
arguments += [ "--main", main ] if main
|
|
235
|
+
output, status = capture(*arguments)
|
|
236
|
+
failed "quint test failed:\n#{output}" unless status.success?
|
|
237
|
+
|
|
238
|
+
written = Dir["#{dir}/test_*.itf.json"]
|
|
239
|
+
if written.empty?
|
|
240
|
+
ir = compile(path, main:)
|
|
241
|
+
runs = ir["modules"].find { _1["name"] == ir["main"] }["declarations"].select { _1["qualifier"] == "run" }.map { _1["name"] }
|
|
242
|
+
failed "quint: no run is named #{name} in #{ir['main']}; it has #{runs.sort.join(', ')}"
|
|
243
|
+
end
|
|
244
|
+
yield_traces(written, seed:, written_by: call("test", path, name, seed:, main:)) { yield _1 }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The call that writes the same traces again, with the spec named as
|
|
249
|
+
# raised in names a frame.
|
|
250
|
+
def self.call(method, path, *names, **options)
|
|
251
|
+
arguments = [ Agreement.relative(File.expand_path(path)), *names ].map(&:inspect)
|
|
252
|
+
"Quint.#{method}(#{[ *arguments, *options.compact.map { "#{_1}: #{_2.inspect}" } ].join(', ')})"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The trace files go with their directory, so a replay's failure names
|
|
256
|
+
# its trace by +seed+ and +written_by+, the call that writes it again,
|
|
257
|
+
# instead of by its path.
|
|
258
|
+
def self.yield_traces(traces, seed:, written_by:)
|
|
259
|
+
traces.each do |trace|
|
|
260
|
+
@written[trace] = { seed:, written_by: }
|
|
261
|
+
yield trace
|
|
262
|
+
rescue Mismatch, StepRaised => failure
|
|
263
|
+
name_trace(failure, trace)
|
|
264
|
+
raise
|
|
265
|
+
ensure
|
|
266
|
+
@written.delete(trace)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
@written = {}
|
|
270
|
+
|
|
271
|
+
# Names +trace+ on a +failure+ that names none and, while a simulate or
|
|
272
|
+
# test block is yielding it, the seed and the call that write it again.
|
|
273
|
+
# It keeps its class and cause, and leaves quint_driver's frames out of its
|
|
274
|
+
# backtrace, as Agreement.outside says. Trace.replay calls it too, so
|
|
275
|
+
# a block that rescues a replay's failure reads what the caller of
|
|
276
|
+
# simulate reads.
|
|
277
|
+
def self.name_trace(failure, trace)
|
|
278
|
+
failure.send(:name_trace, trace, **@written[trace].to_h) unless failure.trace
|
|
279
|
+
Agreement.lead_outside(failure)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The Rust evaluator quint 0.33.0 runs under `quint run --backend rust`.
|
|
283
|
+
# Its stdin format is not a documented interface, so the gem reads only
|
|
284
|
+
# this version's.
|
|
285
|
+
EVALUATOR_VERSION = "v0.7.0"
|
|
286
|
+
|
|
287
|
+
# The Rust evaluator simulate runs, without node: QUINT_EVALUATOR names
|
|
288
|
+
# it, and an empty QUINT_EVALUATOR means `quint run`. Unset, it is the copy
|
|
289
|
+
# quint downloaded into QUINT_HOME (~/.quint by default), or `quint run`
|
|
290
|
+
# when there is none.
|
|
291
|
+
def self.evaluator
|
|
292
|
+
return ENV["QUINT_EVALUATOR"].then { _1.empty? ? nil : _1 } if ENV.key?("QUINT_EVALUATOR")
|
|
293
|
+
|
|
294
|
+
home = ENV.fetch("QUINT_HOME") { File.join(Dir.home, ".quint") }
|
|
295
|
+
found = File.join(home, "rust-evaluator-#{EVALUATOR_VERSION}", Gem.win_platform? ? "quint_evaluator.exe" : "quint_evaluator")
|
|
296
|
+
found if File.executable?(found)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# What `quint run --mbt` sends the evaluator for this call, or nil when
|
|
300
|
+
# +step+ is no action of the module or +reach+ no definition of it, so
|
|
301
|
+
# `quint run` reports it.
|
|
302
|
+
def self.evaluator_input(path, traces:, max_steps:, seed:, main:, step:, reach:)
|
|
303
|
+
ir = compile(path, main:)
|
|
304
|
+
declarations = ir.fetch("modules").find { _1["name"] == ir["main"] }&.fetch("declarations") || []
|
|
305
|
+
table = ir.fetch("table").dup
|
|
306
|
+
id = table.keys.map(&:to_i).max.to_i
|
|
307
|
+
# quint parses --init and --step into name nodes with fresh ids, and
|
|
308
|
+
# the table resolves each id to the action.
|
|
309
|
+
named = %w[init].push(step).map do |action|
|
|
310
|
+
definition = declarations.find { _1["kind"] == "def" && _1["qualifier"] == "action" && _1["name"] == action } or return
|
|
311
|
+
table[(id += 1).to_s] = definition
|
|
312
|
+
{ "id" => id, "kind" => "name", "name" => action }
|
|
313
|
+
end
|
|
314
|
+
invariant = if reach
|
|
315
|
+
table[(id += 1).to_s] = declarations.find { _1["kind"] == "def" && _1["name"] == reach } || return
|
|
316
|
+
{ id: id + 1, kind: "app", opcode: "not", args: [ { id:, kind: "name", name: reach } ] }
|
|
317
|
+
else
|
|
318
|
+
{ id: id + 1, kind: "bool", value: true }
|
|
319
|
+
end
|
|
320
|
+
number = Integer(seed.to_s, exception: false) or failed "quint: the seed #{seed.inspect} is not an integer"
|
|
321
|
+
{ parsed: { modules: ir["modules"], table:, main: ir["main"], init: named[0], step: named[1],
|
|
322
|
+
invariants: [ invariant ], witnesses: [] },
|
|
323
|
+
source: path, nruns: traces, nsteps: max_steps, ntraces: traces, nthreads: [ traces, Etc.nprocessors ].min,
|
|
324
|
+
seed: number, mbt: true, verbosity: 0 }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Writes each trace the evaluator returns as quint would: trace_0.itf.json
|
|
328
|
+
# onwards, in the order it returns them.
|
|
329
|
+
def self.write_traces(evaluator, input, dir)
|
|
330
|
+
output, errors, status = begin
|
|
331
|
+
Open3.capture3(evaluator, "simulate-from-stdin", stdin_data: JSON.generate(input))
|
|
332
|
+
rescue SystemCallError => error
|
|
333
|
+
failed "quint: cannot run the Rust evaluator #{evaluator}; set QUINT_EVALUATOR to it, or to an empty string to simulate through `quint run` (#{error.message})"
|
|
334
|
+
end
|
|
335
|
+
failed "quint: the Rust evaluator #{evaluator} failed:\n#{errors}" unless status.success?
|
|
336
|
+
|
|
337
|
+
result = begin
|
|
338
|
+
JSON.parse(output.lines.reverse.find { _1.start_with?("{") }.to_s)
|
|
339
|
+
rescue JSON::ParserError
|
|
340
|
+
end
|
|
341
|
+
unless result.is_a?(Hash) && result["bestTraces"].is_a?(Array) && result["bestTraces"].all? { _1["states"].is_a?(Hash) }
|
|
342
|
+
failed "quint: the Rust evaluator #{evaluator} answered in a format quint_driver does not read; " \
|
|
343
|
+
"it reads the #{EVALUATOR_VERSION} evaluator quint 0.33.0 runs. Set QUINT_EVALUATOR to that " \
|
|
344
|
+
"evaluator, or to an empty string to simulate through `quint run`"
|
|
345
|
+
end
|
|
346
|
+
# A violation is only possible when simulate asks to reach a definition.
|
|
347
|
+
unless %w[ok violation].include?(result["status"])
|
|
348
|
+
failed "quint run failed:\n#{result.fetch('errors', []).map { "Error [#{_1['code']}]: #{_1['message']}" }.join("\n")}"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
result["bestTraces"].each_with_index { |trace, index| File.write(File.join(dir, "trace_#{index}.itf.json"), JSON.generate(trace["states"])) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# QUINT is a shell command, such as `npx --yes ...`, so the line runs
|
|
355
|
+
# through a shell, and each word after it is quoted: an app's folder can
|
|
356
|
+
# have a space in its name. +stdout+ is a file quint's output goes into.
|
|
357
|
+
def self.capture(*words, stdout: nil)
|
|
358
|
+
line = "#{command} #{Shellwords.join(words)}#{" > #{Shellwords.escape(stdout)}" if stdout}"
|
|
359
|
+
output, status = Open3.capture2e(line)
|
|
360
|
+
# Only npm prefixes its lines with "npm error", so these mean npx did
|
|
361
|
+
# not get as far as running quint.
|
|
362
|
+
fetch = output[/^npm (?:error|ERR!) code .*/] if !status.success? && !ENV.key?("QUINT")
|
|
363
|
+
failed "quint: QUINT is unset, and `#{command}` could not fetch quint (#{fetch}); install quint and set QUINT to its command" if fetch
|
|
364
|
+
|
|
365
|
+
[ output, status ]
|
|
366
|
+
rescue SystemCallError => error
|
|
367
|
+
failed "quint: cannot run `#{command}`; set QUINT to the quint command (#{error.message})"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A failure of quint's, raised from the caller's line: the spec is the
|
|
371
|
+
# caller's, so its frame leads, as a refused argument's does.
|
|
372
|
+
def self.failed(message) = Agreement.refuse(message, QuintFailed)
|
|
373
|
+
|
|
374
|
+
private_class_method :call, :capture, :compile_now, :evaluator_input, :failed, :imported_names, :kind_of, :name_trace, :renamed, :runnable!, :sources, :without_init, :write_traces, :yield_traces
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module QuintDriver
|
|
2
|
+
# One module of a compiled Quint spec, and the rules a step action offers.
|
|
3
|
+
class Spec
|
|
4
|
+
# A move the step action can take: +action+ called with +args+ (IR nodes),
|
|
5
|
+
# whose free names are the nondet picks drawn from +domains+ (name => IR
|
|
6
|
+
# node of a set), in order, since a later set may read an earlier pick.
|
|
7
|
+
# +fixed+ names the domains that are the same set in every state and
|
|
8
|
+
# after every earlier pick, so a caller can evaluate each of them once.
|
|
9
|
+
Rule = Struct.new(:action, :args, :domains, :fixed)
|
|
10
|
+
|
|
11
|
+
attr_reader :declarations
|
|
12
|
+
|
|
13
|
+
# The q:: definitions are what quint adds for --main, so only the module
|
|
14
|
+
# a file compiled for first would have them. A module that imports reads
|
|
15
|
+
# quint's flattened copy, where what it imports is its own.
|
|
16
|
+
def initialize(path, main:)
|
|
17
|
+
ir = Quint.compile(path, main:)
|
|
18
|
+
declarations = ir.fetch("modules").find { _1["name"] == main }.fetch("declarations")
|
|
19
|
+
ir = Quint.compile(path, main:, flat: true) if declarations.any? { %w[import instance export].include?(_1["kind"]) }
|
|
20
|
+
@declarations = ir.fetch("modules").find { _1["name"] == main }.fetch("declarations")
|
|
21
|
+
.reject { _1["name"].to_s.start_with?("q::") }
|
|
22
|
+
@effects = ir.fetch("effects")
|
|
23
|
+
@main = main
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# With +step+ nil, the model holds no rules: it evaluates definitions on
|
|
27
|
+
# a state it is given, and takes no step.
|
|
28
|
+
def model(step = "step") = Model.send(:new, declarations, step ? rules(step) : [], owners)
|
|
29
|
+
|
|
30
|
+
# Raises ArgumentError unless each of +names+ is a definition the model
|
|
31
|
+
# can read in a state: a val or a def without parameters, not an action,
|
|
32
|
+
# and not a sum type's case, which quint declares as a val too.
|
|
33
|
+
def observable!(names)
|
|
34
|
+
name = names.find { !observable.include?(_1) } or return
|
|
35
|
+
Agreement.refuse "quint: observe names #{name}, which is no definition without parameters of #{@main}; it has #{observable.sort.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The definition that writes each IR node, by node id, so the model names
|
|
39
|
+
# where a node is written when it cannot evaluate it.
|
|
40
|
+
def owners
|
|
41
|
+
@owners ||= declarations.each_with_object({}) do |declaration, owners|
|
|
42
|
+
ids_in(declaration["expr"]).each { owners[_1] = declaration["name"] } if declaration["kind"] == "def"
|
|
43
|
+
end.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Walks +step+: `any` splits into rules, `nondet x = S.oneOf()` adds a
|
|
47
|
+
# domain, and a call of a declared action ends a rule. A parameterless
|
|
48
|
+
# action made of `any` (such as `step` inside a wider step) opens in place.
|
|
49
|
+
# An `all` of one action is that action, as in `all { nondet x = ...
|
|
50
|
+
# any { ... } }`; an `all` of more holds a condition no rule can carry.
|
|
51
|
+
def rules(step)
|
|
52
|
+
actions = declarations.select { _1["qualifier"] == "action" }.to_h { [ _1["name"], _1 ] }
|
|
53
|
+
Agreement.refuse(Quint.send(:without_init, @main)) unless actions.key?("init")
|
|
54
|
+
choice = ->(node) { node["kind"] == "app" && node["opcode"] == "actionAny" }
|
|
55
|
+
walk = lambda do |node, domains, within|
|
|
56
|
+
if choice.(node)
|
|
57
|
+
node["args"].flat_map { walk.(_1, domains, within) }
|
|
58
|
+
elsif node["kind"] == "app" && node["opcode"] == "actionAll" && node["args"].size == 1
|
|
59
|
+
walk.(node["args"].first, domains, within)
|
|
60
|
+
elsif node["kind"] == "let" && node["opdef"]["qualifier"] == "nondet" && node["opdef"]["expr"]["opcode"] == "oneOf"
|
|
61
|
+
walk.(node["expr"], domains.merge(node["opdef"]["name"] => node["opdef"]["expr"]["args"].first), within)
|
|
62
|
+
elsif actions.key?(node["name"])
|
|
63
|
+
body = actions[node["name"]]["expr"]
|
|
64
|
+
choice.(body) ? walk.(body, domains, node["name"]) : [ Rule.new(node["name"], [], domains, fixed(domains)) ]
|
|
65
|
+
elsif node["kind"] == "app" && actions.key?(node["opcode"])
|
|
66
|
+
[ Rule.new(node["opcode"], node["args"], domains, fixed(domains)) ]
|
|
67
|
+
else
|
|
68
|
+
arm = node["opcode"] == "assign" ? "assigns #{node['args'][0]['name']}'" : "holds #{node['opcode'] || node['kind']}"
|
|
69
|
+
raise Unsupported, "quint: #{within} #{arm} in an arm that names no action; name every action in #{step}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
top = actions.fetch(step) { Agreement.refuse "quint: no action is named #{step}; the module has #{actions.keys.sort.join(', ')}" }
|
|
73
|
+
walk.(top["expr"], {}, step)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
def observable
|
|
78
|
+
@observable ||= declarations.select do
|
|
79
|
+
%w[val def pureval puredef].include?(_1["qualifier"]) && _1["expr"]["kind"] != "lambda" && _1["expr"]["opcode"] != "variant"
|
|
80
|
+
end.map { _1["name"] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Quint's effect of a set says which variables it reads, and a pick is
|
|
84
|
+
# not a variable, so a set also has to name no earlier pick. A lambda
|
|
85
|
+
# parameter that shadows a pick counts as the pick: one evaluation too
|
|
86
|
+
# many is only slower.
|
|
87
|
+
def fixed(domains)
|
|
88
|
+
domains.each_with_index.filter_map do |(name, domain), index|
|
|
89
|
+
reads_none = @effects.dig(domain["id"].to_s, "effect") == { "kind" => "concrete", "components" => [] }
|
|
90
|
+
name if reads_none && !names_in(domain).intersect?(domains.keys.take(index))
|
|
91
|
+
end.to_set
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ids_in(node)
|
|
95
|
+
case node
|
|
96
|
+
when Hash then [ node["id"], *node.each_value.flat_map { ids_in(_1) } ].compact
|
|
97
|
+
when Array then node.flat_map { ids_in(_1) }
|
|
98
|
+
else []
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def names_in(node)
|
|
103
|
+
case node
|
|
104
|
+
when Hash then [ node["kind"] == "name" ? node["name"] : node["opcode"], *node.each_value.flat_map { names_in(_1) } ].compact
|
|
105
|
+
when Array then node.flat_map { names_in(_1) }
|
|
106
|
+
else []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module QuintDriver
|
|
2
|
+
# A trace `quint run --mbt` wrote. Each state names the action the
|
|
3
|
+
# simulator took and the values it picked, so a driver can run the same
|
|
4
|
+
# command and be held to the next state.
|
|
5
|
+
module Trace
|
|
6
|
+
Step = Struct.new(:action, :picks, :expected, :index)
|
|
7
|
+
|
|
8
|
+
# +state+ is the path to the record or map a driver is held to, such as
|
|
9
|
+
# %w[choreo::s system] in a Choreo spec, which keeps the whole state in
|
|
10
|
+
# one variable. +action+ is the path to a variable the spec records the
|
|
11
|
+
# action in, as a sum type whose tag names the action and whose record
|
|
12
|
+
# holds its picks; without it, the action is quint's mbt::actionTaken,
|
|
13
|
+
# which `quint run --mbt` writes and quint test does not.
|
|
14
|
+
def self.steps(path, state: [], action: nil)
|
|
15
|
+
JSON.parse(File.read(path)).fetch("states").each_with_index.map do |itf, index|
|
|
16
|
+
values = decode(itf)
|
|
17
|
+
taken, picks = if action
|
|
18
|
+
at(values, action, index).then { _1.is_a?(String) ? [ _1, {} ] : _1 }
|
|
19
|
+
else
|
|
20
|
+
[ values.fetch("mbt::actionTaken") { raise Unsupported, "quint: state #{index} of the trace names no action taken; quint test writes no mbt::actionTaken, so pass action: the path to a variable that records it" },
|
|
21
|
+
made(itf.fetch("mbt::nondetPicks")) ]
|
|
22
|
+
end
|
|
23
|
+
expected = at(values.reject { _1.start_with?("mbt::") }, state, index)
|
|
24
|
+
unless expected.is_a?(Hash)
|
|
25
|
+
raise Unsupported, "quint: state #{index} of the trace has #{state.join('.')} = #{Agreement.show(expected)}, " \
|
|
26
|
+
"which is no record or map; pass state: the path to the record or map the driver reports"
|
|
27
|
+
end
|
|
28
|
+
Step.new(taken, picks, expected, index)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# quint wraps each pick in an Option of its own: Some when the move made
|
|
33
|
+
# it, None when it did not. Only that wrapper is taken off, so a pick the
|
|
34
|
+
# move made of the spec's own None is nil and still present.
|
|
35
|
+
def self.made(picks)
|
|
36
|
+
picks.filter_map { |name, pick| [ name, decode(pick["value"]) ] if pick["tag"] == "Some" }.to_h
|
|
37
|
+
end
|
|
38
|
+
private_class_method :made
|
|
39
|
+
|
|
40
|
+
def self.at(values, path, index)
|
|
41
|
+
path.each_with_index.reduce(values) do |value, (key, depth)|
|
|
42
|
+
next value[key] if value.is_a?(Hash) && value.key?(key)
|
|
43
|
+
|
|
44
|
+
within = path.take(depth).join(".")
|
|
45
|
+
raise Unsupported, "quint: state #{index} of the trace has no #{path.take(depth + 1).join('.')}" \
|
|
46
|
+
"#{"; #{within} has #{value.keys.sort.join(', ')}" if value.is_a?(Hash)}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :at
|
|
50
|
+
|
|
51
|
+
# +log+, an IO such as $stdout, gets each step before the driver runs it.
|
|
52
|
+
# +state+ and +action+ are paths into each state, as Trace.steps reads them.
|
|
53
|
+
# +observe+ names definitions of +spec+, a Spec, that derive a value from
|
|
54
|
+
# the variables, such as a total no variable holds: the driver reports
|
|
55
|
+
# each one in its state by name, and is held to the value the spec's
|
|
56
|
+
# definition has in the trace's state. +ignore+ names variables the
|
|
57
|
+
# driver does not report, such as what a remote service answers, which
|
|
58
|
+
# the spec models and the driver only reads to set the system up; the
|
|
59
|
+
# driver reports every other variable. Returns the steps it ran, so a
|
|
60
|
+
# caller can count the actions and states its traces reached.
|
|
61
|
+
def self.replay(path, driver, log: nil, state: [], action: nil, spec: nil, observe: [], ignore: [])
|
|
62
|
+
observe, ignore = observe.map(&:to_s), ignore.map(&:to_s)
|
|
63
|
+
observed = observed(path, spec, observe)
|
|
64
|
+
ran = []
|
|
65
|
+
steps = steps(path, state:, action:)
|
|
66
|
+
Agreement.ignorable!(ignore, steps.first.expected.keys, "the trace's state") if steps.any?
|
|
67
|
+
steps.each do |step|
|
|
68
|
+
# An unnamed action names no command for the driver to run.
|
|
69
|
+
raise Unsupported, "quint: step #{step.index} of #{path} took an anonymous action; name every action in step" if step.action.empty?
|
|
70
|
+
|
|
71
|
+
log&.puts "step #{step.index} #{step.action} #{Agreement.show(step.picks)}"
|
|
72
|
+
ran << [ step.action, step.picks ]
|
|
73
|
+
at = "step #{step.index} `#{step.action}` #{Agreement.show(step.picks)}"
|
|
74
|
+
actual = Agreement.drive(at, steps: ran) do
|
|
75
|
+
driver.step(step.action, step.picks)
|
|
76
|
+
driver.state
|
|
77
|
+
end
|
|
78
|
+
Agreement.check!(at, step.expected.merge(observed[step.index]), actual, by: "trace", steps: ran, observe:, spec:, ignore:)
|
|
79
|
+
end
|
|
80
|
+
rescue Mismatch, StepRaised => failure
|
|
81
|
+
Quint.send(:name_trace, failure, path)
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The value of each definition +observe+ names, in each state of the
|
|
86
|
+
# trace. The model reads the state as quint wrote it, so the driver's
|
|
87
|
+
# steps never pass through the model; it reads only the spec's variables,
|
|
88
|
+
# so the mbt:: entries stay.
|
|
89
|
+
def self.observed(path, spec, observe)
|
|
90
|
+
return Hash.new({}) if observe.empty?
|
|
91
|
+
Agreement.refuse "quint: observe needs spec:, the Spec that evaluates #{observe.join(', ')}" unless spec
|
|
92
|
+
|
|
93
|
+
spec.observable!(observe)
|
|
94
|
+
model = spec.model(nil)
|
|
95
|
+
JSON.parse(File.read(path)).fetch("states").map do |values|
|
|
96
|
+
model.enter(decode(values) { |tag, inner| Model::Variant.new(tag, inner) })
|
|
97
|
+
model.observe(observe)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :observed
|
|
101
|
+
|
|
102
|
+
# ITF (https://apalache-mc.org/docs/adr/015adr-trace.html) to plain values.
|
|
103
|
+
# An Option is nil for None and its value for Some, as Model.plain gives one.
|
|
104
|
+
# With a block, a sum type's value is what the block makes of its tag and
|
|
105
|
+
# its decoded value, as the model holds one.
|
|
106
|
+
def self.decode(value, &variant)
|
|
107
|
+
case value
|
|
108
|
+
when Array then value.map { decode(_1, &variant) }
|
|
109
|
+
when Hash
|
|
110
|
+
if value.key?("#set") then value["#set"].to_set { decode(_1, &variant) }
|
|
111
|
+
elsif value.key?("#map") then value["#map"].to_h { |k, v| [ decode(k, &variant), decode(v, &variant) ] }
|
|
112
|
+
elsif value.key?("#tup") then value["#tup"].map { decode(_1, &variant) }
|
|
113
|
+
elsif value.key?("#bigint") then value["#bigint"].to_i
|
|
114
|
+
elsif value.key?("tag") && value.key?("value")
|
|
115
|
+
inner = decode(value["value"], &variant)
|
|
116
|
+
return variant.(value["tag"], inner) if variant
|
|
117
|
+
|
|
118
|
+
case value["tag"]
|
|
119
|
+
when "None" then nil
|
|
120
|
+
when "Some" then inner
|
|
121
|
+
else inner == [] ? value["tag"] : [ value["tag"], inner ]
|
|
122
|
+
end
|
|
123
|
+
else value.except("#meta").transform_values { decode(_1, &variant) }
|
|
124
|
+
end
|
|
125
|
+
else value
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|