lrama-fuzz 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/Gemfile +10 -0
- data/LICENSE +7 -0
- data/README.md +228 -0
- data/Rakefile +11 -0
- data/examples/arithmetic.y +9 -0
- data/examples/json.y +31 -0
- data/examples/lists.y +14 -0
- data/exe/lrama-fuzz +170 -0
- data/lib/lrama/fuzz/codon_random.rb +48 -0
- data/lib/lrama/fuzz/composed_evolver.rb +94 -0
- data/lib/lrama/fuzz/cost.rb +82 -0
- data/lib/lrama/fuzz/coverage.rb +133 -0
- data/lib/lrama/fuzz/evolver.rb +252 -0
- data/lib/lrama/fuzz/expansion.rb +62 -0
- data/lib/lrama/fuzz/generator.rb +243 -0
- data/lib/lrama/fuzz/genetic_operators.rb +36 -0
- data/lib/lrama/fuzz/joiner.rb +54 -0
- data/lib/lrama/fuzz/json.rb +106 -0
- data/lib/lrama/fuzz/prism.rb +52 -0
- data/lib/lrama/fuzz/ruby/composer.rb +538 -0
- data/lib/lrama/fuzz/ruby.rb +97 -0
- data/lib/lrama/fuzz/ruby_terminals.rb +174 -0
- data/lib/lrama/fuzz/rubyvm.rb +52 -0
- data/lib/lrama/fuzz/session.rb +110 -0
- data/lib/lrama/fuzz/shrinker.rb +101 -0
- data/lib/lrama/fuzz/version.rb +7 -0
- data/lib/lrama/fuzz.rb +86 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 20f3eb480a5c1390a4bbf41c9fe786d5a2c4d5a6f71d1131a9db21962c58706a
|
|
4
|
+
data.tar.gz: a8276babf5102702fdc250718298c5f2840ebd2f05691c3a97c98d596ba4252c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 79b4593ed0821e4e1102e426170132be2de49206e0b12301101e9e374f40be11731c6f937378401bcd1321c047d95791bcc73fe4cea2f8211910ef238aa841fc
|
|
7
|
+
data.tar.gz: 13a57d3e084070b2d7f557c73a4e4b7ed90290dc16ab66160c2f2145a3a6b77bb1cd6d9ddef8ef054768c0762e212af50d1a7f2bf8c887bd664d0d3b1743f521
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026-present, Kevin Newton
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# lrama-fuzz
|
|
2
|
+
|
|
3
|
+
Grammar-based fuzzer for [lrama](https://github.com/ruby/lrama) grammars. Generates random strings from any lrama grammar, with built-in profiles for fuzzing Ruby (via Prism or RubyVM) and JSON.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
In your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "lrama-fuzz"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install lrama-fuzz
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### Ruby fuzzing (Prism)
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "lrama/fuzz"
|
|
25
|
+
|
|
26
|
+
session = Lrama::Fuzz.prism(ruby_src_dir: "/path/to/ruby", seed: 42)
|
|
27
|
+
|
|
28
|
+
# Generate a raw grammar derivation (may or may not be valid Ruby)
|
|
29
|
+
puts session.generate
|
|
30
|
+
|
|
31
|
+
# Generate a composed, valid Ruby program
|
|
32
|
+
puts session.compose
|
|
33
|
+
|
|
34
|
+
# Evolve programs over 10 generations, optimizing for complexity
|
|
35
|
+
best = session.evolve(10, population_size: 50)
|
|
36
|
+
best.each { |code, fitness| puts "#{fitness.round(2)}: #{code}" }
|
|
37
|
+
|
|
38
|
+
# Check grammar rule coverage
|
|
39
|
+
session.generate_full_coverage(max_attempts: 500)
|
|
40
|
+
cov = session.coverage
|
|
41
|
+
puts "#{cov.covered_count}/#{cov.total_count} rules (#{(cov.ratio * 100).round(1)}%)"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Ruby fuzzing (RubyVM)
|
|
45
|
+
|
|
46
|
+
Uses `RubyVM::InstructionSequence.compile_parsey` for validation instead of Prism. This tests the lrama-generated parser directly.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
session = Lrama::Fuzz.rubyvm(ruby_src_dir: "/path/to/ruby", seed: 42)
|
|
50
|
+
puts session.compose
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### JSON fuzzing
|
|
54
|
+
|
|
55
|
+
Uses the JSON grammar from `examples/json.y` -- no external files needed.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
session = Lrama::Fuzz.json(seed: 42)
|
|
59
|
+
|
|
60
|
+
puts session.generate # raw derivation
|
|
61
|
+
puts session.generate_valid(max_retries: 50) # valid JSON document
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Coverage-guided generation
|
|
65
|
+
|
|
66
|
+
Generate programs with validity feedback. The generator tracks which grammar rules have appeared in valid programs and biases future generation toward rules that haven't been tested in valid contexts yet.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
session = Lrama::Fuzz.json(seed: 42)
|
|
70
|
+
|
|
71
|
+
# Generate 200 programs with automatic feedback
|
|
72
|
+
valid_programs = session.generate_guided(count: 200)
|
|
73
|
+
puts "#{valid_programs.size} valid out of 200"
|
|
74
|
+
|
|
75
|
+
# Check valid coverage (rules seen in valid programs)
|
|
76
|
+
cov = session.coverage
|
|
77
|
+
puts "Raw coverage: #{(cov.ratio * 100).round(1)}%"
|
|
78
|
+
puts "Valid coverage: #{(cov.valid_ratio * 100).round(1)}%"
|
|
79
|
+
|
|
80
|
+
# Use the block form for per-program handling
|
|
81
|
+
session.generate_guided(count: 100) do |code, valid|
|
|
82
|
+
File.write("corpus/#{Time.now.to_f}.json", code) if valid
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The generator also uses valid coverage in its rule selection: after all rules have been expanded at least once, it prefers rules that haven't yet appeared in any valid program. This drives generation toward under-tested parts of the grammar.
|
|
87
|
+
|
|
88
|
+
## Shrinking
|
|
89
|
+
|
|
90
|
+
Minimize a failing input to the smallest version that still triggers the bug. Uses delta debugging (line-level, then character-level).
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# Standalone
|
|
94
|
+
small = Lrama::Fuzz::Shrinker.shrink(big_program) { |code| crashes?(code) }
|
|
95
|
+
|
|
96
|
+
# Via session
|
|
97
|
+
small = session.shrink(big_program) { |code| crashes?(code) }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## CLI
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
$ lrama-fuzz --help
|
|
104
|
+
Usage: lrama-fuzz [options]
|
|
105
|
+
|
|
106
|
+
Generates programs from lrama grammars.
|
|
107
|
+
|
|
108
|
+
--profile PROFILE Profile: prism, rubyvm, json (default: prism)
|
|
109
|
+
-d, --ruby-src-dir DIR Path to Ruby source (default: $RUBY_SRC_DIR)
|
|
110
|
+
--grammar-path PATH Path to grammar file (json only)
|
|
111
|
+
-n, --count N Number of programs to generate (default: 10)
|
|
112
|
+
-m, --mode MODE Mode: generate, compose, evolve, coverage (default: generate)
|
|
113
|
+
-g, --generations N Generations for evolve mode (default: 10)
|
|
114
|
+
-p, --population N Population size for evolve mode (default: 50)
|
|
115
|
+
-s, --seed N Random seed for reproducibility
|
|
116
|
+
-h, --help Show this help
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
# Generate 5 composed Ruby programs
|
|
123
|
+
RUBY_SRC_DIR=/path/to/ruby lrama-fuzz -m compose -n 5
|
|
124
|
+
|
|
125
|
+
# Generate valid JSON
|
|
126
|
+
lrama-fuzz --profile json -m generate -n 10
|
|
127
|
+
|
|
128
|
+
# Evolve Ruby programs for 20 generations
|
|
129
|
+
RUBY_SRC_DIR=/path/to/ruby lrama-fuzz -m evolve -g 20 -p 30
|
|
130
|
+
|
|
131
|
+
# Measure grammar rule coverage
|
|
132
|
+
RUBY_SRC_DIR=/path/to/ruby lrama-fuzz -m coverage -n 500
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Custom grammars
|
|
136
|
+
|
|
137
|
+
You can fuzz any lrama grammar by using the core API directly.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
require "lrama/fuzz"
|
|
141
|
+
|
|
142
|
+
# Parse a grammar
|
|
143
|
+
grammar = Lrama::Fuzz.parse("path/to/grammar.y")
|
|
144
|
+
|
|
145
|
+
# Define terminal generators -- each token name maps to a string or proc
|
|
146
|
+
terminals = {
|
|
147
|
+
"NUMBER" => -> { rand(1..100).to_s },
|
|
148
|
+
"STRING" => -> { %w[foo bar baz].sample }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Create a generator
|
|
152
|
+
generator = Lrama::Fuzz::Generator.new(
|
|
153
|
+
grammar,
|
|
154
|
+
terminals: terminals,
|
|
155
|
+
max_depth: 10, # depth limit for derivation (default: 10)
|
|
156
|
+
random: Random.new(42)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Generate strings
|
|
160
|
+
10.times { puts generator.generate }
|
|
161
|
+
|
|
162
|
+
# Generate strings that pass a validator
|
|
163
|
+
valid = generator.generate_valid(max_retries: 100) { |s| valid?(s) }
|
|
164
|
+
|
|
165
|
+
# Track coverage
|
|
166
|
+
generator.generate_full_coverage(max_attempts: 500)
|
|
167
|
+
puts generator.coverage.ratio
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Wrapping in a Session
|
|
171
|
+
|
|
172
|
+
For access to composition, evolution, and shrinking, wrap a generator in a `Session`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
session = Lrama::Fuzz::Session.new(
|
|
176
|
+
generator,
|
|
177
|
+
fitness: ->(code) { code.length > 10 ? 1.5 : 0.3 },
|
|
178
|
+
validator: ->(code) { code.length > 0 },
|
|
179
|
+
random: Random.new(42)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
session.generate # raw derivation
|
|
183
|
+
session.generate_valid # passes validator
|
|
184
|
+
session.evolve(10, population_size: 20) # evolutionary optimization
|
|
185
|
+
session.shrink(code) { |c| some_predicate?(c) } # delta debugging
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Architecture
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
Lrama::Fuzz
|
|
192
|
+
.prism(ruby_src_dir:, seed:) # -> Session (Prism profile)
|
|
193
|
+
.rubyvm(ruby_src_dir:, seed:) # -> Session (RubyVM profile)
|
|
194
|
+
.json(seed:) # -> Session (JSON profile)
|
|
195
|
+
.parse(path) # -> Grammar
|
|
196
|
+
.new(path, terminals:, **opts) # -> Generator
|
|
197
|
+
|
|
198
|
+
Session # unified interface
|
|
199
|
+
#generate # raw grammar derivation
|
|
200
|
+
#generate_valid # derivation that passes validator
|
|
201
|
+
#generate_guided # generate with validity feedback loop
|
|
202
|
+
#compose # template-composed valid program (Ruby only)
|
|
203
|
+
#evolve(n) # evolutionary optimization
|
|
204
|
+
#shrink(code, &pred) # delta debugging minimizer
|
|
205
|
+
#coverage # grammar rule coverage tracker
|
|
206
|
+
|
|
207
|
+
Generator # core derivation engine
|
|
208
|
+
#generate # random derivation from start symbol
|
|
209
|
+
#generate_valid # retry until validator passes
|
|
210
|
+
#record_result # feed back validity for coverage guidance
|
|
211
|
+
#generate_full_coverage # target uncovered rules
|
|
212
|
+
|
|
213
|
+
Coverage # grammar rule coverage tracking
|
|
214
|
+
#ratio # raw coverage (rules expanded / reachable)
|
|
215
|
+
#valid_ratio # valid coverage (rules in valid programs / reachable)
|
|
216
|
+
#uncovered_valid_rules # rules not yet seen in valid programs
|
|
217
|
+
|
|
218
|
+
Profiles (provide fitness, validator, session factory):
|
|
219
|
+
Prism # validates with ::Prism.parse
|
|
220
|
+
RubyVM # validates with ::RubyVM::InstructionSequence.compile_parsey
|
|
221
|
+
Json # validates with JSON.parse
|
|
222
|
+
|
|
223
|
+
Ruby # shared Ruby grammar infrastructure (classifier, composer)
|
|
224
|
+
Shrinker # delta debugging minimizer
|
|
225
|
+
Joiner # token spacing/joining
|
|
226
|
+
Evolver # genome-based evolutionary optimization
|
|
227
|
+
ComposedEvolver # evolutionary optimization with composer
|
|
228
|
+
```
|
data/Rakefile
ADDED
data/examples/json.y
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
%token STRING NUMBER TRUE FALSE NULL
|
|
2
|
+
|
|
3
|
+
%%
|
|
4
|
+
|
|
5
|
+
value: object
|
|
6
|
+
| array
|
|
7
|
+
| STRING
|
|
8
|
+
| NUMBER
|
|
9
|
+
| TRUE
|
|
10
|
+
| FALSE
|
|
11
|
+
| NULL
|
|
12
|
+
;
|
|
13
|
+
|
|
14
|
+
object: '{' '}'
|
|
15
|
+
| '{' members '}'
|
|
16
|
+
;
|
|
17
|
+
|
|
18
|
+
members: pair
|
|
19
|
+
| members ',' pair
|
|
20
|
+
;
|
|
21
|
+
|
|
22
|
+
pair: STRING ':' value
|
|
23
|
+
;
|
|
24
|
+
|
|
25
|
+
array: '[' ']'
|
|
26
|
+
| '[' elements ']'
|
|
27
|
+
;
|
|
28
|
+
|
|
29
|
+
elements: value
|
|
30
|
+
| elements ',' value
|
|
31
|
+
;
|
data/examples/lists.y
ADDED
data/exe/lrama-fuzz
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "lrama/fuzz"
|
|
7
|
+
|
|
8
|
+
module Lrama
|
|
9
|
+
module Fuzz
|
|
10
|
+
module CLI
|
|
11
|
+
def self.run(argv = ARGV)
|
|
12
|
+
options = {
|
|
13
|
+
mode: :generate,
|
|
14
|
+
count: 10,
|
|
15
|
+
generations: 10,
|
|
16
|
+
population_size: 50,
|
|
17
|
+
seed: nil,
|
|
18
|
+
profile: :prism,
|
|
19
|
+
ruby_src_dir: ENV["RUBY_SRC_DIR"],
|
|
20
|
+
grammar_path: nil
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
parser = ::OptionParser.new do |opts|
|
|
24
|
+
opts.banner = "Usage: lrama-fuzz [options]"
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Generates programs from lrama grammars."
|
|
27
|
+
opts.separator ""
|
|
28
|
+
|
|
29
|
+
opts.on("--profile PROFILE", %i[prism rubyvm json],
|
|
30
|
+
"Profile: prism, rubyvm, json (default: prism)") do |p|
|
|
31
|
+
options[:profile] = p
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
opts.on("-d", "--ruby-src-dir DIR", "Path to Ruby source (default: $RUBY_SRC_DIR)") do |dir|
|
|
35
|
+
options[:ruby_src_dir] = dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on("--grammar-path PATH", "Path to grammar file (json only)") do |path|
|
|
39
|
+
options[:grammar_path] = path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("-n", "--count N", Integer, "Number of programs to generate (default: 10)") do |n|
|
|
43
|
+
options[:count] = n
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("-m", "--mode MODE", %i[generate compose evolve coverage guided],
|
|
47
|
+
"Mode: generate, compose, evolve, coverage, guided (default: generate)") do |mode|
|
|
48
|
+
options[:mode] = mode
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
opts.on("-g", "--generations N", Integer, "Generations for evolve mode (default: 10)") do |n|
|
|
52
|
+
options[:generations] = n
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.on("-p", "--population N", Integer, "Population size for evolve mode (default: 50)") do |n|
|
|
56
|
+
options[:population_size] = n
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("-s", "--seed N", Integer, "Random seed for reproducibility") do |n|
|
|
60
|
+
options[:seed] = n
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("-h", "--help", "Show this help") do
|
|
64
|
+
puts opts
|
|
65
|
+
exit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
parser.parse!(argv)
|
|
70
|
+
session = build_session(options)
|
|
71
|
+
|
|
72
|
+
case options[:mode]
|
|
73
|
+
when :generate
|
|
74
|
+
emit(options[:count]) { session.generate }
|
|
75
|
+
when :compose
|
|
76
|
+
emit(options[:count]) { session.compose }
|
|
77
|
+
when :evolve
|
|
78
|
+
run_evolve(session, options)
|
|
79
|
+
when :coverage
|
|
80
|
+
run_coverage(session, options)
|
|
81
|
+
when :guided
|
|
82
|
+
run_guided(session, options)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.build_session(options)
|
|
87
|
+
case options[:profile]
|
|
88
|
+
when :prism, :rubyvm
|
|
89
|
+
unless options[:ruby_src_dir]
|
|
90
|
+
$stderr.puts "Error: Ruby source directory required."
|
|
91
|
+
$stderr.puts "Set RUBY_SRC_DIR or pass --ruby-src-dir."
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless File.exist?(File.join(options[:ruby_src_dir], "parse.y"))
|
|
96
|
+
$stderr.puts "Error: #{options[:ruby_src_dir]}/parse.y not found."
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
$stderr.puts "Loading Ruby grammar (#{options[:profile]})..."
|
|
101
|
+
session = Fuzz.public_send(options[:profile],
|
|
102
|
+
ruby_src_dir: options[:ruby_src_dir],
|
|
103
|
+
seed: options[:seed])
|
|
104
|
+
$stderr.puts "Ready."
|
|
105
|
+
session
|
|
106
|
+
when :json
|
|
107
|
+
$stderr.puts "Loading JSON grammar..."
|
|
108
|
+
kwargs = { seed: options[:seed] }
|
|
109
|
+
kwargs[:grammar_path] = options[:grammar_path] if options[:grammar_path]
|
|
110
|
+
session = Fuzz.json(**kwargs)
|
|
111
|
+
$stderr.puts "Ready."
|
|
112
|
+
session
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.emit(count)
|
|
117
|
+
count.times do
|
|
118
|
+
puts yield
|
|
119
|
+
puts "---"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.run_evolve(session, options)
|
|
124
|
+
$stderr.puts "Evolving #{options[:population_size]} programs for #{options[:generations]} generations..."
|
|
125
|
+
best = session.evolve(
|
|
126
|
+
options[:generations],
|
|
127
|
+
population_size: options[:population_size]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
best.each do |code, fitness|
|
|
131
|
+
puts "# fitness: #{fitness.round(4)}"
|
|
132
|
+
puts code
|
|
133
|
+
puts "---"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
$stderr.puts "Best fitness: #{best.first[1].round(4)}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.run_coverage(session, options)
|
|
140
|
+
$stderr.puts "Generating programs to measure coverage..."
|
|
141
|
+
session.generate_full_coverage(max_attempts: options[:count])
|
|
142
|
+
|
|
143
|
+
cov = session.coverage
|
|
144
|
+
$stderr.puts "Coverage: #{cov.covered_count}/#{cov.total_count} rules (#{(cov.ratio * 100).round(1)}%)"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.run_guided(session, options)
|
|
148
|
+
$stderr.puts "Generating #{options[:count]} programs with validity feedback..."
|
|
149
|
+
valid_count = 0
|
|
150
|
+
|
|
151
|
+
session.generate_guided(count: options[:count]) do |code, valid|
|
|
152
|
+
if valid
|
|
153
|
+
valid_count += 1
|
|
154
|
+
puts code
|
|
155
|
+
puts "---"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
cov = session.coverage
|
|
160
|
+
$stderr.puts "Valid: #{valid_count}/#{options[:count]}"
|
|
161
|
+
$stderr.puts "Raw coverage: #{cov.covered_count}/#{cov.total_count} (#{(cov.ratio * 100).round(1)}%)"
|
|
162
|
+
$stderr.puts "Valid coverage: #{(cov.valid_ratio * 100).round(1)}%"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private_class_method :build_session
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Lrama::Fuzz::CLI.run
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lrama
|
|
4
|
+
module Fuzz
|
|
5
|
+
# A Random-compatible object that reads values from a codon (integer)
|
|
6
|
+
# sequence. This allows deterministic replay and evolution of any code
|
|
7
|
+
# path that uses Random — in particular, the Composer's template and
|
|
8
|
+
# fragment selection.
|
|
9
|
+
#
|
|
10
|
+
# Wraps around when the sequence is exhausted, so any genome length
|
|
11
|
+
# can drive arbitrarily many decisions.
|
|
12
|
+
class CodonRandom
|
|
13
|
+
def initialize(codons)
|
|
14
|
+
@codons = codons
|
|
15
|
+
@index = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Compatible with Random#rand:
|
|
19
|
+
# rand() -> Float in [0, 1)
|
|
20
|
+
# rand(n) -> Integer in [0, n)
|
|
21
|
+
# rand(a..b) -> Integer in [a, b]
|
|
22
|
+
def rand(max = nil)
|
|
23
|
+
codon = next_codon
|
|
24
|
+
|
|
25
|
+
case max
|
|
26
|
+
when nil
|
|
27
|
+
codon.to_f / GeneticOperators::MAX_CODON
|
|
28
|
+
when Integer
|
|
29
|
+
max == 0 ? 0 : codon % max
|
|
30
|
+
when Range
|
|
31
|
+
min_val = max.min
|
|
32
|
+
span = max.max - min_val + (max.exclude_end? ? 0 : 1)
|
|
33
|
+
span <= 0 ? min_val : min_val + (codon % span)
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "unexpected argument: #{max.inspect}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def next_codon
|
|
42
|
+
codon = @codons[@index % @codons.size]
|
|
43
|
+
@index += 1
|
|
44
|
+
codon
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lrama
|
|
4
|
+
module Fuzz
|
|
5
|
+
# Evolves genomes that drive Composer template and fragment selection
|
|
6
|
+
# via CodonRandom. Unlike the grammar-level Evolver, every genome here
|
|
7
|
+
# produces a structurally valid program (the Composer enforces that),
|
|
8
|
+
# so evolution optimizes for fitness (complexity, diversity) rather
|
|
9
|
+
# than basic validity.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# generator = Generator.new(grammar, terminals: ..., ...)
|
|
13
|
+
# evolver = ComposedEvolver.new(generator, fitness: Ruby.fitness)
|
|
14
|
+
# results = evolver.evolve # => [[code, fitness], ...]
|
|
15
|
+
class ComposedEvolver
|
|
16
|
+
include GeneticOperators
|
|
17
|
+
|
|
18
|
+
DEFAULT_POPULATION_SIZE = 50
|
|
19
|
+
DEFAULT_GENOME_LENGTH = 300
|
|
20
|
+
DEFAULT_MUTATION_RATE = 0.05
|
|
21
|
+
|
|
22
|
+
attr_reader :generation, :best_fitness
|
|
23
|
+
|
|
24
|
+
def initialize(generator, fitness:, composer_class: Ruby::Composer, validator: nil, population_size: DEFAULT_POPULATION_SIZE, genome_length: DEFAULT_GENOME_LENGTH, mutation_rate: DEFAULT_MUTATION_RATE, random: Random.new)
|
|
25
|
+
@generator = generator
|
|
26
|
+
@fitness = fitness
|
|
27
|
+
@composer_class = composer_class
|
|
28
|
+
@validator = validator
|
|
29
|
+
@population_size = population_size
|
|
30
|
+
@genome_length = genome_length
|
|
31
|
+
@mutation_rate = mutation_rate
|
|
32
|
+
@random = random
|
|
33
|
+
@generation = 0
|
|
34
|
+
@best_fitness = 0.0
|
|
35
|
+
@last_evaluated = nil
|
|
36
|
+
|
|
37
|
+
@population = Array.new(population_size) { random_genome }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run one generation: evaluate, select, crossover, mutate.
|
|
41
|
+
# Returns an array of [code, fitness] pairs.
|
|
42
|
+
def evolve
|
|
43
|
+
evaluated = @population.map do |genome|
|
|
44
|
+
code = generate_from_genome(genome)
|
|
45
|
+
score = @fitness.call(code)
|
|
46
|
+
Individual.new(genome: genome, code: code, fitness: score)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
evaluated.sort_by! { |ind| -ind.fitness }
|
|
50
|
+
@best_fitness = evaluated.first.fitness
|
|
51
|
+
|
|
52
|
+
new_population = []
|
|
53
|
+
|
|
54
|
+
# Elitism: keep top 20%
|
|
55
|
+
elite_count = [@population_size / 5, 1].max
|
|
56
|
+
new_population.concat(evaluated.first(elite_count).map(&:genome))
|
|
57
|
+
|
|
58
|
+
# Fill rest with crossover + mutation
|
|
59
|
+
while new_population.size < @population_size
|
|
60
|
+
p1 = tournament_select(evaluated)
|
|
61
|
+
p2 = tournament_select(evaluated)
|
|
62
|
+
child = crossover(p1, p2)
|
|
63
|
+
mutate!(child)
|
|
64
|
+
new_population << child
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@population = new_population.first(@population_size)
|
|
68
|
+
@last_evaluated = evaluated
|
|
69
|
+
@generation += 1
|
|
70
|
+
|
|
71
|
+
evaluated.map { |ind| [ind.code, ind.fitness] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Generate a program from a genome by driving the Composer with
|
|
75
|
+
# a CodonRandom seeded from the genome.
|
|
76
|
+
def generate_from_genome(genome)
|
|
77
|
+
codon_random = CodonRandom.new(genome)
|
|
78
|
+
composer = @composer_class.new(@generator, random: codon_random, validator: @validator)
|
|
79
|
+
composer.generate
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Return the best programs from the current population.
|
|
83
|
+
def best_programs(n = 10)
|
|
84
|
+
evaluated = @last_evaluated || @population.map do |genome|
|
|
85
|
+
code = generate_from_genome(genome)
|
|
86
|
+
score = @fitness.call(code)
|
|
87
|
+
Individual.new(genome: genome, code: code, fitness: score)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
evaluated.sort_by { |ind| -ind.fitness }.first(n).map { |ind| [ind.code, ind.fitness] }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|