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,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. |
|