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,925 @@
1
+ ---
2
+ name: quint_driver
3
+ description: Use when testing a Ruby system against a Quint spec with the quint_driver gem. Shape the spec so every move names an action, write a driver, replay Quint's traces, check the Ruby model, then let Hegel choose and shrink the steps.
4
+ ---
5
+
6
+ # quint_driver
7
+
8
+ quint_driver runs a Ruby system the way a Quint spec says and compares the two after every step. You write the spec and a driver. Quint or Hegel chooses the steps. A disagreement raises `QuintDriver::Mismatch` with the steps that reproduce it.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install --global @informalsystems/quint@0.33.0
14
+ export QUINT=quint
15
+ ```
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "quint_driver"
20
+ gem "hegeltest", "= 0.5.1" # only for step 5
21
+ ```
22
+
23
+ Steps 1 to 4 need Ruby 3.3 or later and no other gem. Step 5 needs hegeltest, which needs Ruby 3.4 or later. Without `QUINT`, the gem runs `npx --yes @informalsystems/quint@0.33.0`. The gem reads `QUINT` each time it runs quint.
24
+
25
+ The first `quint run` downloads the Rust evaluator into `~/.quint/rust-evaluator-v0.7.0/`. When the network blocks api.github.com, download it from the release instead. Pick the target of your machine, such as `x86_64-unknown-linux-gnu` or `aarch64-apple-darwin`:
26
+
27
+ ```sh
28
+ mkdir -p ~/.quint/rust-evaluator-v0.7.0
29
+ curl -sSL https://github.com/quint-co/quint/releases/download/evaluator/v0.7.0/quint_evaluator-x86_64-unknown-linux-gnu.tar.gz \
30
+ | tar xz -C ~/.quint/rust-evaluator-v0.7.0
31
+ ```
32
+
33
+ With that evaluator in place, `Quint.simulate` runs it directly and skips node; the same seed gives the same traces as `quint run`. Set `QUINT_EVALUATOR` to another copy of evaluator v0.7.0, or to an empty string to go through `quint run`.
34
+
35
+ ## 1. Shape the spec
36
+
37
+ Each move of `step` must name the action that a driver runs.
38
+
39
+ - Write `step` as `any { ... }` whose arms call named actions, even for one action: `action step = any { tick }`. Without `any`, Quint names every move `step`, so a driver cannot tell the moves apart, and replay through the model raises `QuintDriver::Unsupported`. One `all` around the picks and the `any` reads the same: `action step = all { nondet x = S.oneOf() any { add(x), sub(x) } }`. An `all` that also holds a condition is refused, because no rule carries the condition.
40
+ - Take arguments only from `nondet x = S.oneOf()` before the call: `nondet b = BOOKS.oneOf() any { give_back(b), give_back_refused(b) }`.
41
+ - Draw each pick in the arm whose action takes it. A pick drawn above `any` is in the `picks` of every arm, so a failure lists it on a move that does not use it.
42
+ - A parameterless action whose body is `any { ... }` opens in place, so `action step = any { lending, returning }` works.
43
+ - Do not assign inline in an arm of `step` (`any { n' = n + 1 }`). The move names no action, and `spec.rules` raises `QuintDriver::Unsupported` with the action that holds the arm: `quint: counting assigns n' in an arm that names no action; name every action in step`.
44
+ - Give each command a refused twin that holds when the command is disabled and records why, such as `borrow_refused`. Traces then check refusals too.
45
+ - Assign every variable in `init` and in every action.
46
+
47
+ ```sh
48
+ quint typecheck spec.qnt
49
+ ```
50
+
51
+ When the file holds several modules, pass the module name as `main:` to `Spec.new` and to `Quint.simulate`.
52
+
53
+ ## 2. Write the driver
54
+
55
+ ```ruby
56
+ class LoansDriver
57
+ def step(action, picks)
58
+ @outcome = "Done"
59
+ case action.delete_suffix("_refused")
60
+ when "init" then @loans = Loans.new
61
+ when "borrow" then @loans.borrow(picks.fetch("m"), picks.fetch("b"))
62
+ when "give_back" then @loans.give_back(picks.fetch("b"))
63
+ end
64
+ rescue Loans::Refused => refusal
65
+ @outcome = [ "Refused", refusal.reason.to_s ]
66
+ end
67
+
68
+ def state
69
+ { "holder" => %w[b1 b2 b3].to_h { [ _1, @loans.holder(_1) || "" ] }, "outcome" => @outcome }
70
+ end
71
+ end
72
+ ```
73
+
74
+ - `step("init", {})` comes first in every sequence. Make a new system there.
75
+ - `picks` holds the nondet values by name. A pick the move did not make is absent, and one it made of the spec's own `None` is present as `nil`, so read one an action may go without as `picks["b"]`, which is `nil` then, and one it needs as `picks.fetch("b")`.
76
+ - A refused twin runs the same command. Rescue the domain error into the spec's refusal value.
77
+ - A value the spec derives, a `val` or a `def` without parameters, is in no trace state. To compare one, name it in `observe:` (section 3) and return it from `state` under its name.
78
+ - `state` returns every variable of the spec. A variable it leaves out fails the step, so a forgotten one cannot pass. A variable the driver only reads, such as what a remote service answers, goes in `ignore:` (section 3). The values are plain: a set is a `Set`, a map a `Hash`, a tuple or a list an `Array`, a record a `Hash` with string keys, and a sum type its tag, or `[tag, value]` when it carries one. An Option, a sum type with the cases `None` and `Some`, is `nil` or the value itself.
79
+
80
+ ## 3. Replay Quint's traces
81
+
82
+ ```ruby
83
+ require "quint_driver"
84
+
85
+ QuintDriver::Quint.simulate("spec.qnt", main: "loans", traces: 50, max_steps: 20, seed: "1") do |path|
86
+ QuintDriver::Trace.replay(path, LoansDriver.new)
87
+ end
88
+ ```
89
+
90
+ Quint chooses the steps, so replay reaches deep states quickly. It works the same in a Minitest test and in an RSpec example. Pass `log: $stdout` to `Trace.replay` to print each step before the driver runs it.
91
+
92
+ `Trace.replay` returns the steps it ran (`action`, `picks`, `expected`, `index`), so you can see what the traces reached before you trust a pass:
93
+
94
+ ```ruby
95
+ reached = Hash.new(0)
96
+ QuintDriver::Quint.simulate("spec.qnt", main: "loans", traces: 50, max_steps: 20, seed: "1") do |path|
97
+ QuintDriver::Trace.replay(path, LoansDriver.new).each { reached[_1.action] += 1 }
98
+ end
99
+ reached.sort.to_h # => {"borrow" => 214, "borrow_refused" => 283, "give_back" => 161, "give_back_refused" => 342, "init" => 50}
100
+ ```
101
+
102
+ An action that never appears is one no test reached; raise `traces` or `max_steps`, or aim at it with `reach:`.
103
+
104
+ The driver is held to every variable of the trace's state. One its `state` leaves out fails at the first step, `init`:
105
+
106
+ ```text
107
+ quint: after step 0 `init` {}, the driver differs from the trace
108
+ count: trace 0, but the driver's state leaves count out; report it, or name it in ignore: when the driver only reads it
109
+ ```
110
+
111
+ A spec can model the world outside the code, such as what a remote service answers, in variables the driver reads to set up its stubs and cannot read back from the code. Name those in `ignore:`, and they are not compared: `Trace.replay(path, driver, ignore: %w[remote jobUp])`, or `QuintDriver.machine(spec, driver:, ignore: %w[remote jobUp])`. A name that is not a variable raises `ArgumentError` with the variables there are. "A driver for code that calls remote services" below shows the whole shape.
112
+
113
+ A trace holds only the variables, so a definition the spec derives from them, such as a total, is compared only when you observe it: `Trace.replay(path, driver, spec:, observe: %w[approvedSum])`, or `QuintDriver.machine(spec, driver:, observe: %w[approvedSum])`. The driver's `state` reports each observed name beside the variables, and is held to the value the definition has in each state. A driver that leaves it out fails with `approvedSum: model 0, but the driver's state leaves approvedSum out; report it, since observe: names it`. The model evaluates the definition, on a replay too, so the line names the model, such as `approvedSum: model 0, driver 15000`. A name `state` reports that is in no state fails as `dueCents: driver 0, but dueCents is not in the trace's state, which has ...`, and when the name is a definition of the spec the line says to name it in `observe:`. A name that is no `val` or `def` without parameters raises `ArgumentError` with the names the module has. The Rails section below holds a total to one.
114
+
115
+ To test the steps that lead to one state, write a definition that holds there and pass its name as `reach:`. Each trace then ends at the first state where it holds, and only the traces that get there are yielded:
116
+
117
+ ```ruby
118
+ # In loans.qnt: val two_on_loan = BOOKS.filter(b => onLoan(holder, b)).size() == 2
119
+ QuintDriver::Quint.simulate("spec.qnt", main: "loans", traces: 20, max_steps: 10, seed: "1", reach: "two_on_loan") do |path|
120
+ QuintDriver::Trace.replay(path, LoansDriver.new)
121
+ end
122
+ ```
123
+
124
+ One of those traces runs `init give_back_refused borrow borrow`. When no trace gets there, `Quint.simulate` raises `QuintDriver::QuintFailed`: `quint: no trace of 20 reached two_on_loan within 1 steps; raise max_steps or traces`. Raise `max_steps` or `traces` then. `reach:` takes a predicate (a boolean `val` or `def` without parameters) and `step:` an action without parameters; a name of another kind, such as an action as `reach:`, raises `ArgumentError` before quint runs, and the message lists the names that fit. A name the module neither declares nor imports, such as a typo, raises the same `ArgumentError` once quint has run: `quint: step names borow, which loans neither declares nor imports; step takes an action without parameters, such as step`.
125
+
126
+ To replay a named run (`run lendTest = init.then(...)`), use `Quint.test`. quint test writes no `mbt::actionTaken`, so the spec must record each step's action in a variable, as a sum type whose tag names the action and whose record holds its picks, the way a Choreo spec records it. Pass that variable's path as `action:`, and the path to the state the driver answers for as `state:`:
127
+
128
+ ```ruby
129
+ QuintDriver::Quint.test("test/recorded.qnt", "lendTest", seed: "1") do |trace|
130
+ QuintDriver::Trace.replay(trace, RecordedDriver.new, state: %w[s books], action: %w[s taken])
131
+ end
132
+ ```
133
+
134
+ `test/recorded.qnt` in this repository, driven by `RecordedDriver` in `test/trace_test.rb`, keeps `var s: { books: str -> str, taken: Taken }` with `type Taken = Init | Borrow({ m: str, b: str }) | GiveBack({ b: str })`, so the driver gets `step("Init", {})`, then `step("Borrow", { "m" => "m1", "b" => "b1" })`, and its `state` returns the `books` map. A system that keeps a book's holder after `GiveBack` fails like this:
135
+
136
+ ```
137
+ quint: after step 3 `GiveBack` {"b" => "b1"}, the driver differs from the trace
138
+ b1: trace "", driver "m1"
139
+ steps:
140
+ Init {}
141
+ Borrow {"b" => "b1", "m" => "m1"}
142
+ Borrow {"b" => "b2", "m" => "m2"}
143
+ GiveBack {"b" => "b1"}
144
+ trace: test_0.itf.json of Quint.test("test/recorded.qnt", "lendTest", seed: "1")
145
+ ```
146
+
147
+ Without `action:`, the replay raises `QuintDriver::Unsupported`: `quint: state 0 of the trace names no action taken; quint test writes no mbt::actionTaken, so pass action: the path to a variable that records it`.
148
+
149
+ A `state:` path that ends at a value that is no record or map raises `QuintDriver::Unsupported` before the driver runs, since the driver's `state` answers a record or map: `quint: state 0 of the trace has s.taken = "Init", which is no record or map; pass state: the path to the record or map the driver reports`.
150
+
151
+ ## 4. Check the Ruby model
152
+
153
+ Hegel trusts the Ruby evaluation of the spec. Hold the model to Quint's own traces first. A module that imports, such as a test module that imports the spec's actions, reads what it imports as its own, also a definition that nothing in it reaches, and needs no action named `step`:
154
+
155
+ ```ruby
156
+ spec = QuintDriver::Spec.new("spec.qnt", main: "loans")
157
+ QuintDriver::Quint.simulate("spec.qnt", main: "loans", traces: 200, max_steps: 30, seed: "7") do |path|
158
+ QuintDriver::Trace.replay(path, spec.model)
159
+ end
160
+ ```
161
+
162
+ `spec.model` is the one way to build a model. It evaluates sets, maps (`Map`, `get`, `keys`, `put`, `mapBy`, `set`, `setBy`, `setToMap`), records, tuples, lists (`append`, `concat`, `head`, `tail`, `length`, `nth`, `indices`, `replaceAt`, `slice`, `range`, `select`, `foldl`, and `foldr`, which Quint's docs leave out but both evaluators define as `(x, acc) => ...` from the right), sum types, integers (`/` and `%` only on non-negative operands, because Quint's evaluators truncate and Ruby floors), and booleans, with `Bool` as the set of `false` and `true`. `Int` and `Nat` are infinite, so the model cannot list the picks of a move drawn from them and raises `QuintDriver::Unsupported`: `quint: no evaluation for the name Nat in step; Nat is infinite, so no move can list its picks; draw from a finite set such as 0.to(9)`. An operation Quint's docs leave undefined, such as `head` of an empty list, raises `QuintDriver::Unsupported` too, and so does `get`, `set`, or `setBy` at a key the map does not have. An operator the model does not evaluate raises `QuintDriver::Unsupported`, and the message names the action or definition that holds it, such as `quint: no evaluation for operator powerset in subsets`. Go on to step 5 only when this passes. `model.moves(rule)` lists the `[action, picks, next state]` moves a rule of `spec.rules("step")` allows now, and `model.enter(next_state)` takes one. `model.value(node, env)` evaluates one IR node of a rule, and a refusal names the action the node is written in; for a node no definition holds, it says the node is `outside any definition`.
163
+
164
+ ## 5. Let Hegel choose and shrink
165
+
166
+ ```ruby
167
+ require "quint_driver/hegel"
168
+
169
+ machine = QuintDriver.machine(spec, driver: -> { LoansDriver.new }, invariants: %i[one_book_each])
170
+ Hegel.test(test_cases: 100, seed: 1, suppress_health_check: %i[filter_too_much too_slow]) do |tc|
171
+ Hegel::Stateful.run(machine.new, tc)
172
+ end
173
+ ```
174
+
175
+ - `invariants:` names `val`s of the spec that must hold after every move.
176
+ - `choose: :rotate` gives a disabled action's turn to the next enabled one. Use it when most actions are disabled in most states. The default, `:rule`, shrinks to the shortest sequence.
177
+ - `Hegel::Stateful.run(machine.new, tc, step_count: 50)` sets the most moves in one test case.
178
+
179
+ ## In a Rails app with RSpec
180
+
181
+ For a first spec in an app, start from one status column and the method that changes it: the README's quick start writes `spec/quint/loan_state.qnt` with one variable, `init`, and two actions, and a driver that creates the record in `init` and reads the column back with `reload`.
182
+
183
+ A driver can call the app's service objects in place of the system's methods, and read the records back for `state`. The run below needs no Rails gem: `Loan` stands in for an ActiveRecord model, with the model methods that the services call. In an app, `Loan` is the model itself, and the Gemfile lines of Install go in the `:test` group.
184
+
185
+ ```ruby
186
+ # app/models/loan.rb: stands in for an ActiveRecord model, one row per book on loan.
187
+ class Loan
188
+ attr_reader :member, :book
189
+
190
+ def self.all = (@rows ||= [])
191
+ def self.delete_all = all.clear
192
+ def self.create!(member:, book:) = new(member, book).tap { all << _1 }
193
+ def self.find_by(book:) = all.find { _1.book == book }
194
+ def self.exists?(**attributes) = all.any? { |row| attributes.all? { row.public_send(_1) == _2 } }
195
+
196
+ def initialize(member, book) = (@member, @book = member, book)
197
+ def destroy! = Loan.all.delete(self)
198
+ end
199
+ ```
200
+
201
+ ```ruby
202
+ # app/services/lending.rb: service objects a controller calls. A refusal is a domain error with a reason.
203
+ module Lending
204
+ class Refused < StandardError
205
+ attr_reader :reason
206
+
207
+ def initialize(reason) = super(@reason = reason)
208
+ end
209
+
210
+ class BorrowBook
211
+ def self.call(member:, book:)
212
+ raise Refused, "on_loan" if Loan.exists?(book:)
213
+ raise Refused, "limit_reached" if Loan.exists?(member:)
214
+
215
+ Loan.create!(member:, book:)
216
+ end
217
+ end
218
+
219
+ class GiveBack
220
+ def self.call(book:)
221
+ loan = Loan.find_by(book:) or raise Refused, "not_on_loan"
222
+ loan.destroy!
223
+ end
224
+ end
225
+ end
226
+ ```
227
+
228
+ The driver empties the table at `init`. One example replays many traces, and with transactional fixtures they all run in one transaction, so a trace does not start from an empty table by itself. The driver rescues the domain refusal into the spec's refusal value:
229
+
230
+ ```ruby
231
+ # spec/quint/lending_driver.rb
232
+ class LendingDriver
233
+ BOOKS = %w[b1 b2 b3].freeze
234
+
235
+ def step(action, picks)
236
+ @outcome = "Done"
237
+ case action.delete_suffix("_refused")
238
+ when "init" then Loan.delete_all
239
+ when "borrow" then Lending::BorrowBook.call(member: picks.fetch("m"), book: picks.fetch("b"))
240
+ when "give_back" then Lending::GiveBack.call(book: picks.fetch("b"))
241
+ end
242
+ rescue Lending::Refused => refusal
243
+ @outcome = [ "Refused", refusal.reason ]
244
+ end
245
+
246
+ def state
247
+ { "holder" => BOOKS.to_h { [ _1, Loan.find_by(book: _1)&.member || "" ] }, "outcome" => @outcome }
248
+ end
249
+ end
250
+ ```
251
+
252
+ The spec is `examples/loans/loans.qnt`, which the gem ships (`bundle info --path quint_driver` prints where), copied to `spec/quint/loans.qnt`. The spec path is relative, because RSpec runs from the app's root:
253
+
254
+ ```ruby
255
+ # spec/quint/lending_spec.rb
256
+ require "quint_driver"
257
+ require "quint_driver/hegel"
258
+ require_relative "../../app/models/loan"
259
+ require_relative "../../app/services/lending"
260
+ require_relative "lending_driver"
261
+
262
+ RSpec.describe "lending, held to loans.qnt" do
263
+ spec_file = "spec/quint/loans.qnt"
264
+
265
+ it "follows every trace Quint writes" do
266
+ QuintDriver::Quint.simulate(spec_file, main: "loans", traces: 50, max_steps: 20, seed: "1") do |path|
267
+ QuintDriver::Trace.replay(path, LendingDriver.new)
268
+ end
269
+ end
270
+
271
+ it "agrees with the model on every sequence Hegel chooses" do
272
+ spec = QuintDriver::Spec.new(spec_file, main: "loans")
273
+ machine = QuintDriver.machine(spec, driver: -> { LendingDriver.new }, invariants: %i[one_book_each])
274
+
275
+ Hegel.test(test_cases: 100, seed: 1, suppress_health_check: %i[filter_too_much too_slow]) do |tc|
276
+ Hegel::Stateful.run(machine.new, tc)
277
+ end
278
+ end
279
+ end
280
+ ```
281
+
282
+ ```text
283
+ $ bundle exec rspec spec/quint
284
+ ..
285
+
286
+ Finished in 6.02 seconds (files took 0.18241 seconds to load)
287
+ 2 examples, 0 failures
288
+ ```
289
+
290
+ Without the `rescue`, the refusal leaves the driver. The replay fails with a `StepRaised`, and its `raised in:` line names the line of the service that refused:
291
+
292
+ ```text
293
+ 1) lending, held to loans.qnt follows every trace Quint writes
294
+ Failure/Error: raise Refused, "limit_reached" if Loan.exists?(member:)
295
+
296
+ QuintDriver::StepRaised:
297
+ quint: at step 4 `borrow_refused` {"b" => "b3", "m" => "m2"}, the step raised Lending::Refused: limit_reached
298
+ raised in: app/services/lending.rb:12:in 'Lending::BorrowBook.call'
299
+ steps:
300
+ init {}
301
+ borrow {"b" => "b2", "m" => "m2"}
302
+ give_back {"b" => "b2"}
303
+ borrow {"b" => "b2", "m" => "m2"}
304
+ borrow_refused {"b" => "b3", "m" => "m2"}
305
+ trace: trace_0.itf.json of Quint.simulate("spec/quint/loans.qnt", traces: 50, max_steps: 20, seed: "1", main: "loans", step: "step", backend: "rust")
306
+ # ./app/services/lending.rb:12:in 'Lending::BorrowBook.call'
307
+ ```
308
+
309
+ ### With ActiveRecord and a test database
310
+
311
+ The same shape runs on a real ActiveRecord model in a Rails 8.1 app with rspec-rails, factory_bot_rails, transactional fixtures, and SQLite or PostgreSQL. This run is on Ruby 3.3, so it replays traces only; Hegel needs Ruby 3.4. The app's `Loan` belongs to a `Borrower` with a `limit`, validates `amount > 0`, and `Loan#approve(officer)` answers `:already`, `:over_limit` when the borrower's approved loans and this one pass the limit, `:needs_senior` above 10,000 for an officer who is not senior, or `:approved` after `update!(approved_at:, approved_by:)`.
312
+
313
+ Name the records in the spec (`l1`, `l2`) and keep the id that `create!` returns for each name, because ids keep growing from one trace to the next. Put the amounts at each limit the code compares with (10,000, and 20,000 in two loans), or a `>` written as `>=` passes every trace:
314
+
315
+ ```quint
316
+ // spec/quint/loan.qnt: one borrower's loans and their approval.
317
+ module loan {
318
+ val LOANS = Set("l1", "l2", "l3")
319
+ val AMOUNTS = Set(0, 5000, 10000, 15000)
320
+ val OFFICERS = Set("grace", "ada")
321
+ val SENIOR = Set("grace")
322
+ val LIMIT = 20000
323
+
324
+ type Outcome = Done | Invalid | Approved | Already | OverLimit | NeedsSenior
325
+
326
+ var amount: str -> int
327
+ var approvedBy: str -> str
328
+ var outcome: Outcome
329
+
330
+ def requested(l) = amount.get(l) > 0
331
+ def approvedSum = LOANS.filter(l => approvedBy.get(l) != "").fold(0, (sum, l) => sum + amount.get(l))
332
+ def decision(l, o) =
333
+ if (approvedBy.get(l) != "") Already
334
+ else if (approvedSum + amount.get(l) > LIMIT) OverLimit
335
+ else if (amount.get(l) > 10000 and not(SENIOR.contains(o))) NeedsSenior
336
+ else Approved
337
+
338
+ action init = all {
339
+ amount' = LOANS.mapBy(_ => 0),
340
+ approvedBy' = LOANS.mapBy(_ => ""),
341
+ outcome' = Done,
342
+ }
343
+
344
+ action request(l, a) = all {
345
+ not(requested(l)),
346
+ amount' = if (a > 0) amount.set(l, a) else amount,
347
+ approvedBy' = approvedBy,
348
+ outcome' = if (a > 0) Done else Invalid,
349
+ }
350
+
351
+ action approve(l, o) = all {
352
+ requested(l),
353
+ amount' = amount,
354
+ approvedBy' = if (decision(l, o) == Approved) approvedBy.set(l, o) else approvedBy,
355
+ outcome' = decision(l, o),
356
+ }
357
+
358
+ action step = any {
359
+ nondet l = LOANS.oneOf()
360
+ nondet a = AMOUNTS.oneOf()
361
+ request(l, a),
362
+ nondet l = LOANS.oneOf()
363
+ nondet o = OFFICERS.oneOf()
364
+ approve(l, o),
365
+ }
366
+
367
+ val withinLimit = approvedSum <= LIMIT
368
+ }
369
+ ```
370
+
371
+ The driver makes its records with the app's factories, so a record gets every attribute the factory sets, and rescues the validation error into the spec's `Invalid`:
372
+
373
+ ```ruby
374
+ # spec/quint/loan_driver.rb
375
+ class LoanDriver
376
+ LOANS = %w[l1 l2 l3].freeze
377
+ OFFICERS = { "grace" => { name: "grace", senior: true }, "ada" => { name: "ada", senior: false } }.freeze
378
+ OUTCOMES = { approved: "Approved", already: "Already", over_limit: "OverLimit", needs_senior: "NeedsSenior" }.freeze
379
+
380
+ def step(action, picks)
381
+ @outcome = "Done"
382
+ case action
383
+ when "init"
384
+ @borrower = FactoryBot.create(:borrower, limit: 20_000)
385
+ @ids = {}
386
+ when "request"
387
+ @ids[picks.fetch("l")] = FactoryBot.create(:loan, borrower: @borrower, amount: picks.fetch("a")).id
388
+ when "approve"
389
+ @outcome = OUTCOMES.fetch(Loan.find(@ids.fetch(picks.fetch("l"))).approve(OFFICERS.fetch(picks.fetch("o"))))
390
+ end
391
+ rescue ActiveRecord::RecordInvalid
392
+ @outcome = "Invalid"
393
+ end
394
+
395
+ def state
396
+ loans = LOANS.to_h { [ _1, @ids[_1] && Loan.find(@ids[_1]) ] }
397
+ { "amount" => loans.transform_values { _1&.amount || 0 },
398
+ "approvedBy" => loans.transform_values { _1&.approved_by || "" },
399
+ "outcome" => @outcome }
400
+ end
401
+ end
402
+ ```
403
+
404
+ With `quint_driver` in the Gemfile's `:test` group, `Bundler.require` in `config/application.rb` loads it, so the spec file needs only `rails_helper`. With `require: false` on that line, as the README's quick start writes it so the app does not load the gem outside the specs, add `require "quint_driver"` after `rails_helper`. `Rails.root.join` names the spec from any directory RSpec starts in, and a failure still prints it relative to the app's root:
405
+
406
+ ```ruby
407
+ # spec/quint/loan_spec.rb
408
+ require "rails_helper"
409
+ require_relative "loan_driver"
410
+
411
+ RSpec.describe Loan, "held to loan.qnt" do
412
+ def rolled_back
413
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
414
+ yield
415
+ raise ActiveRecord::Rollback
416
+ end
417
+ end
418
+
419
+ it "decides every approval the way the spec does" do
420
+ outcomes = Set.new
421
+ QuintDriver::Quint.simulate(Rails.root.join("spec/quint/loan.qnt"), traces: 50, max_steps: 12, seed: "1") do |path|
422
+ rolled_back { QuintDriver::Trace.replay(path, LoanDriver.new).each { outcomes << _1.expected["outcome"] } }
423
+ end
424
+
425
+ expect(outcomes).to include("Approved", "Already", "OverLimit", "NeedsSenior", "Invalid")
426
+ expect(Loan.count).to eq(0)
427
+ end
428
+ end
429
+ ```
430
+
431
+ ```text
432
+ $ bundle exec rspec spec/quint
433
+ .
434
+
435
+ Finished in 2.41 seconds (files took 1.59 seconds to load)
436
+ 1 example, 0 failures
437
+ ```
438
+
439
+ `expect(Loan.count).to eq(0)` shows that every trace rolled back. With `amount > 10_000` written as `amount >= 10_000` in `Loan#approve`, the same example fails:
440
+
441
+ ```text
442
+ QuintDriver::Mismatch:
443
+ quint: after step 3 `approve` {"l" => "l1", "o" => "ada"}, the driver differs from the trace
444
+ approvedBy: trace {"l1" => "ada", "l2" => "", "l3" => ""}, driver {"l1" => "", "l2" => "", "l3" => ""}
445
+ outcome: trace "Approved", driver "NeedsSenior"
446
+ steps:
447
+ init {}
448
+ request {"a" => 10000, "l" => "l1"}
449
+ request {"a" => 5000, "l" => "l2"}
450
+ approve {"l" => "l1", "o" => "ada"}
451
+ trace: trace_3.itf.json of Quint.simulate("spec/quint/loan.qnt", traces: 50, max_steps: 12, seed: "1", step: "step", backend: "rust")
452
+ ```
453
+
454
+ Transactional fixtures roll back once, after the example, not after each trace or Hegel test case. A row one trace writes is still there in the next, so the driver deletes the rows at `init`, or each trace and each test case rolls back to a savepoint:
455
+
456
+ ```ruby
457
+ def rolled_back
458
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
459
+ yield
460
+ raise ActiveRecord::Rollback
461
+ end
462
+ end
463
+
464
+ QuintDriver::Quint.simulate(spec_file, main: "loan", traces: 50, max_steps: 12, seed: "1") do |path|
465
+ rolled_back { QuintDriver::Trace.replay(path, LoanDriver.new) }
466
+ end
467
+ Hegel.test(test_cases: 100, seed: 1, suppress_health_check: %i[filter_too_much too_slow]) do |tc|
468
+ rolled_back { Hegel::Stateful.run(machine.new, tc) }
469
+ end
470
+ ```
471
+
472
+ With `rolled_back`, the driver's `init` needs no `delete_all`. Pass `joinable: false`. A joinable savepoint takes in the model's own `transaction` blocks, so a `raise ActiveRecord::Rollback` inside one rolls nothing back: a loan that a model transaction approved and then rolled back read `"approved"` under `transaction(requires_new: true)`, and `"requested"` under `joinable: false`, as under the fixture's transaction alone.
473
+
474
+ A driver that neither deletes nor rolls back fails at init when its `state` reads the rows an earlier trace or test case left:
475
+
476
+ ```text
477
+ quint: after step 0 `init` {}, the driver differs from the model
478
+ outstanding: model 0, driver 500100
479
+ steps:
480
+ init {}
481
+ ```
482
+
483
+ `LoanDriver` above passes without `rolled_back` too, because its `init` makes a new borrower and `state` reads only the loans it made: the example left 145 loans behind for the fixture's rollback. Keep `rolled_back` anyway. App code that reads past the borrower, such as a count of all loans, sees the rows of earlier traces, and the failure then names a move, not the leftover rows.
484
+
485
+ On 4 CPUs at load average 1.3 to 1.6, a trace of 13 steps took 15 ms on SQLite and 35 ms on PostgreSQL, and a Hegel test case 25 ms and 42 ms. Deleting at init and rolling back to a savepoint took the same time within 10%.
486
+
487
+ ### A value the spec derives
488
+
489
+ `loan.qnt` derives the approved total with `def approvedSum = LOANS.filter(l => approvedBy.get(l) != "").fold(0, (sum, l) => sum + amount.get(l))`. No variable holds it, so the example above passes while the page's total is wrong. Observe it:
490
+
491
+ ```ruby
492
+ # app/models/borrower.rb
493
+ class Borrower < ApplicationRecord
494
+ has_many :loans
495
+
496
+ # What the borrower's page shows as approved so far.
497
+ def approved_total = loans.where.not(approved_at: nil).sum(:amount)
498
+ end
499
+
500
+ # spec/quint/observe_spec.rb
501
+ require "rails_helper"
502
+ require_relative "loan_driver"
503
+
504
+ class ObservedLoanDriver < LoanDriver
505
+ def state = super.merge("approvedSum" => @borrower.reload.approved_total)
506
+ end
507
+
508
+ RSpec.describe Borrower, "held to loan.qnt's approvedSum" do
509
+ def rolled_back
510
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
511
+ yield
512
+ raise ActiveRecord::Rollback
513
+ end
514
+ end
515
+
516
+ it "totals the approved loans the way the spec does" do
517
+ qnt = Rails.root.join("spec/quint/loan.qnt")
518
+ spec = QuintDriver::Spec.new(qnt, main: "loan")
519
+ QuintDriver::Quint.simulate(qnt, traces: 50, max_steps: 12, seed: "1") do |path|
520
+ rolled_back { QuintDriver::Trace.replay(path, ObservedLoanDriver.new, spec:, observe: %w[approvedSum]) }
521
+ end
522
+ end
523
+ end
524
+ ```
525
+
526
+ With `approved_total` written as `loans.sum(:amount)`, which counts the loans not approved yet, the example fails at the first request:
527
+
528
+ ```text
529
+ QuintDriver::Mismatch:
530
+ quint: after step 1 `request` {"a" => 15000, "l" => "l1"}, the driver differs from the model
531
+ approvedSum: model 0, driver 15000
532
+ steps:
533
+ init {}
534
+ request {"a" => 15000, "l" => "l1"}
535
+ trace: trace_0.itf.json of Quint.simulate("spec/quint/loan.qnt", traces: 50, max_steps: 12, seed: "1", step: "step", backend: "rust")
536
+ ```
537
+
538
+ ### A driver for code that calls remote services
539
+
540
+ Here a job and a JSON endpoint both ask a carrier's tracking service for a shipment's status:
541
+
542
+ ```ruby
543
+ # app/services/carrier_api.rb: the carrier's tracking service. Specs stub it.
544
+ module CarrierApi
545
+ class Unavailable < StandardError; end
546
+
547
+ def self.status_of(ref) = raise(NotImplementedError, "calls the carrier over HTTP for #{ref}")
548
+ end
549
+
550
+ # app/models/shipment.rb
551
+ class Shipment < ApplicationRecord
552
+ # Takes the carrier's status. While the carrier is down, keeps the old one.
553
+ def sync_status!
554
+ update!(status: CarrierApi.status_of(ref))
555
+ rescue CarrierApi::Unavailable
556
+ false
557
+ end
558
+ end
559
+
560
+ # app/jobs/sync_shipment_job.rb
561
+ class SyncShipmentJob < ApplicationJob
562
+ def perform(shipment) = shipment.sync_status!
563
+ end
564
+
565
+ # app/controllers/shipments_controller.rb, routed as post "/shipments/:ref/sync"
566
+ class ShipmentsController < ApplicationController
567
+ def sync
568
+ shipment = Shipment.find_by!(ref: params[:ref])
569
+ shipment.sync_status!
570
+ render json: { ref: shipment.ref, status: shipment.status }
571
+ end
572
+ end
573
+ ```
574
+
575
+ The spec holds the outside world as variables: `remote`, what the service answers for each shipment, and `carrierUp`, whether it answers at all. The actions `setRemote` and `setCarrierUp` change only those. `calls` counts the calls the code makes, failed ones too, so the spec checks that a call happened, and happened once:
576
+
577
+ ```quint
578
+ // spec/quint/shipment.qnt
579
+ module shipment {
580
+ pure val REFS = Set("s1", "s2")
581
+ pure val ANSWERS = Set("in_transit", "delivered")
582
+
583
+ var remote: str -> str
584
+ var carrierUp: bool
585
+ var status: str -> str
586
+ var calls: int
587
+
588
+ action init = all {
589
+ remote' = REFS.mapBy(_ => "in_transit"),
590
+ carrierUp' = true,
591
+ status' = REFS.mapBy(_ => "pending"),
592
+ calls' = 0,
593
+ }
594
+
595
+ action setRemote(s: str, a: str): bool = all { remote' = remote.set(s, a), carrierUp' = carrierUp, status' = status, calls' = calls }
596
+ action setCarrierUp(u: bool): bool = all { carrierUp' = u, remote' = remote, status' = status, calls' = calls }
597
+
598
+ action sync(s: str): bool = all {
599
+ calls' = calls + 1,
600
+ status' = if (carrierUp) status.set(s, remote.get(s)) else status,
601
+ remote' = remote,
602
+ carrierUp' = carrierUp,
603
+ }
604
+ action syncByJob(s: str): bool = sync(s)
605
+ action syncByApi(s: str): bool = sync(s)
606
+
607
+ action step = any {
608
+ nondet s = REFS.oneOf()
609
+ nondet a = ANSWERS.oneOf()
610
+ setRemote(s, a),
611
+ nondet u = Bool.oneOf()
612
+ setCarrierUp(u),
613
+ nondet s = REFS.oneOf()
614
+ syncByJob(s),
615
+ nondet s = REFS.oneOf()
616
+ syncByApi(s),
617
+ }
618
+ }
619
+ ```
620
+
621
+ The driver keeps the world, answers the stubbed calls from it, and counts them. It gets the example, so it can make the request. A response that is not 2xx raises, so an unexpected 4xx or 5xx is a `StepRaised` that names the step, not a mismatch:
622
+
623
+ ```ruby
624
+ # spec/quint/shipment_driver.rb
625
+ class ShipmentDriver
626
+ REFS = %w[s1 s2].freeze
627
+
628
+ # +example+ is the RSpec example, which makes the requests.
629
+ def initialize(example) = @example = example
630
+
631
+ def answer_for(ref)
632
+ @calls += 1
633
+ raise CarrierApi::Unavailable unless @carrier_up
634
+
635
+ @remote.fetch(ref)
636
+ end
637
+
638
+ def step(action, picks)
639
+ case action
640
+ when "init"
641
+ Shipment.delete_all
642
+ REFS.each { Shipment.create!(ref: _1) }
643
+ @remote, @carrier_up, @calls = REFS.to_h { [ _1, "in_transit" ] }, true, 0
644
+ when "setRemote" then @remote[picks.fetch("s")] = picks.fetch("a")
645
+ when "setCarrierUp" then @carrier_up = picks.fetch("u")
646
+ when "syncByJob" then SyncShipmentJob.perform_now(Shipment.find_by!(ref: picks.fetch("s")))
647
+ when "syncByApi"
648
+ @example.post "/shipments/#{picks.fetch("s")}/sync"
649
+ response = @example.response
650
+ raise "POST answered #{response.status}: #{response.body}" unless response.successful?
651
+ end
652
+ end
653
+
654
+ # remote and carrierUp are not here: the example names them in ignore:.
655
+ def state = { "status" => Shipment.pluck(:ref, :status).to_h, "calls" => @calls }
656
+ end
657
+ ```
658
+
659
+ The example stubs the service once, in `before`, with a block that asks the driver. The driver is a `let`, so the stub and the replay share it, and its `init` resets the world for each trace:
660
+
661
+ ```ruby
662
+ # spec/quint/shipment_spec.rb
663
+ require "rails_helper"
664
+ require_relative "shipment_driver"
665
+
666
+ RSpec.describe "Shipment status held to shipment.qnt", type: :request do
667
+ let(:driver) { ShipmentDriver.new(self) }
668
+
669
+ before { allow(CarrierApi).to receive(:status_of) { |ref| driver.answer_for(ref) } }
670
+
671
+ it "follows the carrier through the job and the endpoint" do
672
+ qnt = Rails.root.join("spec/quint/shipment.qnt")
673
+ QuintDriver::Quint.simulate(qnt, traces: 30, max_steps: 12, seed: "1") do |path|
674
+ QuintDriver::Trace.replay(path, driver, ignore: %w[remote carrierUp])
675
+ end
676
+ end
677
+ end
678
+ ```
679
+
680
+ Without `ignore:`, the replay fails at `init` and names `carrierUp` and `remote`. Under the machine, pass the same list: `QuintDriver.machine(spec, driver: -> { driver }, ignore: %w[remote carrierUp])`. With a `sync_status!` that retries once while the carrier is down, Hegel shrinks the failure to:
681
+
682
+ ```text
683
+ QuintDriver::Mismatch:
684
+ quint: after step 2 `syncByJob` {"s" => "s1"}, the driver differs from the model
685
+ calls: model 1, driver 2
686
+ steps:
687
+ init {}
688
+ setCarrierUp {"u" => false}
689
+ syncByJob {"s" => "s1"}
690
+ ```
691
+
692
+ A driver that reads `flash` and follows no redirect starts a new session for each step: `@example.reset!`, then sign in again. A flash a step sets before its redirect is still there in the next request, so the next step sees two outcomes. Reading the outcome from the response or the rows works too.
693
+
694
+ ### A choice the code leaves to the database
695
+
696
+ Here a job reads a batch's items through `has_many` with no ORDER BY, and the order decides which item gets the free slot:
697
+
698
+ ```ruby
699
+ # app/models/batch.rb
700
+ class Batch < ApplicationRecord
701
+ has_many :items, autosave: true
702
+ end
703
+
704
+ # app/jobs/assign_slots_job.rb
705
+ class AssignSlotsJob < ApplicationJob
706
+ def perform(batch)
707
+ free = batch.free
708
+ batch.items.each do |item|
709
+ next if item.state.in?(%w[assigned done])
710
+
711
+ if free > 0
712
+ item.state = "assigned"
713
+ free -= 1
714
+ else
715
+ item.state = "waiting"
716
+ end
717
+ end
718
+ batch.update!(free:)
719
+ end
720
+ end
721
+ ```
722
+
723
+ Without ORDER BY, the database returns the rows in no fixed order. SQLite returned them in the order they were made. PostgreSQL 16 did so too on an index scan, but on a sequential scan, which it chose for a small table, it returned a row after the others once the row was updated. A spec that walks the items in one fixed order then fails a replay where the code is right. Make the order a pick of the action: the spec lists the orders the database may return, and `step` picks one.
724
+
725
+ ```quint
726
+ // spec/quint/slots.qnt: a batch gives its free slots to its items.
727
+ module slots {
728
+ pure val ITEMS = Set("i1", "i2", "i3")
729
+ // Every order the database may return the three items in.
730
+ pure val ORDERS = Set(
731
+ ["i1", "i2", "i3"], ["i1", "i3", "i2"], ["i2", "i1", "i3"],
732
+ ["i2", "i3", "i1"], ["i3", "i1", "i2"], ["i3", "i2", "i1"],
733
+ )
734
+
735
+ var state: str -> str
736
+ var blocked: str -> bool
737
+ var free: int
738
+ var outcome: str
739
+
740
+ action init = all {
741
+ state' = ITEMS.mapBy(_ => "pending"),
742
+ blocked' = ITEMS.mapBy(_ => false),
743
+ free' = 1,
744
+ outcome' = "ok",
745
+ }
746
+
747
+ action block(i: str): bool = all {
748
+ state' = state, blocked' = blocked.set(i, true), free' = free, outcome' = "ok",
749
+ }
750
+
751
+ // One item's turn of the job: a slot while one is free, else it waits.
752
+ def give(acc: (str -> str, int), i: str): (str -> str, int) =
753
+ if (acc._1.get(i) == "assigned" or acc._1.get(i) == "done") acc
754
+ else if (acc._2 > 0) (acc._1.set(i, "assigned"), acc._2 - 1)
755
+ else (acc._1.set(i, "waiting"), acc._2)
756
+
757
+ // The job reads the items with no ORDER BY, so the order is a pick.
758
+ action runJob(order: List[str]): bool = {
759
+ val walked = order.foldl((state, free), give)
760
+ val invalid = ITEMS.exists(i => blocked.get(i) and state.get(i) != "assigned" and walked._1.get(i) == "assigned")
761
+ all {
762
+ state' = if (invalid) state else walked._1,
763
+ free' = if (invalid) free else walked._2,
764
+ blocked' = blocked,
765
+ outcome' = if (invalid) "invalid" else "ok",
766
+ }
767
+ }
768
+
769
+ action release(i: str): bool = all {
770
+ state' = if (state.get(i) == "assigned") state.set(i, "done") else state,
771
+ free' = if (state.get(i) == "assigned") free + 1 else free,
772
+ blocked' = blocked,
773
+ outcome' = if (state.get(i) == "assigned") "ok" else "refused",
774
+ }
775
+
776
+ action step = any {
777
+ nondet i = ITEMS.oneOf()
778
+ block(i),
779
+ nondet order = ORDERS.oneOf()
780
+ runJob(order),
781
+ nondet i = ITEMS.oneOf()
782
+ release(i),
783
+ }
784
+ }
785
+ ```
786
+
787
+ Three items have six orders, and n rows have n! orders. With more rows, list only the orders that lead to different states. Here at most one slot is free, so only the first item that is neither assigned nor done gets it, and the order of the rest changes nothing. `Item` validates that a blocked item is not assigned, so a run that would assign one is `invalid` and changes nothing.
788
+
789
+ The driver enacts the pick on the loaded association. While the job runs, `in_order` loads the association's target and sorts it in place; at any other time it returns the association untouched. Its `release` step goes through a page, so it starts a new session first:
790
+
791
+ ```ruby
792
+ # spec/quint/slots_driver.rb
793
+ class SlotsDriver
794
+ ITEMS = %w[i1 i2 i3].freeze
795
+
796
+ # +example+ is the RSpec example, which makes the requests.
797
+ def initialize(example) = @example = example
798
+
799
+ # Batch#items while the job runs: the loaded items, sorted in place into the order the step picked.
800
+ def in_order(items)
801
+ return items unless @order
802
+
803
+ items.load_target
804
+ items.target.sort_by! { @order.index(_1.id) }
805
+ items
806
+ end
807
+
808
+ def step(action, picks)
809
+ @outcome = "ok"
810
+ case action
811
+ when "init"
812
+ @batch = Batch.create!(free: 1)
813
+ @ids = ITEMS.to_h { [ _1, @batch.items.create!.id ] }
814
+ when "block" then Item.find(@ids.fetch(picks.fetch("i"))).update!(blocked: true)
815
+ when "runJob"
816
+ @order = @ids.values_at(*picks.fetch("order"))
817
+ AssignSlotsJob.perform_now(Batch.find(@batch.id))
818
+ when "release"
819
+ @example.reset!
820
+ @example.post "/session", params: { name: "operator" }
821
+ @example.patch "/items/#{@ids.fetch(picks.fetch("i"))}/release"
822
+ raise "PATCH answered #{@example.response.status}" unless @example.response.redirect?
823
+
824
+ @outcome = @example.flash[:notice] ? "ok" : "refused"
825
+ end
826
+ rescue ActiveRecord::RecordInvalid
827
+ @outcome = "invalid"
828
+ ensure
829
+ @order = nil
830
+ end
831
+
832
+ def state
833
+ items = Item.find(@ids.values).index_by(&:id)
834
+ { "state" => @ids.transform_values { items.fetch(_1).state },
835
+ "blocked" => @ids.transform_values { items.fetch(_1).blocked },
836
+ "free" => @batch.reload.free,
837
+ "outcome" => @outcome }
838
+ end
839
+ end
840
+ ```
841
+
842
+ The example wraps the reader once, in `before`. The driver is a `let`, so the wrap and the replay share it:
843
+
844
+ ```ruby
845
+ # spec/quint/slots_spec.rb
846
+ require "rails_helper"
847
+ require_relative "slots_driver"
848
+
849
+ RSpec.describe "Batch slots held to slots.qnt", type: :request do
850
+ let(:driver) { SlotsDriver.new(self) }
851
+
852
+ before do
853
+ allow_any_instance_of(Batch).to receive(:items).and_wrap_original { |reader, *args| driver.in_order(reader.call(*args)) }
854
+ end
855
+
856
+ def rolled_back
857
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
858
+ yield
859
+ raise ActiveRecord::Rollback
860
+ end
861
+ end
862
+
863
+ it "gives the free slot in the order the step picked" do
864
+ QuintDriver::Quint.simulate(Rails.root.join("spec/quint/slots.qnt"), traces: 50, max_steps: 10, seed: "1") do |path|
865
+ rolled_back { QuintDriver::Trace.replay(path, driver) }
866
+ end
867
+ end
868
+ end
869
+ ```
870
+
871
+ Without the wrap, the first trace fails at its first `runJob`, on SQLite and on PostgreSQL 16 alike:
872
+
873
+ ```text
874
+ QuintDriver::Mismatch:
875
+ quint: after step 1 `runJob` {"order" => ["i3", "i2", "i1"]}, the driver differs from the trace
876
+ state: trace {"i1" => "waiting", "i2" => "waiting", "i3" => "assigned"}, driver {"i1" => "assigned", "i2" => "waiting", "i3" => "waiting"}
877
+ steps:
878
+ init {}
879
+ runJob {"order" => ["i3", "i2", "i1"]}
880
+ ```
881
+
882
+ Do not return `reorder(...)` from the wrap. A new relation loads records of its own, not the association's target, so the job changes records that `batch.update!` does not autosave. The same trace then fails with `driver {"i1" => "pending", "i2" => "pending", "i3" => "pending"}` while `free` drops to 0, and an item validation that fails no longer stops the update: with a blocked item that may not be assigned, `update!` raised `Validation failed: Items state blocked` when the job changed the loaded target, and saved when it changed the records of `reorder(:id)`.
883
+
884
+ ## Read a failure
885
+
886
+ ```text
887
+ quint: after step 4 `borrow_refused` {"b" => "b3", "m" => "m2"}, the driver differs from the trace
888
+ holder: trace {"b1" => "", "b2" => "m2", "b3" => ""}, driver {"b1" => "", "b2" => "m2", "b3" => "m2"}
889
+ outcome: trace ["Refused", "limit_reached"], driver "Done"
890
+ steps:
891
+ init {}
892
+ borrow {"b" => "b2", "m" => "m2"}
893
+ give_back {"b" => "b2"}
894
+ borrow {"b" => "b2", "m" => "m2"}
895
+ borrow_refused {"b" => "b3", "m" => "m2"}
896
+ trace: trace_0.itf.json of Quint.simulate("spec.qnt", traces: 50, max_steps: 20, seed: "1", main: "loans", step: "step", backend: "rust")
897
+ ```
898
+
899
+ - Each line before `steps:` names one variable that differs. `trace`, or `model` under Hegel, is what the spec says. `driver` is what the system did.
900
+ - `steps:` lists every step from `init`. Run them on a new driver to reproduce the failure.
901
+ - Every value prints with a set's elements and a hash's keys sorted, so the two sides of a line, a replay and the machine, and Ruby 3.3 and 3.4 print the same value the same way.
902
+ - `trace:` names the trace. Under `simulate` or `test`, the file is gone after the call returns, so the line gives its file name and the call that writes the same trace again; outside `simulate`, it gives the path. A `Mismatch` reads the same whether you rescue it inside the `simulate` block or after it, and carries the same facts as `trace`, `seed`, and `written_by`. A `Mismatch` your own code raises inside the block gets the `trace:` line when it leaves the block, so raise `QuintDriver::Mismatch` from a check of your own too. Give it the message alone (`raise QuintDriver::Mismatch, "quint: the ledger lost a loan"`); `Mismatch.new` takes no `trace:`, `seed:`, or `written_by:`, because the gem fills them with the trace that ran.
903
+ - The backtrace of a `Mismatch` and of a `StepRaised` leaves out quint_driver's own frames, and under the machine Hegel's too. RSpec hides the frames of an installed gem but not of a gem the Gemfile loads from a path, and Minitest hides none, so without this the gem's frames would come first. The first line is the driver's or the system's raise, or the line of the spec that called `Trace.replay` or `Hegel.test`.
904
+ - An error a step raises, in the driver's own code or in the system beneath it, is a `QuintDriver::StepRaised`, not a `Mismatch`: the two sides were never compared. Fix the code the message points at first. A domain error the driver lets through, such as `Loans::Refused` on a `_refused` step, lands here too; rescue it into the spec's refusal value, as step 2 does. The first line names the step and the error. The `raised in:` line names the frame of your project nearest the raise, with the path relative to the directory the run started in. It skips frames in a gem, in Ruby's library, and in quint_driver, so an error a gem raises names your line that called the gem. That line is usually the one to fix; for a domain error the driver should rescue, it is where the error came from. When every frame is skipped, the line reads `raised in: no frame of the project; every frame is in a gem, in Ruby's library, or in quint_driver`; read the backtrace then. The backtrace is the error's without quint_driver's frames, and `cause` is the original error. Here a driver like the one in section 2 reads `picks.fetch("member")`, but the pick is named `m`:
905
+
906
+ ```text
907
+ quint: at step 1 `borrow` {"b" => "b2", "m" => "m2"}, the step raised KeyError: key not found: "member"
908
+ raised in: loans_driver.rb:6:in 'Hash#fetch'
909
+ steps:
910
+ init {}
911
+ borrow {"b" => "b2", "m" => "m2"}
912
+ trace: trace_0.itf.json of Quint.simulate("spec.qnt", traces: 50, max_steps: 20, seed: "1", main: "loans", step: "step", backend: "rust")
913
+ ```
914
+
915
+ Under Hegel the line reads the same, and the number is the one Hegel's report gives the move (`Step 1: borrow`). A rule Hegel rejected counts too, so the number can be larger than the moves under `steps:`. A failure while the `driver:` lambda makes the driver, or at its first state, reads ``quint: at step 0 `init` {}, ...``, as on a replay. The machine makes its driver when it starts, so Hegel's report for that failure lists no step.
916
+ - Under Hegel, there is no seed: Hegel's report ends with `To reproduce this failure, pass the blob below to Hegel.test:` and a blob. Pass it as `Hegel.test(reproduce_failure: "...")` to run that one case again with the same message.
917
+ - Under Hegel, `steps:` is the shrunk sequence. Hegel's own report names each draw after the pick it chose, and under `choose: :rotate` names the turn `rule` and the move `move`. A label drawn more than once in a test case is numbered in draw order (`m_1 = "m2"`, `m_2 = "m1"`); a label drawn once prints bare (`m = "m2"`).
918
+
919
+ | Error | Cause |
920
+ | --- | --- |
921
+ | `QuintDriver::Mismatch` | The system and the spec disagree, or the model cannot take a move a trace took, such as a pick its domain does not hold. |
922
+ | `QuintDriver::StepRaised` | The driver's `step` or `state`, the system it called, or the `driver:` lambda raised. `raised in:` names your project's frame. `cause` holds the error. |
923
+ | `QuintDriver::Unsupported` | The spec uses an operator the model does not evaluate, a move names no action of `step`, or a move lacks a pick its action needs. |
924
+ | `ArgumentError` | `QuintDriver.machine`'s `step:`, or `spec.rules`, names no action of the module. The message lists the actions it has. Or the module has no action named `init`: every trace, model, and machine starts there, and no option names another, so add `action init = <the action that starts it>` to the spec. Its backtrace starts at your line that made the call. |
925
+ | `QuintDriver::QuintFailed` | quint rejected the spec (a failure at the flattening stage can come from another module of the file, as quint flattens every one), `main:` names no module of the file (the message lists the modules it has), the `QUINT` command is not installed, `QUINT` is unset and npx could not fetch quint, or the `QUINT_EVALUATOR` evaluator cannot run or answers in a format other than v0.7.0's. Its backtrace starts at your line that made the call. |