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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module QuintDriver
2
+ VERSION = "0.1.0"
3
+ end