zizek 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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generators"
4
+ require_relative "shell"
5
+
6
+ module Zizek
7
+ module Stateful
8
+ DEFAULT_STEP_COUNT = 50
9
+
10
+ # Chance a free per-step "stop here?" boolean comes up true under the stop
11
+ # protocols (1 and 2), which only a replay of an older blob still records,
12
+ # and which a probe extends from the RNG once that blob runs out. New cases
13
+ # record a "continue?" boolean instead, at
14
+ # Core.stateful_continue_probability.
15
+ STOP_PROBABILITY = 1.0 / 65536.0
16
+
17
+ Rule = Struct.new(:name, :apply)
18
+
19
+ # A state machine written as a class.
20
+ #
21
+ # class QueueMachine < Zizek::Stateful::Machine
22
+ # def initialize
23
+ # @queue = RingQueue.new
24
+ # @model = []
25
+ # end
26
+ #
27
+ # rule(:push) do |t|
28
+ # value = t.draw(Zizek::Generators.integers(min: 0, max: 99))
29
+ # @queue.push(value) && @model << value
30
+ # end
31
+ #
32
+ # rule(:pop) do |t|
33
+ # t.assume(!@model.empty?)
34
+ # raise "wrong value" unless @queue.pop == @model.shift
35
+ # end
36
+ #
37
+ # invariant(:sizes_agree) { @queue.size == @model.length or raise "drifted" }
38
+ # end
39
+ #
40
+ # Zizek.check(QueueMachine.property, examples: 2_000)
41
+ #
42
+ # Rule bodies run with `self` bound to the machine, so they read and write
43
+ # its state directly. `assume` inside a rule is a precondition: the step is
44
+ # discarded and the run continues, rather than the whole example being
45
+ # rejected.
46
+ #
47
+ # The array-returning `#rules` and `#invariants` protocol this is built on
48
+ # is still supported; a machine that needs to compute its rules at runtime
49
+ # can define those methods directly instead of subclassing.
50
+ class Machine
51
+ class << self
52
+ def rule(name, &body)
53
+ raise ArgumentError, "rule #{name.inspect} needs a block" if body.nil?
54
+
55
+ declared_rules << Rule.new(name.to_s, ->(machine, test_case) { machine.instance_exec(test_case, &body) })
56
+ name
57
+ end
58
+
59
+ def invariant(name, &body)
60
+ raise ArgumentError, "invariant #{name.inspect} needs a block" if body.nil?
61
+
62
+ declared_invariants << Rule.new(
63
+ name.to_s, ->(machine, test_case) { machine.instance_exec(test_case, &body) }
64
+ )
65
+ name
66
+ end
67
+
68
+ def declared_rules
69
+ @declared_rules ||= []
70
+ end
71
+
72
+ def declared_invariants
73
+ @declared_invariants ||= []
74
+ end
75
+
76
+ # A property that runs one fresh machine per example.
77
+ #
78
+ # A machine holds mutable state, so the instance cannot be shared: each
79
+ # example — and each shrink candidate — gets its own.
80
+ def property(step_count: DEFAULT_STEP_COUNT, **new_args)
81
+ machine_class = self
82
+ lambda do |test_case|
83
+ machine = new_args.empty? ? machine_class.new : machine_class.new(**new_args)
84
+ Stateful.run(machine, test_case, step_count: step_count)
85
+ end
86
+ end
87
+
88
+ # Subclasses start from their parent's rules rather than an empty list,
89
+ # so a shared base machine can define the rules every variant has.
90
+ def inherited(subclass)
91
+ super
92
+ subclass.instance_variable_set(:@declared_rules, declared_rules.dup)
93
+ subclass.instance_variable_set(:@declared_invariants, declared_invariants.dup)
94
+ end
95
+ end
96
+
97
+ private_class_method :declared_rules, :declared_invariants, :inherited
98
+
99
+ def rules
100
+ self.class.__send__(:declared_rules)
101
+ end
102
+
103
+ def invariants
104
+ self.class.__send__(:declared_invariants)
105
+ end
106
+ end
107
+
108
+ class FeatureFlags
109
+ def initialize(test_case, rule_count)
110
+ raw = test_case.__send__(:stateful_feature_probability_raw,
111
+ Core.stateful_feature_generation_raw
112
+ )
113
+ @probability = Core.stateful_feature_probability(raw)
114
+ @disabled = Array.new(rule_count)
115
+ @candidates = (0...rule_count).to_a
116
+ end
117
+
118
+ def enabled?(test_case, index)
119
+ test_case.start_span("stateful.feature_flag")
120
+ decided = @disabled[index]
121
+ forced = Core.stateful_feature_forced(
122
+ @candidates.length,
123
+ @candidates.include?(index),
124
+ !decided.nil?,
125
+ decided || false
126
+ )
127
+ begin
128
+ disabled = forced.nil? ? test_case.weighted(@probability) : test_case.__send__(:forced_boolean, forced)
129
+ rescue Exception # rubocop:disable Lint/RescueException
130
+ test_case.stop_span
131
+ raise
132
+ end
133
+ @disabled[index] = disabled
134
+ @candidates.clear unless disabled
135
+ @candidates.delete(index)
136
+ test_case.stop_span
137
+ !disabled
138
+ end
139
+ end
140
+
141
+ # Execution-local pool of arbitrary host-language values.
142
+ #
143
+ # A variable's identity is a *recorded* choice rather than a host-side
144
+ # counter, and a draw from the pool records the identifier it selected
145
+ # rather than a position in the live list. That is what lets the shrinker
146
+ # delete an earlier addition without changing what every later draw refers
147
+ # to — see ir/stateful.zir's stable-identifier section. Before that,
148
+ # deleting one addition renumbered every reference after it, so most
149
+ # deletions produced a different rule sequence that no longer failed and
150
+ # shrinking stalled far from minimal.
151
+ class Pool
152
+ def initialize
153
+ @active = []
154
+ @values = {}
155
+ end
156
+
157
+ def length
158
+ @active.length
159
+ end
160
+ alias size length
161
+
162
+ def empty?
163
+ @active.empty?
164
+ end
165
+
166
+ # Add a value under a freshly drawn identifier.
167
+ #
168
+ # Takes the case because adding is now a *draw*: the identifier is
169
+ # recorded in the choice stream rather than handed out by a host-side
170
+ # counter, which is what lets a later reference to this variable keep
171
+ # meaning this variable when an earlier addition is shrunk away.
172
+ def add(test_case, value)
173
+ variable_id = test_case.__send__(:fresh_id)
174
+ @active << variable_id
175
+ @values[variable_id] = value
176
+ nil
177
+ end
178
+
179
+ def values_reusable
180
+ ValuesReusable.new(self)
181
+ end
182
+
183
+ def values_consumed
184
+ ValuesConsumed.new(self)
185
+ end
186
+
187
+ protected
188
+
189
+ attr_reader :active, :values
190
+ end
191
+
192
+ class ValuesReusable
193
+ def initialize(pool)
194
+ @pool = pool
195
+ end
196
+
197
+ def do_draw(test_case)
198
+ test_case.assume(!@pool.empty?)
199
+ variable_id = test_case.__send__(:from_set, @pool.send(:active))
200
+ @pool.send(:values).fetch(variable_id)
201
+ end
202
+ end
203
+
204
+ class ValuesConsumed
205
+ def initialize(pool)
206
+ @pool = pool
207
+ end
208
+
209
+ def do_draw(test_case)
210
+ test_case.assume(!@pool.empty?)
211
+ variable_id = test_case.__send__(:from_set, @pool.send(:active))
212
+ @pool.send(:active).delete(variable_id)
213
+ @pool.send(:values).delete(variable_id)
214
+ end
215
+ end
216
+
217
+ module_function
218
+
219
+ def run(machine, test_case, step_count: DEFAULT_STEP_COUNT)
220
+ rules = machine.rules.to_a
221
+ test_case.assume(!rules.empty?)
222
+ invariants = machine.invariants.to_a
223
+ test_case.note("initial invariant check")
224
+ check_invariants(machine, invariants, test_case)
225
+
226
+ steps_drawn = 0
227
+ steps_rejected = 0
228
+ protocol = test_case.__send__(:stateful_protocol)
229
+
230
+ # The first decision, which is forced, and the feature probability sit
231
+ # before any step's span; each step's span then holds its rule probes,
232
+ # the rule's own draws, and the decision that follows it. That makes
233
+ # every step, the first included, one span the shrinker can delete
234
+ # without misaligning the steps after it. Upstream's span starts at the
235
+ # decision instead, which leaves the first step undeletable here:
236
+ # deleting it takes the forced decision and the probability with it, and
237
+ # a misread decision stops the run. The span tree is not part of the blob.
238
+ return nil if stop_decision(test_case, protocol, steps_drawn, steps_rejected, step_count)
239
+
240
+ feature_flags = protocol == 1 ? nil : FeatureFlags.new(test_case, rules.length)
241
+
242
+ loop do
243
+ test_case.start_span("stateful.rule")
244
+ rejected = false
245
+ begin
246
+ if feature_flags.nil?
247
+ index = draw_rule_index(test_case, rules.length)
248
+ else
249
+ index, feature_flags = select_rule(test_case, rules.length, feature_flags)
250
+ end
251
+ rule = rules[index]
252
+ steps_drawn += 1
253
+ test_case.note("step #{steps_drawn}: #{rule.name}")
254
+ begin
255
+ rule.apply.call(machine, test_case)
256
+ rescue AssumptionRejected
257
+ steps_rejected += 1
258
+ rejected = true
259
+ test_case.note("rule rejected by assumption")
260
+ end
261
+ check_invariants(machine, invariants, test_case) unless rejected
262
+ stop = stop_decision(test_case, protocol, steps_drawn, steps_rejected, step_count)
263
+ rescue Exception # rubocop:disable Lint/RescueException
264
+ test_case.stop_span
265
+ raise
266
+ end
267
+ test_case.stop_span(discarded: rejected)
268
+ break if stop
269
+ end
270
+ nil
271
+ end
272
+
273
+ # Draw one round's decision and report whether the case stops. Protocol 3
274
+ # records "continue?"; protocols 1 and 2 recorded "stop here?", and a
275
+ # replay of one of their blobs keeps drawing that.
276
+ def stop_decision(test_case, protocol, steps_drawn, steps_rejected, step_count)
277
+ if protocol == 3
278
+ forced = Core.stateful_continue_forced(steps_drawn, steps_rejected, step_count)
279
+ proceed = if forced.nil?
280
+ test_case.weighted(Core.stateful_continue_probability)
281
+ else
282
+ test_case.__send__(:forced_boolean, forced)
283
+ end
284
+ return !proceed
285
+ end
286
+
287
+ forced = Core.stateful_stop_forced(steps_drawn, steps_rejected, step_count)
288
+ forced.nil? ? test_case.weighted(STOP_PROBABILITY) : test_case.__send__(:forced_boolean, forced)
289
+ end
290
+ private_class_method :stop_decision
291
+
292
+ def draw_rule_index(test_case, rule_count)
293
+ test_case.integer(0, rule_count - 1)
294
+ end
295
+ private_class_method :draw_rule_index
296
+
297
+ def select_rule(test_case, rule_count, feature_flags)
298
+ feature_flags ||= FeatureFlags.new(test_case, rule_count)
299
+ known_bad = []
300
+ Core.stateful_rule_probe_count.times do
301
+ # A probe — the rule index and the flag it consults — is one span, so
302
+ # the shrinker can delete a probe that landed on a disabled rule
303
+ # without misaligning the draws after it. A misaligned continue
304
+ # decision reads as a stop, so that deletion is the one that reaches
305
+ # the minimal sequence; the span tree is not part of the blob.
306
+ test_case.start_span("stateful.rule_probe")
307
+ begin
308
+ index = draw_rule_index(test_case, rule_count)
309
+ enabled = !known_bad.include?(index) && feature_flags.enabled?(test_case, index)
310
+ ensure
311
+ test_case.stop_span
312
+ end
313
+ return [index, feature_flags] if enabled
314
+
315
+ known_bad << index unless known_bad.include?(index)
316
+ end
317
+
318
+ max_good = Core.stateful_fallback_max_good(rule_count, known_bad.length)
319
+ speculative = test_case.integer(0, max_good - 1)
320
+ allowed = []
321
+ rule_count.times do |index|
322
+ next if known_bad.include?(index)
323
+ next unless feature_flags.enabled?(test_case, index)
324
+
325
+ allowed << index
326
+ if Core.stateful_fallback_accept(allowed.length, speculative)
327
+ return [test_case.__send__(:forced_integer, index), feature_flags]
328
+ end
329
+ end
330
+ offset = test_case.integer(0, allowed.length - 1)
331
+ [test_case.__send__(:forced_integer, allowed[offset]), feature_flags]
332
+ end
333
+ private_class_method :select_rule
334
+
335
+ def check_invariants(machine, invariants, test_case)
336
+ invariants.each do |invariant|
337
+ test_case.note("invariant: #{invariant.name}")
338
+ invariant.apply.call(machine, test_case)
339
+ end
340
+ end
341
+ private_class_method :check_invariants
342
+
343
+ def pool(_test_case)
344
+ Pool.new
345
+ end
346
+
347
+ private_constant :FeatureFlags, :ValuesReusable, :ValuesConsumed, :STOP_PROBABILITY
348
+ end
349
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zizek
4
+ # The gemspec reads this file directly, so it must stay free of requires:
5
+ # loading the rest of the package to learn the version would drag the
6
+ # generated core into every `gem build`.
7
+ VERSION = "0.1.0"
8
+ end
data/lib/zizek.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zizek/version"
4
+ require_relative "zizek/settings"
5
+ require_relative "zizek/database"
6
+ require_relative "zizek/shell"
7
+ require_relative "zizek/generators"
8
+ require_relative "zizek/stateful"
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zizek
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - waldemar.quevedo@gmail.com
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3'
40
+ description: |
41
+ Zizek is a native property-based testing for Ruby, modeled on Hegel and Hypothesis.
42
+ It explores generated examples, records every choice, shrinks the first failure to a
43
+ minimal counterexample, and reports a reproduce blob that replays that exact example.
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files:
47
+ - CHANGELOG.md
48
+ - GUIDE.md
49
+ - LICENSE
50
+ - README.md
51
+ files:
52
+ - CHANGELOG.md
53
+ - GUIDE.md
54
+ - LICENSE
55
+ - README.md
56
+ - docs/assets/zizek-rb-logo-dark.svg
57
+ - docs/assets/zizek-rb-logo.svg
58
+ - examples/combinators.rb
59
+ - examples/coverage_assertions.rb
60
+ - examples/database_workflow.rb
61
+ - examples/die_hard.rb
62
+ - examples/hypothesis_quickstart.rb
63
+ - examples/keyed_draws.rb
64
+ - examples/ledger.rb
65
+ - examples/min_stack.rb
66
+ - examples/minitest_suite.rb
67
+ - examples/multi.rb
68
+ - examples/queue.rb
69
+ - examples/roundtrip.rb
70
+ - examples/stack.rb
71
+ - examples/tour.rb
72
+ - lib/zizek.rb
73
+ - lib/zizek/database.rb
74
+ - lib/zizek/gen.rb
75
+ - lib/zizek/generators.rb
76
+ - lib/zizek/minitest.rb
77
+ - lib/zizek/rspec.rb
78
+ - lib/zizek/settings.rb
79
+ - lib/zizek/shell.rb
80
+ - lib/zizek/stateful.rb
81
+ - lib/zizek/version.rb
82
+ homepage: https://github.com/wallyqs/zizek.rb
83
+ licenses:
84
+ - Apache-2.0
85
+ metadata:
86
+ homepage_uri: https://github.com/wallyqs/zizek.rb
87
+ source_code_uri: https://github.com/wallyqs/zizek.rb
88
+ changelog_uri: https://github.com/wallyqs/zizek.rb/blob/main/CHANGELOG.md
89
+ documentation_uri: https://github.com/wallyqs/zizek.rb/blob/main/GUIDE.md
90
+ bug_tracker_uri: https://github.com/wallyqs/zizek.rb/issues
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '2.6'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.0'
105
+ requirements: []
106
+ rubygems_version: 4.0.23
107
+ specification_version: 4
108
+ summary: Property-based testing with portable, cross-language reproduce blobs
109
+ test_files: []