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.
data/examples/multi.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of Hegel's two-assertion example. Zizek reports the first failure found
4
+ # and preserves that assertion site while shrinking, so a candidate which trips
5
+ # the other assertion cannot be mistaken for progress on the selected bug.
6
+
7
+ require "zizek"
8
+
9
+ module MultiExample
10
+ EDGE = 1_337
11
+
12
+ module_function
13
+
14
+ def property(test_case)
15
+ value = test_case.draw(
16
+ Zizek::Generators.integers.min_value(0).max_value(2_000)
17
+ )
18
+ raise "#{value} hit the forbidden edge" if value == EDGE
19
+ raise "#{value} is not below the edge" unless value < EDGE
20
+ end
21
+
22
+ def find_and_replay_one_origin
23
+ found =
24
+ begin
25
+ Zizek.check(method(:property), examples: 100, seed: 1)
26
+ raise "one of the two integer bugs should be discovered"
27
+ rescue Zizek::Failure => failure
28
+ message = failure.property_cause.message
29
+ unless message.include?("forbidden edge") || message.include?("not below the edge")
30
+ raise "unexpected failure origin: #{message}"
31
+ end
32
+ failure
33
+ end
34
+
35
+ begin
36
+ Zizek.check(method(:property), reproduce: found.blob)
37
+ rescue Zizek::Failure => replayed
38
+ raise unless replayed.property_cause.message == found.property_cause.message
39
+
40
+ return found
41
+ end
42
+ raise "the selected failure origin must replay"
43
+ end
44
+ end
45
+
46
+ puts MultiExample.find_and_replay_one_origin if $PROGRAM_NAME == __FILE__
data/examples/queue.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Model-based stateful example: a bounded ring queue vs a plain array model.
4
+ #
5
+ # Demonstrates:
6
+ # - manual state machines (Zizek::Stateful::Rule, Zizek::Stateful.run);
7
+ # - assumptions inside rules (pop is rejected while the queue is empty);
8
+ # - invariants checked after every successful rule;
9
+ # - failure capture — the bug eventually reads past the backing array; and
10
+ # - structural shrinking of whole rule spans plus blob replay.
11
+
12
+ require "zizek"
13
+
14
+ module QueueExample
15
+ CAPACITY = 2
16
+
17
+ # The shrunk failure blob is identical in Go, Python, and Ruby.
18
+ PORTABLE_QUEUE_FAILURE =
19
+ "AXicjcnBCQBACAPB3e8VcP2XqiL4E8xnQvIB5UmlcYrbkp7ONAAiJQCa"
20
+
21
+ # A fixed-capacity FIFO queue over a circular buffer. Pop wraps the head
22
+ # index one slot too late — a deliberate off-by-one that reads past the
23
+ # backing array (returning nil) on the third pop.
24
+ class RingQueue
25
+ attr_reader :size
26
+
27
+ def initialize
28
+ @data = Array.new(CAPACITY, 0)
29
+ @head = 0
30
+ @tail = 0
31
+ @size = 0
32
+ end
33
+
34
+ def push(value)
35
+ return false if size == CAPACITY
36
+
37
+ @data[@tail] = value
38
+ @tail = (@tail + 1) % CAPACITY
39
+ @size += 1
40
+ true
41
+ end
42
+
43
+ def pop
44
+ value = @data[@head]
45
+ @head = (@head + 1) % (CAPACITY + 1) # BUG: should wrap modulo CAPACITY
46
+ @size -= 1
47
+ value
48
+ end
49
+ end
50
+
51
+ class QueueTest
52
+ def initialize
53
+ @queue = RingQueue.new
54
+ @model = []
55
+ end
56
+
57
+ def push(test_case)
58
+ value = test_case.draw(Zizek::Generators.integers.min_value(0).max_value(99))
59
+ if @queue.push(value)
60
+ @model << value
61
+ test_case.note("push #{value}")
62
+ else
63
+ test_case.note("push #{value} refused: queue full")
64
+ end
65
+ end
66
+
67
+ def pop(test_case)
68
+ test_case.assume(!@model.empty?)
69
+ want = @model.shift
70
+ got = @queue.pop
71
+ test_case.note("pop -> #{got.inspect}")
72
+ raise "pop returned #{got.inspect}, expected #{want}" unless got == want
73
+ end
74
+
75
+ def sizes_agree(_test_case)
76
+ return if @queue.size == @model.length
77
+
78
+ raise "queue reports #{@queue.size} items, model holds #{@model.length}"
79
+ end
80
+
81
+ def rules
82
+ [
83
+ Zizek::Stateful::Rule.new("push", ->(machine, test_case) { machine.push(test_case) }),
84
+ Zizek::Stateful::Rule.new("pop", ->(machine, test_case) { machine.pop(test_case) })
85
+ ]
86
+ end
87
+
88
+ def invariants
89
+ [
90
+ Zizek::Stateful::Rule.new(
91
+ "sizes_agree",
92
+ ->(machine, test_case) { machine.sizes_agree(test_case) }
93
+ )
94
+ ]
95
+ end
96
+ end
97
+
98
+ module_function
99
+
100
+ def property(test_case)
101
+ Zizek::Stateful.run(QueueTest.new, test_case)
102
+ end
103
+
104
+ def find_and_replay_bug
105
+ found =
106
+ begin
107
+ Zizek.check(method(:property), examples: 2_000, seed: 3)
108
+ raise "the ring-queue off-by-one should be discovered"
109
+ rescue Zizek::Failure => failure
110
+ failure
111
+ end
112
+
113
+ begin
114
+ Zizek.check(method(:property), reproduce: found.blob)
115
+ rescue Zizek::Failure
116
+ return found
117
+ end
118
+ raise "the shrunk queue failure must replay"
119
+ end
120
+ end
121
+
122
+ puts QueueExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Round-trip property example: run-length encoding.
4
+ #
5
+ # Demonstrates:
6
+ # - the portable text generator (Zizek::Generators.text);
7
+ # - notes attached to the failing example (test_case.note);
8
+ # - shrinking to a minimal counterexample; and
9
+ # - replaying the portable reproduce blob.
10
+
11
+ require "zizek"
12
+
13
+ module RoundtripExample
14
+ # The shrunk failure blob is identical in Go, Python, and Ruby.
15
+ PORTABLE_ROUNDTRIP_FAILURE = "AAEAAAAEAQAAADAAAAA="
16
+
17
+ module_function
18
+
19
+ # Compresses text into [count, char] runs. Deliberate bug: the run in
20
+ # progress is never flushed when the input ends, so the final run of every
21
+ # non-empty input is lost.
22
+ def encode(text)
23
+ runs = []
24
+ count = 0
25
+ current = nil
26
+ text.each_char do |char|
27
+ if count.positive? && char == current
28
+ count += 1
29
+ next
30
+ end
31
+ runs << [count, current] if count.positive?
32
+ current = char
33
+ count = 1
34
+ end
35
+ runs
36
+ end
37
+
38
+ def decode(runs)
39
+ runs.map { |count, char| char * count }.join
40
+ end
41
+
42
+ def property(test_case)
43
+ text = test_case.draw(Zizek::Generators.text.max_size(12))
44
+ test_case.note("text #{text.inspect} encodes to #{encode(text).inspect}")
45
+ decoded = decode(encode(text))
46
+ return if decoded == text
47
+
48
+ raise "round trip lost data: #{text.inspect} became #{decoded.inspect}"
49
+ end
50
+
51
+ def find_and_replay_bug
52
+ found =
53
+ begin
54
+ Zizek.check(method(:property), examples: 200, seed: 1)
55
+ raise "the round-trip bug should be discovered"
56
+ rescue Zizek::Failure => failure
57
+ failure
58
+ end
59
+
60
+ # The blob is a portable description of the shrunk choices: replaying it
61
+ # runs exactly the recorded example, here or in any other Zizek package.
62
+ begin
63
+ Zizek.check(method(:property), reproduce: found.blob)
64
+ rescue Zizek::Failure
65
+ return found
66
+ end
67
+ raise "the shrunk round-trip failure must replay"
68
+ end
69
+ end
70
+
71
+ puts RoundtripExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
data/examples/stack.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of Hegel's passing stack state machine. It demonstrates a model whose
4
+ # rules include an assumption: push_pop is rejected while the stack is empty,
5
+ # without rejecting the whole generated example.
6
+
7
+ require "zizek"
8
+
9
+ module StackExample
10
+ class IntegerStack
11
+ def initialize
12
+ @stack = []
13
+ end
14
+
15
+ def push(test_case)
16
+ @stack << test_case.draw(Zizek::Generators.integers)
17
+ end
18
+
19
+ def pop(_test_case)
20
+ @stack.pop
21
+ end
22
+
23
+ def pop_push(test_case)
24
+ value = test_case.draw(Zizek::Generators.integers)
25
+ initial = @stack.dup
26
+ @stack << value
27
+ raise "pop did not return the pushed value" unless @stack.pop == value
28
+ raise "push then pop changed the stack" unless @stack == initial
29
+ end
30
+
31
+ def push_pop(test_case)
32
+ initial = @stack.dup
33
+ value = @stack.pop
34
+ test_case.assume(!value.nil?)
35
+ @stack << value
36
+ raise "pop then push changed the stack" unless @stack == initial
37
+ end
38
+
39
+ def rules
40
+ [
41
+ Zizek::Stateful::Rule.new("push", ->(machine, test_case) { machine.push(test_case) }),
42
+ Zizek::Stateful::Rule.new("pop", ->(machine, test_case) { machine.pop(test_case) }),
43
+ Zizek::Stateful::Rule.new("pop_push", ->(machine, test_case) { machine.pop_push(test_case) }),
44
+ Zizek::Stateful::Rule.new("push_pop", ->(machine, test_case) { machine.push_pop(test_case) })
45
+ ]
46
+ end
47
+
48
+ def invariants
49
+ []
50
+ end
51
+ end
52
+
53
+ module_function
54
+
55
+ def property(test_case)
56
+ Zizek::Stateful.run(IntegerStack.new, test_case)
57
+ end
58
+
59
+ def verify_stack_model
60
+ Zizek.check(method(:property), examples: 100, seed: 1)
61
+ true
62
+ end
63
+ end
64
+
65
+ if $PROGRAM_NAME == __FILE__
66
+ StackExample.verify_stack_model
67
+ puts "stack model passed"
68
+ end
data/examples/tour.rb ADDED
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A tour of Zizek's draw and configuration surface, one scenario per feature:
4
+ #
5
+ # 1. bounded integers with a custom shrink target;
6
+ # 2. assumptions that reject examples instead of failing them;
7
+ # 3. boolean and byte draws shrinking together;
8
+ # 4. bounded IEEE-754 floats with portable shrinking; and
9
+ # 5. rejection limits (Zizek::TooManyRejections).
10
+ #
11
+ # Every scenario is deterministic: it uses a fixed seed and prints the shrunk
12
+ # failure it expects to find.
13
+
14
+ require "zizek"
15
+
16
+ module TourExample
17
+ module_function
18
+
19
+ # shrink_towards: failures shrink to the failing value closest to the
20
+ # requested target instead of the interval minimum.
21
+ def shrink_targets
22
+ property = lambda do |test_case|
23
+ value = test_case.draw(
24
+ Zizek::Generators.integers.min_value(-1000).max_value(1000).shrink_towards(500)
25
+ )
26
+ test_case.note("value #{value}")
27
+ raise "value #{value} exceeds the budget of 700" if value > 700
28
+ end
29
+
30
+ begin
31
+ Zizek.check(property, examples: 300, seed: 1)
32
+ raise "expected a failure above 700"
33
+ rescue Zizek::Failure => failure
34
+ failure
35
+ end
36
+ end
37
+
38
+ # assume: odd draws are rejected (not failed), so the shrunk counterexample
39
+ # is the smallest *even* value above the limit.
40
+ def even_numbers_only
41
+ property = lambda do |test_case|
42
+ value = test_case.integer(0, 100)
43
+ test_case.assume(value.even?)
44
+ test_case.note("even value #{value}")
45
+ raise "even value #{value} exceeds 50" if value > 50
46
+ end
47
+
48
+ begin
49
+ Zizek.check(property, examples: 300, seed: 1)
50
+ raise "expected an even failure above 50"
51
+ rescue Zizek::Failure => failure
52
+ failure
53
+ end
54
+ end
55
+
56
+ # Low-level boolean and bytes draws shrinking together: the boolean stays
57
+ # true because false passes, and the payload shrinks to the single smallest
58
+ # high byte.
59
+ def flags_and_bytes
60
+ property = lambda do |test_case|
61
+ compressed = test_case.boolean
62
+ payload = test_case.bytes(1, 4)
63
+ test_case.note("compressed=#{compressed} payload=#{payload.unpack1('H*')}")
64
+ next unless compressed
65
+
66
+ payload.each_byte do |value|
67
+ raise format("compressed payload contains high byte 0x%02x", value) if value >= 0x80
68
+ end
69
+ end
70
+
71
+ begin
72
+ Zizek.check(property, examples: 300, seed: 1)
73
+ raise "expected a high-byte failure"
74
+ rescue Zizek::Failure => failure
75
+ failure
76
+ end
77
+ end
78
+
79
+ # Float generation, replay, and shrink ordering use the compiled float
80
+ # choice. The smallest value outside [0, 1) is exactly 1.0.
81
+ def bounded_floats
82
+ property = lambda do |test_case|
83
+ value = test_case.draw(
84
+ Zizek::Generators.floats.min_value(0.0).max_value(10.0)
85
+ )
86
+ test_case.note("value #{value.inspect}")
87
+ raise "float #{value.inspect} is outside [0, 1)" if value >= 1.0
88
+ end
89
+
90
+ begin
91
+ Zizek.check(property, examples: 300, seed: 1)
92
+ raise "expected a float failure"
93
+ rescue Zizek::Failure => failure
94
+ failure
95
+ end
96
+ end
97
+
98
+ # max_rejections: a property whose assumptions reject every example stops
99
+ # with TooManyRejections instead of searching forever.
100
+ def rejection_limit
101
+ property = lambda do |test_case|
102
+ value = test_case.integer(0, 1000)
103
+ test_case.assume(value > 1000) # impossible: every example is rejected
104
+ end
105
+
106
+ begin
107
+ Zizek.check(property, examples: 10, max_rejections: 20, seed: 1)
108
+ raise "expected TooManyRejections"
109
+ rescue Zizek::TooManyRejections => rejected
110
+ rejected
111
+ end
112
+ end
113
+ end
114
+
115
+ if $PROGRAM_NAME == __FILE__
116
+ puts "== 1. integers with a shrink target =="
117
+ puts TourExample.shrink_targets.message
118
+ puts "\n== 2. assumptions =="
119
+ puts TourExample.even_numbers_only.message
120
+ puts "\n== 3. booleans and bytes =="
121
+ puts TourExample.flags_and_bytes.message
122
+ puts "\n== 4. bounded floats =="
123
+ puts TourExample.bounded_floats.message
124
+ puts "\n== 5. rejection limits =="
125
+ puts TourExample.rejection_limit.message
126
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module Zizek
7
+ # Where a counterexample goes after the run that found it.
8
+ #
9
+ # A database is a cache that never needs invalidating. It stores raw
10
+ # serialized choice streams — the bytes under a reproduce blob, not the blob
11
+ # — keyed by a property's name, so the next run replays yesterday's failure
12
+ # instead of hoping to rediscover it.
13
+ #
14
+ # Both digests are the project's compiled keyed-draw hash (ir/keys.zir), so a
15
+ # directory written by this gem is byte-for-byte the directory the Python and
16
+ # Go packages write.
17
+ module Database
18
+ # Where a database goes when a caller asks for one without saying where.
19
+ DEFAULT_PATH = ".zizek/examples"
20
+
21
+ # An entry's filename: sixteen lowercase hex digits and nothing else.
22
+ # Anything else in a key directory belongs to something other than the
23
+ # engine — .name, a .gitignore, an editor's swap file — and is ignored
24
+ # rather than replayed.
25
+ ENTRY_PATTERN = /\A[0-9a-f]{16}\z/.freeze
26
+
27
+ # The file naming a key directory, for a human reading a checkout. The
28
+ # engine writes it and never reads it.
29
+ NAME_FILE = ".name"
30
+
31
+ module_function
32
+
33
+ # The sixteen hex digits a name or a stream is stored under.
34
+ #
35
+ # FNV-1a 64 over the tagged encoding compiled from ir/keys.zir, which is the
36
+ # same hash a keyed draw uses and the reason a database directory is
37
+ # portable across languages.
38
+ def digest(value)
39
+ format("%016x", Zizek.key_hash(value.to_s.b))
40
+ end
41
+
42
+ # The order stored entries are replayed in: shortest first, then bytes.
43
+ # The same order the shrinker prefers, so the simplest stored
44
+ # counterexample is the first one a run pays for.
45
+ def shortlex(stream)
46
+ [stream.bytesize, stream]
47
+ end
48
+
49
+ # Turn what a caller passed for `database:` into a database or nil.
50
+ #
51
+ # nil and false disable it, true selects the default directory, ":memory:"
52
+ # an in-memory one, any other string or Pathname a directory, and anything
53
+ # else is assumed to implement fetch/save/delete itself.
54
+ def resolve(spec)
55
+ return nil if spec.nil? || spec == false
56
+ return Directory.new(DEFAULT_PATH) if spec == true
57
+ return Memory.new if spec == ":memory:"
58
+ return Directory.new(spec) if spec.is_a?(String) || spec.respond_to?(:to_path)
59
+
60
+ %i[fetch save delete].each do |method|
61
+ next if spec.respond_to?(method)
62
+
63
+ raise ArgumentError,
64
+ "database must be a path, \":memory:\", true, nil, or an object " \
65
+ "responding to fetch/save/delete; #{spec.inspect} does not respond to #{method}"
66
+ end
67
+ spec
68
+ end
69
+
70
+ # A database that forgets when the process exits.
71
+ #
72
+ # For a suite that wants replay within a run — or a test about the database
73
+ # itself — without leaving anything behind.
74
+ class Memory
75
+ def initialize
76
+ @entries = Hash.new { |store, key| store[key] = [] }
77
+ end
78
+
79
+ def inspect
80
+ "#<Zizek::Database::Memory>"
81
+ end
82
+
83
+ def fetch(key)
84
+ @entries.key?(key) ? @entries[key].dup : []
85
+ end
86
+
87
+ def save(key, value)
88
+ stream = value.b.freeze
89
+ @entries[key] << stream unless @entries[key].include?(stream)
90
+ end
91
+
92
+ def delete(key, value)
93
+ return unless @entries.key?(key)
94
+
95
+ @entries[key].delete(value.b)
96
+ @entries.delete(key) if @entries[key].empty?
97
+ end
98
+ end
99
+
100
+ # A directory of counterexamples, one subdirectory per property.
101
+ #
102
+ # The layout is shared with every other zizek package, so a directory
103
+ # checked into version control replays in any of them:
104
+ #
105
+ # .zizek/examples/
106
+ # 9dc0c2b1f8a17c4e/ # the key's digest
107
+ # .name # the property's name, for a human
108
+ # 3a6e0f5c9b2d1877 # a stored stream
109
+ #
110
+ # Every operation is best-effort: a database that cannot be read or written
111
+ # must not fail a test that would otherwise pass.
112
+ class Directory
113
+ attr_reader :path
114
+
115
+ def initialize(path = DEFAULT_PATH)
116
+ @path = path.to_s
117
+ end
118
+
119
+ def inspect
120
+ "#<Zizek::Database::Directory #{path.inspect}>"
121
+ end
122
+
123
+ def ==(other)
124
+ other.is_a?(Directory) && other.path == path
125
+ end
126
+ alias eql? ==
127
+
128
+ def hash
129
+ [Directory, path].hash
130
+ end
131
+
132
+ def key_path(key)
133
+ File.join(path, Database.__send__(:digest, key))
134
+ end
135
+
136
+ def value_path(key, value)
137
+ File.join(key_path(key), Database.__send__(:digest, value))
138
+ end
139
+
140
+ private :key_path, :value_path
141
+
142
+ def fetch(key)
143
+ directory = key_path(key)
144
+ names = begin
145
+ Dir.children(directory)
146
+ rescue SystemCallError
147
+ return []
148
+ end
149
+ names.select { |name| ENTRY_PATTERN.match?(name) }.map do |name|
150
+ begin
151
+ File.binread(File.join(directory, name))
152
+ rescue SystemCallError
153
+ # Deleted between the listing and the read: a concurrent run
154
+ # collected it, which is exactly what it is supposed to do.
155
+ nil
156
+ end
157
+ end.compact
158
+ end
159
+
160
+ def save(key, value)
161
+ directory = key_path(key)
162
+ FileUtils.mkdir_p(directory)
163
+ name_file = File.join(directory, NAME_FILE)
164
+ Database.__send__(:atomic_write, name_file, key.to_s.b) unless File.exist?(name_file)
165
+ entry = File.join(directory, Database.__send__(:digest, value))
166
+ Database.__send__(:atomic_write, entry, value.b) unless File.exist?(entry)
167
+ rescue SystemCallError
168
+ nil
169
+ end
170
+
171
+ def delete(key, value)
172
+ File.unlink(value_path(key, value))
173
+ # An emptied key directory takes its .name with it, so a database that
174
+ # collected everything leaves no husk behind — but only once the last
175
+ # entry is gone, or a directory that still holds counterexamples would
176
+ # lose the label that says whose they are.
177
+ begin
178
+ directory = key_path(key)
179
+ return if Dir.children(directory).any? { |name| ENTRY_PATTERN.match?(name) }
180
+
181
+ File.unlink(File.join(directory, NAME_FILE))
182
+ Dir.rmdir(directory)
183
+ rescue SystemCallError
184
+ nil
185
+ end
186
+ rescue SystemCallError
187
+ nil
188
+ end
189
+ end
190
+
191
+ # Write through a temporary file, so a reader never sees a partial entry.
192
+ def atomic_write(path, payload)
193
+ directory = File.dirname(path)
194
+ temporary = Tempfile.create(".zizek", directory, binmode: true)
195
+ begin
196
+ temporary.write(payload)
197
+ temporary.close
198
+ File.rename(temporary.path, path)
199
+ rescue SystemCallError
200
+ begin
201
+ File.unlink(temporary.path)
202
+ rescue SystemCallError
203
+ nil
204
+ end
205
+ end
206
+ end
207
+
208
+ private_class_method :digest, :shortlex, :resolve, :atomic_write
209
+ private_constant :ENTRY_PATTERN, :NAME_FILE
210
+ end
211
+ end