Skip to content

Commit 5d9699c

Browse files
dsblankclaude
andcommitted
Add differential fuzzer for Phase 2/JIT; fix odd? inlining bug it found
tests/test_jit_fuzz.py generates many random small Scheme programs (_scheme_fuzz_gen.py) targeting the exact constructs _eval_direct/ _JitCompiler/_phase2_safe_walk implement, and runs each one under the same three execution modes test_phase2_safety.py already uses (fast / slow-trampoline / phase2-only), requiring the exact value or exception to match -- extending that file's fixed hand-written test_all.ss cases with broad randomized coverage of the same failure class. It immediately found a real bug: _JitCompiler inlined `odd?` as a raw `{0} % 2 != 0`, which diverges from the real primitive (`n % 2 == 1`) for any non-integer argument -- e.g. (odd? 2.5) returned #t from JIT-compiled code and #f everywhere else. even?/zero?/not/abs were checked against their real primitives at the same time and confirmed already exact matches. Fixed in Scheme.py's _UNARY table and regenerated scheme.py; tests/test_jit_odd_float.py pins the regression. Also fixes a fuzzer-only bug found along the way: the tail-recursion case generator could produce an accumulator step that multiplies itself by itself, which under repeated squaring across up to 200 iterations causes doubly-exponential bignum growth and stalls a run for minutes -- restricted that case's step to a bounded linear update instead. Validated with 7 different seeds (~11,000 generated programs total) finding zero further mismatches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d942e04 commit 5d9699c

6 files changed

Lines changed: 543 additions & 2 deletions

File tree

calysto_scheme/scheme.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1341,7 +1341,13 @@ class _JitCompiler:
13411341
'not': '({0} is False)',
13421342
'zero?': '({0} == 0)',
13431343
'even?': '({0} % 2 == 0)',
1344-
'odd?': '({0} % 2 != 0)',
1344+
# Must match odd_q's real definition (`n % 2 == 1`) exactly, not
1345+
# `!= 0` -- the two agree for every integer (n % 2 is always 0 or
1346+
# 1 there) but silently diverge for a non-integer argument, e.g.
1347+
# 2.5 % 2 == 0.5: `!= 0` is True (wrongly "odd"), `== 1` is False
1348+
# (correct) -- found by tests/test_jit_fuzz.py's differential
1349+
# fuzzer, see tests/test_jit_odd_float.py.
1350+
'odd?': '({0} % 2 == 1)',
13451351
'car': '_j_safe_car({0})',
13461352
'cdr': '_j_safe_cdr({0})',
13471353
'abs': 'abs({0})',

calysto_scheme/src/Scheme.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,13 @@ class _JitCompiler:
13331333
'not': '({0} is False)',
13341334
'zero?': '({0} == 0)',
13351335
'even?': '({0} % 2 == 0)',
1336-
'odd?': '({0} % 2 != 0)',
1336+
# Must match odd_q's real definition (`n % 2 == 1`) exactly, not
1337+
# `!= 0` -- the two agree for every integer (n % 2 is always 0 or
1338+
# 1 there) but silently diverge for a non-integer argument, e.g.
1339+
# 2.5 % 2 == 0.5: `!= 0` is True (wrongly "odd"), `== 1` is False
1340+
# (correct) -- found by tests/test_jit_fuzz.py's differential
1341+
# fuzzer, see tests/test_jit_odd_float.py.
1342+
'odd?': '({0} % 2 == 1)',
13371343
'car': '_j_safe_car({0})',
13381344
'cdr': '_j_safe_cdr({0})',
13391345
'abs': 'abs({0})',

