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,57 @@
1
+ // A lending shelf: a member borrows one book at a time, and gives it back.
2
+ // Each command has a refused twin, so a trace checks the refusals too.
3
+ module loans {
4
+ type Outcome = Done | Refused(str)
5
+
6
+ pure val BOOKS = Set("b1", "b2", "b3")
7
+ pure val MEMBERS = Set("m1", "m2")
8
+ pure val NOBODY = ""
9
+
10
+ var holder: str -> str
11
+ var outcome: Outcome
12
+
13
+ pure def onLoan(h: str -> str, b: str): bool = h.get(b) != NOBODY
14
+ pure def holds(h: str -> str, m: str): bool = BOOKS.exists(b => h.get(b) == m)
15
+ pure def borrowRefusal(h: str -> str, m: str, b: str): str =
16
+ if (onLoan(h, b)) "on_loan" else if (holds(h, m)) "limit_reached" else ""
17
+
18
+ action init = all {
19
+ holder' = Map("b1" -> NOBODY, "b2" -> NOBODY, "b3" -> NOBODY),
20
+ outcome' = Done,
21
+ }
22
+
23
+ action borrow(m: str, b: str): bool = all {
24
+ borrowRefusal(holder, m, b) == "",
25
+ holder' = holder.put(b, m),
26
+ outcome' = Done,
27
+ }
28
+ action borrow_refused(m: str, b: str): bool = all {
29
+ borrowRefusal(holder, m, b) != "",
30
+ holder' = holder,
31
+ outcome' = Refused(borrowRefusal(holder, m, b)),
32
+ }
33
+ action give_back(b: str): bool = all {
34
+ onLoan(holder, b),
35
+ holder' = holder.put(b, NOBODY),
36
+ outcome' = Done,
37
+ }
38
+ action give_back_refused(b: str): bool = all {
39
+ not(onLoan(holder, b)),
40
+ holder' = holder,
41
+ outcome' = Refused("not_on_loan"),
42
+ }
43
+
44
+ action step = any {
45
+ nondet m = MEMBERS.oneOf()
46
+ nondet b = BOOKS.oneOf()
47
+ any { borrow(m, b), borrow_refused(m, b) },
48
+ nondet b = BOOKS.oneOf()
49
+ any { give_back(b), give_back_refused(b) },
50
+ }
51
+
52
+ val one_book_each = MEMBERS.forall(m => BOOKS.filter(b => holder.get(b) == m).size() <= 1)
53
+ // False once a book is lent: the tests use it to see a broken invariant reported.
54
+ val nothing_on_loan = BOOKS.forall(b => not(onLoan(holder, b)))
55
+ // True once two books are lent: the tests ask quint for traces that reach it.
56
+ val two_on_loan = BOOKS.filter(b => onLoan(holder, b)).size() == 2
57
+ }
@@ -0,0 +1,62 @@
1
+ # The system the tests hold to loans.qnt, and a driver that runs the spec's
2
+ # actions on it. BrokenLoans forgets the one-book limit.
3
+ class Loans
4
+ class Refused < StandardError
5
+ attr_reader :reason
6
+
7
+ def initialize(reason)
8
+ @reason = reason
9
+ super(reason.to_s)
10
+ end
11
+ end
12
+
13
+ def initialize = @holders = {}
14
+
15
+ def borrow(member, book)
16
+ raise Refused.new(:on_loan) if @holders.key?(book)
17
+ raise Refused.new(:limit_reached) if limited? && @holders.value?(member)
18
+
19
+ @holders[book] = member
20
+ end
21
+
22
+ def give_back(book)
23
+ @holders.delete(book) || raise(Refused.new(:not_on_loan))
24
+ end
25
+
26
+ def holder(book) = @holders[book]
27
+
28
+ private
29
+ def limited? = true
30
+ end
31
+
32
+ class BrokenLoans < Loans
33
+ private
34
+ def limited? = false
35
+ end
36
+
37
+ class LoansDriver
38
+ SPEC = File.expand_path("loans.qnt", __dir__)
39
+ BOOKS = %w[b1 b2 b3].freeze
40
+
41
+ def self.spec = @spec ||= QuintDriver::Spec.new(SPEC, main: "loans")
42
+
43
+ def initialize(system = Loans)
44
+ @system = system
45
+ end
46
+
47
+ def step(action, picks)
48
+ @outcome = "Done"
49
+ case action.delete_suffix("_refused")
50
+ when "init" then @loans = @system.new
51
+ when "borrow" then @loans.borrow(picks.fetch("m"), picks.fetch("b"))
52
+ when "give_back" then @loans.give_back(picks.fetch("b"))
53
+ else raise ArgumentError, "no command for the action #{action}"
54
+ end
55
+ rescue Loans::Refused => refusal
56
+ @outcome = [ "Refused", refusal.reason.to_s ]
57
+ end
58
+
59
+ def state
60
+ { "holder" => BOOKS.to_h { [ _1, @loans.holder(_1) || "" ] }, "outcome" => @outcome }
61
+ end
62
+ end
@@ -0,0 +1,141 @@
1
+ # The Hegel adapter. It is the one file that loads hegeltest, which needs
2
+ # Ruby 3.4, so trace replay and the model run without it.
3
+ require "hegel"
4
+ require_relative "../quint_driver"
5
+
6
+ module QuintDriver
7
+ Agreement.hide(File.dirname(Hegel::Stateful.method(:run).source_location.first))
8
+
9
+ # A Hegel::StateMachine for +spec+'s +step+ action. Hegel picks each move,
10
+ # the model takes it, the driver runs the same command on the system, and
11
+ # the two states have to be equal afterwards. Hegel owns every choice, so a
12
+ # failure shrinks to a short sequence of commands.
13
+ #
14
+ # +choose+ is how a move is drawn:
15
+ # - :rule, Hegel picks a rule and a disabled one is rejected (tc.assume).
16
+ # Shrinks to the shortest sequence; deep states take more test cases.
17
+ # - :rotate, a disabled rule passes the turn to the next enabled one.
18
+ # Nothing is rejected, so deep states come sooner; shrinking leaves extra
19
+ # moves, because a draw means a different rule once the state differs.
20
+ #
21
+ # +observe+ names definitions of the spec, as Trace.replay takes them: the
22
+ # driver reports each one in its state, held to the model's value.
23
+ # +ignore+ names variables the driver does not report, as Trace.replay
24
+ # takes them.
25
+ def self.machine(spec, driver:, step: "step", invariants: [], choose: :rule, observe: [], ignore: [])
26
+ rules = spec.rules(step)
27
+ observe, ignore = observe.map(&:to_s), ignore.map(&:to_s)
28
+ spec.observable!(observe)
29
+ Agreement.ignorable!(ignore, spec.declarations.select { _1["kind"] == "var" }.map { _1["name"] }, "the spec's variables")
30
+ # Many draws map to one sequence of moves, so the shrinker asks for the
31
+ # same sequence thousands of times: on one failure, 8818 test cases held
32
+ # 1342 distinct sequences. A sequence the system already agreed on is not
33
+ # run again. That assumes the system answers a sequence the same way each
34
+ # time, which the shrinker assumes too. A disagreement is never kept: it
35
+ # runs on the system every time, so the failure reported is one it
36
+ # reproduced.
37
+ agreed = Set.new
38
+ # A fixed domain (Spec::Rule#fixed) is the same list in every test case,
39
+ # so it is evaluated and sorted once per machine, not once per draw.
40
+ fixed = {}.compare_by_identity
41
+
42
+ init = [ "init", {} ].freeze
43
+ # A replay names a step by its index in the trace, init being step 0; the
44
+ # machine names a move by the number Hegel's report gives it.
45
+ at = ->(number, (action, picks)) { "step #{number} `#{action}` #{Agreement.show(picks)}" }
46
+
47
+ Class.new(Hegel::StateMachine) do
48
+ # The driver starts with the machine, before Hegel's first step, so a
49
+ # failure at init is reported with no step Hegel took. Once init agreed,
50
+ # the empty sequence is kept like any other, and a later machine starts
51
+ # its driver only on a move that is not.
52
+ define_method(:initialize) do
53
+ @model = spec.model(step)
54
+ @model.step(*init)
55
+ @start = @model.state.merge(@model.observe(observe))
56
+ @moves = []
57
+ @numbers = [] # the Hegel step of each move
58
+ @step = 0 # every rule Hegel ran, a rejected one too, as its report counts
59
+ @ran = 0 # how many of @moves the driver has run
60
+ next if agreed.include?(@moves)
61
+
62
+ start_driver
63
+ agreed << []
64
+ end
65
+
66
+ # Every driver is held to the model at init, also one started after an
67
+ # earlier machine agreed on init: a test database keeps the rows of an
68
+ # earlier test case unless the driver's init deletes them, and without
69
+ # this check the first move would take the blame for them.
70
+ define_method(:start_driver) do
71
+ actual = Agreement.drive(at.(0, init), steps: [ init ]) do
72
+ @driver = driver.call
73
+ @driver.step(*init)
74
+ @driver.state
75
+ end
76
+ Agreement.check!(at.(0, init), @start, actual, by: "model", steps: [ init ], observe:, spec:, ignore:)
77
+ end
78
+
79
+ define_method(:move!) do |action, picks, after|
80
+ @model.enter(after)
81
+ @moves << [ action, Model.plain(picks) ]
82
+ @numbers << @step
83
+ return if agreed.include?(@moves)
84
+
85
+ start_driver unless @driver
86
+ # Every move the driver has not run yet, which is more than the last
87
+ # one when the driver skipped a sequence it agreed on.
88
+ while @ran < @moves.size
89
+ move = @moves[@ran]
90
+ Agreement.drive(at.(@numbers[@ran], move), steps: [ init, *@moves[..@ran] ]) { @driver.step(*move) }
91
+ @ran += 1
92
+ end
93
+ last = at.(@numbers.last, @moves.last)
94
+ actual = Agreement.drive(last, steps: [ init, *@moves ]) { @driver.state }
95
+ Agreement.check!(last, @model.state.merge(@model.observe(observe)), actual, by: "model", steps: [ init, *@moves ], observe:, spec:, ignore:)
96
+ agreed << @moves.dup
97
+ end
98
+
99
+ case choose
100
+ when :rule
101
+ rules.each do |rule|
102
+ rule(rule.action) do |tc|
103
+ @step += 1
104
+ picks = rule.domains.each_with_object({}) do |(name, domain), chosen|
105
+ choices = fixed.fetch(domain) do
106
+ sorted = @model.value(domain, chosen).to_a.sort_by(&:to_s).freeze
107
+ rule.fixed.include?(name) ? fixed[domain] = sorted : sorted
108
+ end
109
+ # any? would reject a domain whose one element is false.
110
+ tc.assume(!choices.empty?)
111
+ chosen[name] = tc.draw(sampled_from(choices), label: name)
112
+ end
113
+ after = @model.try(rule.action, rule.args.map { @model.value(_1, picks) })
114
+ tc.assume(after)
115
+ tc.note { "#{rule.action} #{Model.plain(picks)}" }
116
+ move!(rule.action, picks, after)
117
+ end
118
+ end
119
+ when :rotate
120
+ rule(step) do |tc|
121
+ @step += 1
122
+ start = tc.draw(integers(min_value: 0, max_value: rules.size - 1), label: "rule")
123
+ moves = rules.rotate(start).lazy.map { @model.moves(_1) }.find(&:any?)
124
+ tc.assume(moves)
125
+ action, picks, after = tc.draw(sampled_from(moves), label: "move")
126
+ tc.note { "#{action} #{Model.plain(picks)}" }
127
+ move!(action, picks, after)
128
+ end
129
+ else Agreement.refuse "quint: choose is :rule or :rotate"
130
+ end
131
+
132
+ # Hegel samples an invariant between moves unless always_run is set;
133
+ # an invariant here holds after every move.
134
+ invariants.each do |name|
135
+ invariant(name, always_run: true) do
136
+ raise Mismatch, "quint: the model broke `#{name}`", Agreement.outside(caller_locations) unless @model.value(name.to_s) == true
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,289 @@
1
+ module QuintDriver
2
+ # Evaluates a spec's JSON IR inside the Ruby process, so a Hegel step costs
3
+ # no call to another process. It covers the operators a command-per-action
4
+ # spec uses and raises Unsupported on any other, so a spec that outgrows it
5
+ # fails loudly instead of being judged wrongly. It answers the driver
6
+ # protocol too, which is how it is held to Quint: replay Quint's own traces
7
+ # through it.
8
+ class Model
9
+ Variant = Struct.new(:tag, :value)
10
+ BOOL = Set[false, true].freeze
11
+
12
+ # +within+ names the definition that holds the lambda, so an operator its
13
+ # body cannot evaluate is reported where the spec writes it.
14
+ Closure = Struct.new(:params, :body, :env, :within)
15
+
16
+ # Spec#model is the one way to build a model: the evaluator trusts the
17
+ # shape of quint's compiled IR and checks none of it.
18
+ private_class_method :new
19
+
20
+ # +owners+ names the definition that writes each IR node, by its id.
21
+ def initialize(declarations, rules, owners)
22
+ @owners = owners
23
+ @defs = declarations.select { _1["kind"] == "def" }.to_h { [ _1["name"], _1 ] }
24
+ @vars = declarations.select { _1["kind"] == "var" }.map { _1["name"] }
25
+ @rules = rules.to_h { [ _1.action, _1 ] }
26
+ @state = {}
27
+ @constant = {}
28
+ @within = nil
29
+ end
30
+
31
+ # The driver protocol. Picks from a trace are plain values; each one
32
+ # becomes the element of its domain that reads as the same plain value,
33
+ # so a variant pick is a variant again.
34
+ def step(action, picks)
35
+ args = if action == "init" then []
36
+ else
37
+ # Quint names a move after step itself when step is one action,
38
+ # or a nondet pick, that no `any` wraps.
39
+ rule = @rules.fetch(action) do
40
+ raise Unsupported, "quint: no action of step is named #{action}; write step as any { ... } so each move names its action"
41
+ end
42
+ typed = rule.domains.each_with_object({}) do |(name, domain), chosen|
43
+ pick = picks.fetch(name) { raise Unsupported, "quint: #{action} needs a pick for #{name}, and the move gives none" }
44
+ # A pick can be false, so the lookup asks whether it found
45
+ # an element, never whether the element is truthy.
46
+ elements = value(domain, chosen).to_a
47
+ at = elements.index { Model.plain(_1) == pick }
48
+ raise Mismatch, "quint: the model cannot pick #{name} = #{pick.inspect} for #{action}" unless at
49
+
50
+ chosen[name] = elements[at]
51
+ end
52
+ rule.args.map { value(_1, typed) }
53
+ end
54
+ enter(try(action, args) || raise(Mismatch, "quint: the model refused #{action} #{Agreement.show(picks)}"))
55
+ end
56
+
57
+ def state = Model.plain(@state)
58
+
59
+ # The state the action would reach, or nil when it is disabled. The model
60
+ # stays where it is, so a caller can ask which actions are enabled.
61
+ def try(name, args = [])
62
+ expr = @defs.fetch(name)["expr"]
63
+ env = {}
64
+ if expr["kind"] == "lambda"
65
+ env = expr["params"].map { _1["name"] }.zip(args).to_h
66
+ expr = expr["expr"]
67
+ end
68
+ @next = {}
69
+ return nil unless inside(name) { eval(expr, env) }
70
+
71
+ missing = @vars - @next.keys
72
+ raise Unsupported, "quint: #{name} leaves #{missing.join(', ')} unassigned" if missing.any?
73
+
74
+ @next
75
+ end
76
+
77
+ def enter(state) = @state = state
78
+
79
+ # Every [action, picks, next state] that +rule+, a Spec::Rule, allows
80
+ # now, in a fixed order, so the same draw means the same move from the
81
+ # same state.
82
+ def moves(rule)
83
+ picks_of(rule, rule.domains.to_a, {}).filter_map do |picks|
84
+ after = try(rule.action, rule.args.map { value(_1, picks) })
85
+ [ rule.action, picks, after ] if after
86
+ end
87
+ end
88
+
89
+ # A refusal names the definition that writes +node_or_name+, such as the
90
+ # action a domain or an argument of a rule is written in, or says the node
91
+ # is outside any definition when no definition of the spec holds it.
92
+ def value(node_or_name, env = {})
93
+ node = node_or_name.is_a?(String) ? { "kind" => "name", "name" => node_or_name } : node_or_name
94
+ inside(@owners[node["id"]]) { eval(node, env) }
95
+ end
96
+
97
+ # Each definition +names+ names, by name, as a plain value.
98
+ def observe(names) = names.to_h { [ _1, Model.plain(value(_1)) ] }
99
+
100
+ # A value as Trace.decode gives it, so a driver reads an Option the same
101
+ # on a replay and under the machine: nil for None, the value for Some.
102
+ def self.plain(value)
103
+ case value
104
+ when Variant
105
+ case value.tag
106
+ when "None" then nil
107
+ when "Some" then plain(value.value)
108
+ else value.value == [] ? value.tag : [ value.tag, plain(value.value) ]
109
+ end
110
+ when Set then value.to_set { plain(_1) }
111
+ when Hash then value.to_h { |k, v| [ plain(k), plain(v) ] }
112
+ when Array then value.map { plain(_1) }
113
+ else value
114
+ end
115
+ end
116
+
117
+ private
118
+ def picks_of(rule, domains, picks)
119
+ return [ picks ] if domains.empty?
120
+
121
+ (name, domain), *rest = domains
122
+ value(domain, picks).to_a.sort_by(&:to_s).flat_map { picks_of(rule, rest, picks.merge(name => _1)) }
123
+ end
124
+
125
+ def eval(node, env)
126
+ case node["kind"]
127
+ when "str", "int", "bool" then node["value"]
128
+ when "name" then lookup(node["name"], env)
129
+ when "lambda" then Closure.new(node["params"].map { _1["name"] }, node["expr"], env, @within)
130
+ when "let"
131
+ definition = node["opdef"]
132
+ eval(node["expr"], env.merge(definition["name"] => eval(definition["expr"], env)))
133
+ when "app" then apply(node["opcode"], node["args"], env)
134
+ else refuse("IR kind #{node['kind']}")
135
+ end
136
+ end
137
+
138
+ # Quint reserves Bool, Int and Nat, so no definition shadows them.
139
+ def lookup(name, env)
140
+ return env[name] if env.key?(name)
141
+ return @state.fetch(name) if @vars.include?(name)
142
+ return BOOL if name == "Bool"
143
+
144
+ defined(name) do
145
+ if %w[Int Nat].include?(name)
146
+ refuse("the name #{name}", "#{name} is infinite, so no move can list its picks; draw from a finite set such as 0.to(9)")
147
+ end
148
+ refuse("the name #{name}", "no definition, variable or parameter has it")
149
+ end
150
+ end
151
+
152
+ # A pure val or pure def reads no variable, and a lambda reads none
153
+ # until it is called, so their value is the same in every state and is
154
+ # evaluated once. Every other definition is evaluated on each read.
155
+ def defined(name, &missing)
156
+ @constant.fetch(name) do
157
+ definition = @defs.fetch(name, &missing)
158
+ value = inside(name) { eval(definition["expr"], {}) }
159
+ constant = %w[pureval puredef].include?(definition["qualifier"]) || definition["expr"]["kind"] == "lambda"
160
+ constant ? @constant[name] = value.freeze : value
161
+ end
162
+ end
163
+
164
+ def call(closure, args)
165
+ inside(closure.within) { eval(closure.body, closure.env.merge(closure.params.zip(args).to_h)) }
166
+ end
167
+
168
+ def inside(name)
169
+ outer, @within = @within, name
170
+ yield
171
+ ensure
172
+ @within = outer
173
+ end
174
+
175
+ def refuse(what, why = nil)
176
+ raise Unsupported, "quint: no evaluation for #{what} #{@within ? "in #{@within}" : 'outside any definition'}#{"; #{why}" if why}"
177
+ end
178
+
179
+ # Quint's builtin docs leave these undefined, and its two evaluators do
180
+ # not always agree on them (range(3, 1) is [] in one and [3, 2] in the
181
+ # other), so the model refuses them rather than pick an answer.
182
+ def undefined(what) = refuse(what, "Quint leaves it undefined")
183
+
184
+ def index!(list, i, op)
185
+ return i if i.between?(0, list.size - 1)
186
+
187
+ undefined("#{op}(_, #{i}) on a list of length #{list.size}")
188
+ end
189
+
190
+ def nonempty!(list, op) = list.empty? ? undefined("#{op} of an empty list") : list
191
+
192
+ def keyed!(map, key, op) = map.key?(key) || refuse("#{op}(_, #{key.inspect})", "the map has no such key")
193
+
194
+ # Quint documents no rounding: its evaluators truncate / and % toward
195
+ # zero, and Ruby's Integer#/ and #% floor, so only operands where the
196
+ # two agree are evaluated.
197
+ def divide(a, b, op)
198
+ refuse("#{a} #{op} #{b}", "Quint documents no rounding for a negative operand") if a.negative? || b.negative?
199
+ b.zero? ? undefined("#{a} #{op} 0") : a.public_send(op, b)
200
+ end
201
+
202
+ def apply(op, args, env)
203
+ case op
204
+ when "actionAll", "and" then args.all? { eval(_1, env) }
205
+ when "or" then args.any? { eval(_1, env) }
206
+ when "implies" then !eval(args[0], env) || eval(args[1], env)
207
+ when "ite" then eval(args[eval(args[0], env) ? 1 : 2], env)
208
+ when "assign" then @next[args[0]["name"]] = eval(args[1], env); true
209
+ else operate(op, args.map { eval(_1, env) })
210
+ end
211
+ end
212
+
213
+ # Every other operator reads all of its arguments, so they are evaluated
214
+ # once, before it runs.
215
+ def operate(op, values)
216
+ a, b, c = values
217
+ case op
218
+ when "not" then !a
219
+ when "eq", "iff" then a == b
220
+ when "neq" then a != b
221
+ when "igt" then a > b
222
+ when "igte" then a >= b
223
+ when "ilt" then a < b
224
+ when "ilte" then a <= b
225
+ when "iadd" then a + b
226
+ when "isub" then a - b
227
+ when "imul" then a * b
228
+ when "idiv" then divide(a, b, "/")
229
+ when "imod" then divide(a, b, "%")
230
+ when "iuminus" then -a
231
+ when "to" then a <= b ? (a..b).to_set : undefined("#{a}.to(#{b})")
232
+ # A set's fold order is unspecified, so Quint leaves a fold whose
233
+ # operator is not associative and commutative undefined.
234
+ when "fold", "foldl" then a.reduce(b) { |acc, x| call(c, [ acc, x ]) }
235
+ # The builtin docs leave foldr out, but both evaluators fold from the
236
+ # right and pass the element first.
237
+ when "foldr" then a.reverse.reduce(b) { |acc, x| call(c, [ x, acc ]) }
238
+ when "List", "Tup" then values
239
+ when "append" then a + [ b ]
240
+ when "concat" then a + b
241
+ when "head" then nonempty!(a, "head").first
242
+ when "tail" then nonempty!(a, "tail").drop(1)
243
+ when "length" then a.size
244
+ when "nth" then a[index!(a, b, "nth")]
245
+ when "indices" then (0...a.size).to_set
246
+ when "replaceAt" then a.dup.tap { _1[index!(a, b, "replaceAt")] = c }
247
+ when "slice"
248
+ # The docs also leave i == l.length() undefined, but both evaluators
249
+ # answer slice(l, l.length(), l.length()) with [], which the docs'
250
+ # own reading (from i inclusive to j exclusive) gives, and a spec
251
+ # that drops a list's last element needs it.
252
+ 0 <= b && b <= c && c <= a.size ? a[b...c] : undefined("slice(_, #{b}, #{c}) on a list of length #{a.size}")
253
+ when "range" then a <= b ? (a...b).to_a : undefined("range(#{a}, #{b})")
254
+ when "select" then a.select { call(b, [ _1 ]) }
255
+ when "Set" then values.to_set
256
+ when "contains" then a.include?(b)
257
+ when "in" then b.include?(a)
258
+ when "union" then a | b
259
+ when "intersect" then a & b
260
+ when "exclude" then a - b
261
+ when "subseteq" then a.subset?(b)
262
+ when "size" then a.size
263
+ when "exists" then a.any? { call(b, [ _1 ]) }
264
+ when "forall" then a.all? { call(b, [ _1 ]) }
265
+ when "filter" then a.select { call(b, [ _1 ]) }.to_set
266
+ when "map" then a.to_set { call(b, [ _1 ]) }
267
+ when "Map" then values.to_h
268
+ when "put", "with" then a.merge(b => c)
269
+ when "mapBy" then a.to_h { [ _1, call(b, [ _1 ]) ] }
270
+ when "set" then a.merge(b => c) if keyed!(a, b, "set")
271
+ when "setBy" then a.merge(b => call(c, [ a[b] ])) if keyed!(a, b, "setBy")
272
+ # Which of two pairs with one key wins depends on the set's order.
273
+ when "setToMap" then a.to_h.tap { undefined("setToMap of pairs that share a key") if _1.size < a.size }
274
+ when "get" then a.fetch(b) { refuse("get(_, #{b.inspect})", "the map has no such key") }
275
+ when "keys" then a.keys.to_set
276
+ when "item" then a.fetch(b - 1)
277
+ when "Rec" then values.each_slice(2).to_h
278
+ when "field" then a.fetch(b)
279
+ when "variant" then Variant.new(a, b)
280
+ when "matchVariant"
281
+ arm = values.drop(1).each_slice(2).find { |label, _| [ a.tag, "_" ].include?(label) }
282
+ call(arm[1], [ a.value ])
283
+ else
284
+ # A definition is a lambda, or another operator's name (`def doubleOf = twice`).
285
+ call(defined(op) { refuse("operator #{op}") }, values)
286
+ end
287
+ end
288
+ end
289
+ end