tests/_scheme_fuzz_gen.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""
2+
Random small-Scheme-program generator for the differential fuzz test
3+
(test_jit_fuzz.py). Pure Python, no dependency on calysto_scheme -- the
4+
same seed always produces the same list of case sources, in this process
5+
or a fresh subprocess, so results can be regenerated for reporting without
6+
having to round-trip them through the interpreter.
7+
8+
Deliberately narrow in scope: every construct used here is one
9+
_eval_direct / _JitCompiler / _phase2_safe_walk explicitly claims to
10+
handle (see test_jit_tag_parity.py) -- arithmetic, comparisons, `if`,
11+
self/mutual recursion, closures returned from closures, a parameter
12+
called as a function, list ops, and `begin` (deliberately, since `begin`
13+
is the one documented, intentional gap between _eval_direct and
14+
_JitCompiler -- see this file's `begin_body` case and
15+
test_jit_tag_parity.py's module docstring). The goal isn't broad language
16+
coverage, it's many random *shapes* of the specific patterns the three
17+
walkers' bug history (README-PERFORMANCE.md's Phases 6-9) shows they can
18+
silently disagree about.
19+
20+
Every generated case is self-contained (its own uniquely-numbered
21+
function name(s), no shared mutable state) and constructed to always
22+
terminate quickly: recursive cases strictly decrease a bounded counter
23+
toward a base case, and argument magnitudes are kept small.
24+
"""
25+
import random
26+
27+
_NARY_OPS = ["+", "-", "*"]
28+
_CMP_OPS = ["<", ">", "<=", ">=", "="]
29+
_NUM_UNARY_OPS = ["not", "zero?", "even?", "odd?", "abs"]
30+
_NUM_LITERALS = [0, 1, -1, 2, -2, 3, 5, -5, 10, -10, 100,
31+
0.0, -0.0, 1.5, -1.5, 2.5, -2.5]
32+
33+
34+
def _lit_src(v):
35+
if v is True:
36+
return "#t"
37+
if v is False:
38+
return "#f"
39+
return repr(v)
40+
41+
42+
def _gen_num_expr(rng, params, depth):
43+
"""A pure numeric-or-boolean expression, using only params/literals
44+
and the ~9 primitives _JitCompiler inlines as raw Python
45+
operators/templates (_NARY/_CMP/_UNARY) -- the exact surface most
46+
likely to silently diverge between the inlined-operator JIT path and
47+
the general dispatch _eval_direct/the trampoline use instead."""
48+
if depth <= 0 or rng.random() < 0.35:
49+
if params and rng.random() < 0.6:
50+
return rng.choice(params)
51+
return _lit_src(rng.choice(_NUM_LITERALS))
52+
choice = rng.random()
53+
if choice < 0.10:
54+
# (- ) is a genuine arity error (see _JitCompiler._app's comment
55+
# on why `-` has no zero-arg identity element); occasionally
56+
# probe it directly rather than only ever generating well-formed
57+
# calls.
58+
return "(-)"
59+
if choice < 0.45:
60+
op = rng.choice(_NARY_OPS)
61+
n = rng.choice([1, 2, 2, 3])
62+
args = [_gen_num_expr(rng, params, depth - 1) for _ in range(n)]
63+
return f"({op} {' '.join(args)})"
64+
if choice < 0.65:
65+
op = rng.choice(_NUM_UNARY_OPS)
66+
return f"({op} {_gen_num_expr(rng, params, depth - 1)})"
67+
if choice < 0.85:
68+
test = _gen_bool_expr(rng, params, depth - 1)
69+
then_ = _gen_num_expr(rng, params, depth - 1)
70+
else_ = _gen_num_expr(rng, params, depth - 1)
71+
return f"(if {test} {then_} {else_})"
72+
# `begin` wrapping a discarded pure expression then a value -- probes
73+
# the documented begin_aexp gap: _JitCompiler must decline to compile
74+
# any function whose body contains one of these, anywhere, and fall
75+
# back to Phase 2/the trampoline instead, silently and correctly.
76+
junk = _gen_num_expr(rng, params, depth - 1)
77+
val = _gen_num_expr(rng, params, depth - 1)
78+
return f"(begin {junk} {val})"
79+
80+
81+
def _gen_bool_expr(rng, params, depth):
82+
if depth <= 0 or rng.random() < 0.3:
83+
return rng.choice(["#t", "#f"])
84+
a = _gen_num_expr(rng, params, depth - 1)
85+
b = _gen_num_expr(rng, params, depth - 1)
86+
op = rng.choice(_CMP_OPS)
87+
return f"({op} {a} {b})"
88+
89+
90+
def _rand_arg(rng, lo=0, hi=12):
91+
return rng.randint(lo, hi)
92+
93+
94+
def _case_simple_rec(rng, idx):
95+
"""Non-tail self-recursion (the `fib` shape) -- the most common
96+
recursive pattern, and the one Phase 7's self-call identity binding
97+
specifically optimizes."""
98+
fn = f"f_{idx}"
99+
base = rng.choice([0, 1])
100+
combine = rng.choice(_NARY_OPS)
101+
body_extra = _gen_num_expr(rng, [], 1)
102+
src = (
103+
f"(define ({fn} n)\n"
104+
f" (if (<= n {base}) {body_extra}\n"
105+
f" ({combine} ({fn} (- n 1)) ({fn} (- n 2)))))\n"
106+
f"({fn} {_rand_arg(rng, 0, 12)})"
107+
)
108+
return src
109+
110+
111+
def _case_tail_rec(rng, idx):
112+
"""Accumulator-style tail loop -- exercises Phase 4's tail-loop
113+
flattening (self-recursive tail call -> parameter reassignment +
114+
Python `continue`, both in _eval_direct and _JitCompiler.tail_stmts).
115+
116+
The accumulator update is deliberately restricted to a linear
117+
combination of `acc` and `n` (never `acc` multiplied against itself
118+
or another acc-derived expression) -- with up to 200 iterations,
119+
letting `_gen_num_expr`'s general `*` case multiply the carried `acc`
120+
by itself compounds into doubly-exponential bignum growth (repeated
121+
squaring), which stalled a real fuzz run for minutes on one single
122+
generated case before this was found and fixed. `n` itself is safe
123+
to multiply freely since it strictly decreases every iteration and
124+
is never fed back into itself."""
125+
fn = f"f_{idx}"
126+
op = rng.choice(["+", "-"])
127+
other = rng.choice(["n", _gen_num_expr(rng, ["n"], 1)])
128+
step = f"({op} acc {other})"
129+
src = (
130+
f"(define ({fn} n acc)\n"
131+
f" (if (<= n 0) acc\n"
132+
f" ({fn} (- n 1) {step})))\n"
133+
f"({fn} {_rand_arg(rng, 0, 200)} {_lit_src(rng.choice(_NUM_LITERALS))})"
134+
)
135+
return src
136+
137+
138+
def _case_mutual_rec(rng, idx):
139+
"""Mutual recursion -- per README-PERFORMANCE.md, this shape never
140+
successfully JIT-compiles (each function's compile attempt needs the
141+
other already resolved), so it's always retried through _eval_direct
142+
every call. Good coverage for the "never amortized, always hot"
143+
_eval_direct path specifically."""
144+
fe, fo = f"feven_{idx}", f"fodd_{idx}"
145+
src = (
146+
f"(define ({fe} n) (if (<= n 0) #t ({fo} (- n 1))))\n"
147+
f"(define ({fo} n) (if (<= n 0) #f ({fe} (- n 1))))\n"
148+
f"({fe} {_rand_arg(rng, 0, 30)})"
149+
)
150+
return src
151+
152+
153+
def _case_closure_factory(rng, idx):
154+
"""A function returning a freshly-built closure over one of its own
155+
parameters (the `make-adder` shape) -- Phase 5/_JitCompiler._lambda's
156+
reconstructed-frame machinery."""
157+
mk = f"make_{idx}"
158+
op = rng.choice(_NARY_OPS)
159+
k = _rand_arg(rng, -10, 10)
160+
x = _rand_arg(rng, -10, 10)
161+
src = (
162+
f"(define ({mk} k) (lambda (x) ({op} x k)))\n"
163+
f"(({mk} {k}) {x})"
164+
)
165+
return src
166+
167+
168+
def _case_param_as_op(rng, idx):
169+
"""A parameter called as a function (the `apply-twice` shape) --
170+
unreachable by the JIT/Phase 2 fast paths since Phase 8 (see
171+
JIT-OVERVIEW.md's Phase 6/8 discussion), but must still be *correct*,
172+
always falling all the way back."""
173+
ap, inc = f"apply2_{idx}", f"inc_{idx}"
174+
op = rng.choice(_NARY_OPS)
175+
k = _rand_arg(rng, -5, 5)
176+
x = _rand_arg(rng, -20, 20)
177+
src = (
178+
f"(define ({ap} f x) (f (f x)))\n"
179+
f"(define ({inc} x) ({op} x {k}))\n"
180+
f"({ap} {inc} {x})"
181+
)
182+
return src
183+
184+
185+
def _case_list_ops(rng, idx):
186+
"""Self-recursive list traversal (car/cdr/null?/pair? -- the
187+
_UNARY-inlined list predicates/accessors) over a freshly-built,
188+
guaranteed-proper list literal, so car/cdr never hit an empty list."""
189+
fn = f"sum_{idx}"
190+
n = rng.randint(0, 6)
191+
items = [_lit_src(rng.choice(_NUM_LITERALS)) for _ in range(n)]
192+
src = (
193+
f"(define ({fn} lst)\n"
194+
f" (if (null? lst) 0\n"
195+
f" (+ (car lst) ({fn} (cdr lst)))))\n"
196+
f"({fn} (list {' '.join(items)}))"
197+
)
198+
return src
199+
200+
201+
def _case_begin_body(rng, idx):
202+
"""A self-recursive function whose body is a top-level `begin` of
203+
several pure (discarded) sub-expressions before the tail `if` --
204+
structurally the exact shape _JitCompiler.expr has no case for at
205+
all, so every case of this shape must always, silently fall back to
206+
Phase 2/the trampoline. See test_jit_tag_parity.py."""
207+
fn = f"f_{idx}"
208+
junk1 = _gen_num_expr(rng, ["n"], 2)
209+
junk2 = _gen_num_expr(rng, ["n"], 2)
210+
base = rng.choice([0, 1])
211+
src = (
212+
f"(define ({fn} n)\n"
213+
f" (begin\n"
214+
f" {junk1}\n"
215+
f" {junk2}\n"
216+
f" (if (<= n {base}) n\n"
217+
f" (+ 1 ({fn} (- n 1))))))\n"
218+
f"({fn} {_rand_arg(rng, 0, 20)})"
219+
)
220+
return src
221+
222+
223+
def _case_arith_leaf(rng, idx):
224+
"""No function definitions at all -- just a single raw top-level
225+
expression, for broad structural coverage of inlined-operator fold
226+
order/negative-zero/arity-edge behavior without any recursion at
227+
all."""
228+
return _gen_num_expr(rng, [], 4)
229+
230+
231+
_CASE_KINDS = [
232+
_case_simple_rec,
233+
_case_tail_rec,
234+
_case_mutual_rec,
235+
_case_closure_factory,
236+
_case_param_as_op,
237+
_case_list_ops,
238+
_case_begin_body,
239+
_case_arith_leaf,
240+
]
241+
242+
243+
def gen_case(rng, idx):
244+
"""Return (kind_name, source) for case number `idx`, deterministic
245+
given rng's prior state -- callers must share one rng across the
246+
whole batch, in index order, to get a reproducible sequence."""
247+
kind = rng.choice(_CASE_KINDS)
248+
src = kind(rng, idx)
249+
return kind.__name__, src
250+
251+
252+
def gen_cases(seed, count):
253+
"""The full, ordered list of (kind_name, source) for a fuzz batch.
254+
Regenerating with the same (seed, count) always reproduces the exact
255+
same cases -- this is the single source of truth both the runner
256+
subprocess and the reporting test process call into."""
257+
rng = random.Random(seed)
258+
return [gen_case(rng, i) for i in range(count)]

tests/_scheme_fuzz_runner.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""
2+
Subprocess helper for test_jit_fuzz.py -- the differential-fuzzing
3+
counterpart to _phase2_diff_runner.py. Where that file replays the fixed,
4+
hand-written test_all.ss suite under three execution modes,
5+
this generates a large batch of small random Scheme programs
6+
(_scheme_fuzz_gen.gen_cases) and evaluates each one directly, once per
7+
mode, in a single process (so a batch of hundreds of cases costs one
8+
subprocess launch, not one per case -- state built by earlier cases in
9+
the batch, e.g. _jit_cache/_phase2_safe_cache/_fast_prim_map entries for
10+
their own distinct closures, is inert for later cases since every case
11+
uses fresh, uniquely-numbered function names).
12+
13+
Modes, identical meaning to _phase2_diff_runner.py:
14+
- "fast": normal -- Phase 2/JIT enabled, gated by _is_phase2_safe.
15+
- "slow": _is_phase2_safe monkeypatched to always return False,
16+
forcing every closure call through the register-machine
17+
trampoline -- the always-correct baseline.
18+
- "phase2only": _jit_compile_proc monkeypatched to a no-op, so every
19+
Phase-2-eligible closure runs through _eval_direct
20+
alone, _JitCompiler never engaging.
21+
22+
Prints one JSON line to stdout: a list of per-case outcomes, in case
23+
order, each one of:
24+
["value", <repr(result)>]
25+
["scheme-exception", <exception type str>, <message str>]
26+
["python-crash", <repr(exception)>]
27+
28+
"python-crash" (an uncaught Python-level exception escaping
29+
execute_string_rm itself, as opposed to a caught-and-wrapped Scheme
30+
exception) is deliberately captured per-case rather than left to crash
31+
the whole batch -- that would both lose every subsequent case's result
32+
and is itself exactly the kind of bug this fuzzer exists to find.
33+
"""
34+
import json
35+
import os
36+
import sys
37+
import warnings
38+
39+
mode = sys.argv[1]
40+
seed = int(sys.argv[2])
41+
count = int(sys.argv[3])
42+
assert mode in ("fast", "slow", "phase2only")
43+
44+
# The JIT deliberately compiles `(not <int-or-other-non-bool>)` to a raw
45+
# Python `<expr> is False` (see _JitCompiler._UNARY) -- semantically
46+
# correct for any object compared against the False singleton, but CPython
47+
# warns on it when <expr> is a literal int. Expected, not a bug; silenced
48+
# so it doesn't look like fuzzer-discovered noise in CI output.
49+
warnings.filterwarnings("ignore", category=SyntaxWarning)
50+
51+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
52+
from _scheme_fuzz_gen import gen_cases # noqa: E402
53+
54+
import calysto_scheme.scheme as scheme # noqa: E402
55+
56+
if mode == "slow":
57+
scheme._is_phase2_safe = lambda proc, _visiting=None: False
58+
elif mode == "phase2only":
59+
scheme._jit_compile_proc = lambda proc: None
60+
61+
results = []
62+
for kind, src in gen_cases(seed, count):
63+
try:
64+
result = scheme.execute_string_rm(src)
65+
except Exception as e:
66+
results.append(["python-crash", repr(e)])
67+
continue
68+
if scheme.exception_q(result):
69+
exc_obj = result.cdr.car
70+
etype = exc_obj.cdr.car
71+
emsg = exc_obj.cdr.cdr.car
72+
results.append(["scheme-exception", str(etype), str(emsg)])
73+
else:
74+
results.append(["value", repr(result)])
75+
76+
print(json.dumps(results))

0 commit comments

Comments
 (0)