gameability 0.2.0__py3-none-any.whl
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.
- gameability/__init__.py +19 -0
- gameability/__main__.py +5 -0
- gameability/_worker.py +87 -0
- gameability/cli.py +135 -0
- gameability/engine.py +275 -0
- gameability/harden.py +206 -0
- gameability/loaders.py +290 -0
- gameability/mutate.py +155 -0
- gameability/oracle.py +232 -0
- gameability-0.2.0.dist-info/METADATA +162 -0
- gameability-0.2.0.dist-info/RECORD +15 -0
- gameability-0.2.0.dist-info/WHEEL +5 -0
- gameability-0.2.0.dist-info/entry_points.txt +2 -0
- gameability-0.2.0.dist-info/licenses/LICENSE +21 -0
- gameability-0.2.0.dist-info/top_level.txt +1 -0
gameability/harden.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""FIND, THEN FIX. Turn the audit into a repair loop.
|
|
2
|
+
|
|
3
|
+
THE INSIGHT
|
|
4
|
+
The census already computes the repair and discards it. A mutant is certified
|
|
5
|
+
"genuinely broken" by finding a hidden input where it and the reference
|
|
6
|
+
disagree. That input, paired with the reference's output, IS the assertion that
|
|
7
|
+
would have killed it. Emitting it instead of dropping it converts a report into
|
|
8
|
+
a patch.
|
|
9
|
+
|
|
10
|
+
So hardening is nearly free: audit, collect the witnesses for every mutant that
|
|
11
|
+
survived, append them as new asserts, repeat. Each round the suite gets
|
|
12
|
+
strictly stronger, and it stops when survival stops falling.
|
|
13
|
+
|
|
14
|
+
THE CONTROL THAT MAKES IT HONEST, AND WITHOUT WHICH THE NUMBER IS A LIE
|
|
15
|
+
Hardening against a set of mutants and then measuring on THAT SAME SET drives
|
|
16
|
+
survival to zero by construction -- we literally wrote the tests that kill
|
|
17
|
+
them. That is not a result, it is a tautology.
|
|
18
|
+
|
|
19
|
+
So mutants are split. The TRAIN half generates the patches. The TEST half is
|
|
20
|
+
never seen during hardening and is what gets reported. The question that
|
|
21
|
+
actually matters is whether tests written to kill one sample of adversaries
|
|
22
|
+
also kill unseen ones:
|
|
23
|
+
|
|
24
|
+
test-set survival falls -> hardening GENERALISES; the loop makes a
|
|
25
|
+
benchmark genuinely stronger and the product
|
|
26
|
+
is real
|
|
27
|
+
only train-set survival
|
|
28
|
+
falls -> we are memorising specific mutants; worthless
|
|
29
|
+
|
|
30
|
+
This is the same held-out logic as heldout_primitives, one level up: there we
|
|
31
|
+
withheld primitives from training, here we withhold adversaries from hardening.
|
|
32
|
+
|
|
33
|
+
Correctness of the patches is free: every emitted assert is the REFERENCE's own
|
|
34
|
+
behaviour on that input, so a hardened suite can never reject a correct
|
|
35
|
+
solution. The suite only ever gets more specific about what it already meant.
|
|
36
|
+
"""
|
|
37
|
+
import copy
|
|
38
|
+
|
|
39
|
+
from . import engine, mutate, oracle
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def witnesses(problem, n_mutants=24, n_hidden=8, seed=0, k_sites=1, timeout=40):
|
|
43
|
+
"""Audit one problem and return the asserts that would kill its survivors.
|
|
44
|
+
|
|
45
|
+
Returns (n_broken, n_survived, [assert strings]).
|
|
46
|
+
"""
|
|
47
|
+
res = engine.audit(problem, n_mutants=n_mutants, n_hidden=n_hidden,
|
|
48
|
+
seed=seed, k_sites=k_sites, timeout=timeout,
|
|
49
|
+
want_witness=True, mutant_offset=0, mutant_stride=2)
|
|
50
|
+
if res["status"] != "ok":
|
|
51
|
+
return 0, 0, []
|
|
52
|
+
out, seen = [], set()
|
|
53
|
+
for w in res.get("witness", []):
|
|
54
|
+
# w = (call_args_string, reference_output_repr)
|
|
55
|
+
a = "assert %s(%s) == %s" % (problem.entry, w[0], w[1])
|
|
56
|
+
if a not in seen:
|
|
57
|
+
seen.add(a)
|
|
58
|
+
out.append(a)
|
|
59
|
+
return res["n_broken"], res["n_survived"], out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def perturbation_tests(problem, k, seed=0):
|
|
63
|
+
"""`k` fresh asserts, each the REFERENCE's own output on a perturbed input.
|
|
64
|
+
|
|
65
|
+
Unlimited supply and correct by construction. The reference is executed once
|
|
66
|
+
per input; any input on which it raises is skipped rather than guessed at,
|
|
67
|
+
so no assert here can ever reject a correct solution.
|
|
68
|
+
"""
|
|
69
|
+
if k <= 0:
|
|
70
|
+
return []
|
|
71
|
+
_, calls = oracle.extract_calls(problem.tests, problem.call_alias)
|
|
72
|
+
fresh = oracle.fresh_calls(calls, n=k * 3, seed=seed)
|
|
73
|
+
if not fresh:
|
|
74
|
+
return []
|
|
75
|
+
recs = engine._run({"ref": problem.reference, "mutants": [],
|
|
76
|
+
"setup": problem.setup, "tests": problem.tests,
|
|
77
|
+
"fn": problem.entry, "fresh": fresh}, 60)
|
|
78
|
+
ref = recs.get("ref")
|
|
79
|
+
if not ref or not ref.get("loaded"):
|
|
80
|
+
return []
|
|
81
|
+
out, seen = [], set(problem.tests)
|
|
82
|
+
for args, res in zip(fresh, ref["hidden"]):
|
|
83
|
+
if len(out) >= k:
|
|
84
|
+
break
|
|
85
|
+
if res[0] != "ok":
|
|
86
|
+
continue # reference raised -- skip, never guess
|
|
87
|
+
a = "assert %s(%s) == %s" % (problem.entry, args, res[1])
|
|
88
|
+
if a not in seen:
|
|
89
|
+
seen.add(a)
|
|
90
|
+
out.append(a)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def harden(problem, rounds=3, n_mutants=24, seed=0, verbose=False,
|
|
95
|
+
target_tests=0):
|
|
96
|
+
"""Iteratively strengthen a problem's test suite.
|
|
97
|
+
|
|
98
|
+
TRAIN mutants (seed) generate the patches; TEST mutants (seed+500) are held
|
|
99
|
+
out and are the only thing reported. Returns a dict of the trajectory.
|
|
100
|
+
"""
|
|
101
|
+
cur = copy.copy(problem)
|
|
102
|
+
cur.tests = list(problem.tests)
|
|
103
|
+
|
|
104
|
+
def measure(p, offset):
|
|
105
|
+
# offset 0 = TRAIN, the mutants patches were written against.
|
|
106
|
+
# offset 1 = TEST, never seen during hardening. Only TEST is reported.
|
|
107
|
+
r = engine.audit(p, n_mutants=n_mutants, seed=seed,
|
|
108
|
+
mutant_offset=offset, mutant_stride=2)
|
|
109
|
+
if r["status"] != "ok" or r["n_broken"] == 0:
|
|
110
|
+
return None
|
|
111
|
+
return r["n_survived"] / r["n_broken"]
|
|
112
|
+
|
|
113
|
+
hist = {"pid": problem.pid, "train": [], "test": [], "n_tests": [],
|
|
114
|
+
"added": []}
|
|
115
|
+
for rd in range(rounds + 1):
|
|
116
|
+
tr = measure(cur, 0)
|
|
117
|
+
te = measure(cur, 1) # NEVER used to generate patches
|
|
118
|
+
hist["train"].append(tr)
|
|
119
|
+
hist["test"].append(te)
|
|
120
|
+
hist["n_tests"].append(len(cur.tests))
|
|
121
|
+
if verbose:
|
|
122
|
+
print(" round %d tests=%-3d train=%s test=%s"
|
|
123
|
+
% (rd, len(cur.tests),
|
|
124
|
+
"n/a" if tr is None else "%.3f" % tr,
|
|
125
|
+
"n/a" if te is None else "%.3f" % te))
|
|
126
|
+
if rd == rounds:
|
|
127
|
+
break
|
|
128
|
+
_, surv, new = witnesses(cur, n_mutants=n_mutants, seed=seed)
|
|
129
|
+
new = [a for a in new if a not in cur.tests]
|
|
130
|
+
|
|
131
|
+
# TOP UP with perturbation tests. Witnesses run out -- they are bounded
|
|
132
|
+
# by the mutant sample -- and when they do the loop used to halt with
|
|
133
|
+
# the held-out half still leaking. Perturbation inputs are unlimited and
|
|
134
|
+
# the floor-fix curve shows they carry survival from 0.060 to 0.004 on
|
|
135
|
+
# their own. Targeted first, then bulk.
|
|
136
|
+
if target_tests and len(cur.tests) + len(new) < target_tests:
|
|
137
|
+
need = target_tests - len(cur.tests) - len(new)
|
|
138
|
+
new += perturbation_tests(cur, need, seed=seed + 100 * rd)
|
|
139
|
+
|
|
140
|
+
hist["added"].append(len(new))
|
|
141
|
+
if not new:
|
|
142
|
+
# neither source produced anything: witnesses exhausted AND the
|
|
143
|
+
# suite is already at its target size
|
|
144
|
+
break
|
|
145
|
+
cur.tests = cur.tests + new
|
|
146
|
+
hist["final_tests"] = list(cur.tests)
|
|
147
|
+
hist["hardened"] = cur
|
|
148
|
+
return hist
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _carry(seq, depth):
|
|
152
|
+
"""Extend a trajectory to `depth` by repeating its last value.
|
|
153
|
+
|
|
154
|
+
A problem that ran out of survivors to patch STOPPED because it converged.
|
|
155
|
+
It is at that value for every later round, not missing from them. Averaging
|
|
156
|
+
column-wise without this computes each round over only the problems still
|
|
157
|
+
running -- which are exactly the ones with more left to fix -- and makes
|
|
158
|
+
hardening look better the longer it goes.
|
|
159
|
+
"""
|
|
160
|
+
if not seq:
|
|
161
|
+
return [None] * depth
|
|
162
|
+
out = list(seq)
|
|
163
|
+
while len(out) < depth:
|
|
164
|
+
out.append(out[-1])
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def summarise_harden(rows):
|
|
169
|
+
"""Aggregate trajectories; the TEST columns are the result.
|
|
170
|
+
|
|
171
|
+
Every column averages the SAME population, by carrying converged problems
|
|
172
|
+
forward rather than dropping them.
|
|
173
|
+
"""
|
|
174
|
+
n = len(rows)
|
|
175
|
+
depth = max((len(r["test"]) for r in rows), default=0)
|
|
176
|
+
out = {"problems": n, "rounds": depth, "train": [], "test": [],
|
|
177
|
+
"tests": [], "n_scored": [], "dropped_unmatched": 0}
|
|
178
|
+
# MATCHED POPULATION. A problem with no genuinely-broken mutant at round 0
|
|
179
|
+
# scores None there, and can acquire scorable mutants later once the suite
|
|
180
|
+
# grows -- so the population drifts (53 -> 55 in one run) and the columns
|
|
181
|
+
# stop being comparable. Restrict to problems scorable at EVERY round.
|
|
182
|
+
def _full(r):
|
|
183
|
+
c = _carry(r["test"], depth)
|
|
184
|
+
return all(v is not None for v in c)
|
|
185
|
+
|
|
186
|
+
matched = [r for r in rows if _full(r)]
|
|
187
|
+
dropped = len(rows) - len(matched)
|
|
188
|
+
rows = matched
|
|
189
|
+
|
|
190
|
+
tr_all = [_carry(r["train"], depth) for r in rows]
|
|
191
|
+
te_all = [_carry(r["test"], depth) for r in rows]
|
|
192
|
+
nt_all = [_carry(r["n_tests"], depth) for r in rows]
|
|
193
|
+
for i in range(depth):
|
|
194
|
+
tr = [c[i] for c in tr_all if c[i] is not None]
|
|
195
|
+
te = [c[i] for c in te_all if c[i] is not None]
|
|
196
|
+
nt = [c[i] for c in nt_all if c[i] is not None]
|
|
197
|
+
out["train"].append(sum(tr) / len(tr) if tr else None)
|
|
198
|
+
out["test"].append(sum(te) / len(te) if te else None)
|
|
199
|
+
out["tests"].append(sum(nt) / len(nt) if nt else None)
|
|
200
|
+
out["n_scored"].append(len(te))
|
|
201
|
+
# a column scored over fewer problems than another is not comparable
|
|
202
|
+
scored = set(out["n_scored"])
|
|
203
|
+
out["population_stable"] = len(scored) <= 1
|
|
204
|
+
out["dropped_unmatched"] = dropped
|
|
205
|
+
out["added_total"] = sum(sum(r["added"]) for r in rows)
|
|
206
|
+
return out
|
gameability/loaders.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Turn a benchmark into a list of Problems.
|
|
2
|
+
|
|
3
|
+
Three sources:
|
|
4
|
+
* jsonl -- your own benchmark, one problem per line (format in the README)
|
|
5
|
+
* mbpp -- Google's MBPP, "full" config, via Hugging Face `datasets`
|
|
6
|
+
* humaneval -- OpenAI's HumanEval, via Hugging Face `datasets`
|
|
7
|
+
|
|
8
|
+
Every loader returns (problems, dropped) so the caller can say how many records
|
|
9
|
+
never reached the audit instead of silently shrinking the denominator.
|
|
10
|
+
"""
|
|
11
|
+
import ast
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from .engine import Problem
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _defined_functions(src):
|
|
18
|
+
try:
|
|
19
|
+
tree = ast.parse(src)
|
|
20
|
+
except SyntaxError:
|
|
21
|
+
return []
|
|
22
|
+
return [n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _called_names(tests):
|
|
26
|
+
out = []
|
|
27
|
+
for t in tests:
|
|
28
|
+
try:
|
|
29
|
+
tree = ast.parse(t.strip())
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
continue
|
|
32
|
+
out += [n.func.id for n in ast.walk(tree)
|
|
33
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _entry(reference, tests):
|
|
38
|
+
"""The function the reference DEFINES and the tests CALL.
|
|
39
|
+
|
|
40
|
+
Taking the most-called name instead would pick `set` or `len` out of an
|
|
41
|
+
assert like `assert set(f(x)) == set(y)`, and the audit would then measure
|
|
42
|
+
nothing at all while still reporting a number.
|
|
43
|
+
"""
|
|
44
|
+
defined, called = _defined_functions(reference), _called_names(tests)
|
|
45
|
+
return next((c for c in called if c in defined), None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_jsonl(path):
|
|
49
|
+
problems, dropped = [], 0
|
|
50
|
+
with open(path, encoding="utf-8") as f:
|
|
51
|
+
for n, line in enumerate(f, 1):
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
r = json.loads(line)
|
|
56
|
+
for key in ("reference", "tests"):
|
|
57
|
+
if key not in r:
|
|
58
|
+
raise SystemExit("%s line %d: missing required field %r" % (path, n, key))
|
|
59
|
+
tests = r["tests"] if isinstance(r["tests"], list) else [r["tests"]]
|
|
60
|
+
entry = r.get("entry") or _entry(r["reference"], tests)
|
|
61
|
+
if not entry:
|
|
62
|
+
dropped += 1
|
|
63
|
+
continue
|
|
64
|
+
problems.append(Problem(pid=str(r.get("id", n)), reference=r["reference"], tests=tests,
|
|
65
|
+
setup=r.get("setup", ""), entry=entry,
|
|
66
|
+
hidden=list(r.get("hidden", [])),
|
|
67
|
+
call_alias=r.get("call_alias")))
|
|
68
|
+
return problems, dropped
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_dataset(*args):
|
|
72
|
+
try:
|
|
73
|
+
from datasets import load_dataset
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise SystemExit("this loader needs Hugging Face datasets: pip install 'gameability[hf]'")
|
|
76
|
+
return load_dataset(*args)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mbpp():
|
|
80
|
+
"""MBPP 'full': `code` is the reference, `test_list` the visible suite, and
|
|
81
|
+
`challenge_test_list` -- where it exists -- a held-out suite the benchmark
|
|
82
|
+
already has, credited as extra hidden inputs rather than ignored."""
|
|
83
|
+
ds = _load_dataset("google-research-datasets/mbpp", "full")
|
|
84
|
+
# The 'prompt' split is the 10 few-shot examples, not the benchmark.
|
|
85
|
+
rows = [r for name, split in ds.items() if name != "prompt" for r in split]
|
|
86
|
+
problems, dropped = [], 0
|
|
87
|
+
for r in rows:
|
|
88
|
+
tests = list(r.get("test_list") or [])
|
|
89
|
+
entry = _entry(r["code"], tests) if tests and r.get("code") else None
|
|
90
|
+
if entry is None:
|
|
91
|
+
dropped += 1
|
|
92
|
+
continue
|
|
93
|
+
problems.append(Problem(pid=str(r["task_id"]), reference=r["code"], tests=tests,
|
|
94
|
+
setup=r.get("test_setup_code") or "", entry=entry,
|
|
95
|
+
hidden=list(r.get("challenge_test_list") or [])))
|
|
96
|
+
return problems, dropped
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def humaneval():
|
|
100
|
+
"""HumanEval's `test` defines check(candidate); calling it is the suite."""
|
|
101
|
+
rows = _load_dataset("openai/openai_humaneval")["test"]
|
|
102
|
+
problems = []
|
|
103
|
+
for r in rows:
|
|
104
|
+
problems.append(Problem(pid=r["task_id"], reference=r["prompt"] + r["canonical_solution"],
|
|
105
|
+
tests=[r["test"] + "\ncheck(%s)\n" % r["entry_point"]],
|
|
106
|
+
entry=r["entry_point"], call_alias="candidate"))
|
|
107
|
+
return problems, 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _split_evalplus(test_src):
|
|
111
|
+
"""EvalPlus runs all of a problem's inputs in one loop:
|
|
112
|
+
|
|
113
|
+
inputs = [...]; results = [...]
|
|
114
|
+
for i, (inp, exp) in enumerate(zip(inputs, results)):
|
|
115
|
+
assertion(fn(*inp), exp, ATOL)
|
|
116
|
+
|
|
117
|
+
Run as one test, a single step budget would cover hundreds of inputs and cut
|
|
118
|
+
the reference off. So the loop is unrolled into one assert per input, each
|
|
119
|
+
built from the exact source text of that input and its expected value.
|
|
120
|
+
Returns (prelude, [(args_src, exp_src)], atol_src, called_name) or None.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
tree = ast.parse(test_src)
|
|
124
|
+
except SyntaxError:
|
|
125
|
+
return None
|
|
126
|
+
# ast.get_source_segment re-splits the whole source on every call, which is
|
|
127
|
+
# quadratic on EvalPlus files with thousands of inputs. AST columns are UTF-8
|
|
128
|
+
# byte offsets, so slice the encoded source with line offsets computed once.
|
|
129
|
+
raw = test_src.encode("utf-8")
|
|
130
|
+
starts, pos = [0], 0
|
|
131
|
+
for ln in raw.splitlines(keepends=True):
|
|
132
|
+
pos += len(ln)
|
|
133
|
+
starts.append(pos)
|
|
134
|
+
|
|
135
|
+
def seg(node):
|
|
136
|
+
a = starts[node.lineno - 1] + node.col_offset
|
|
137
|
+
b = starts[node.end_lineno - 1] + node.end_col_offset
|
|
138
|
+
return raw[a:b].decode("utf-8")
|
|
139
|
+
|
|
140
|
+
lit, loop = {}, None
|
|
141
|
+
for node in ast.walk(tree):
|
|
142
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) \
|
|
143
|
+
and node.targets[0].id in ("inputs", "results"):
|
|
144
|
+
lit[node.targets[0].id] = node.value
|
|
145
|
+
if isinstance(node, ast.For) and loop is None:
|
|
146
|
+
loop = node
|
|
147
|
+
if set(lit) != {"inputs", "results"} or loop is None:
|
|
148
|
+
return None
|
|
149
|
+
if not isinstance(lit["inputs"], ast.List) or not isinstance(lit["results"], ast.List):
|
|
150
|
+
return None
|
|
151
|
+
call = next((n for n in ast.walk(loop) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
|
152
|
+
and n.func.id == "assertion"), None)
|
|
153
|
+
if call is None or len(call.args) != 3 or not isinstance(call.args[0], ast.Call) \
|
|
154
|
+
or not isinstance(call.args[0].func, ast.Name):
|
|
155
|
+
return None
|
|
156
|
+
called = call.args[0].func.id
|
|
157
|
+
atol = seg(call.args[2])
|
|
158
|
+
if len(lit["inputs"].elts) != len(lit["results"].elts):
|
|
159
|
+
return None
|
|
160
|
+
pairs = []
|
|
161
|
+
for inp, exp in zip(lit["inputs"].elts, lit["results"].elts):
|
|
162
|
+
if not isinstance(inp, (ast.List, ast.Tuple)):
|
|
163
|
+
return None
|
|
164
|
+
args = ", ".join(seg(a) for a in inp.elts)
|
|
165
|
+
pairs.append((args, seg(exp)))
|
|
166
|
+
keep = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))
|
|
167
|
+
or (isinstance(n, ast.FunctionDef) and n.name != "check")]
|
|
168
|
+
prelude = "\n\n".join(seg(n) for n in keep)
|
|
169
|
+
return prelude, pairs, atol, called
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mbpp_plus(original_reference=False):
|
|
173
|
+
"""EvalPlus MBPP+: the visible suite is EvalPlus's full input set; fresh inputs
|
|
174
|
+
are harvested from the ORIGINAL MBPP test_list, so MBPP and MBPP+ face the
|
|
175
|
+
same adversary inputs. EvalPlus also rewrote many references, so with
|
|
176
|
+
original_reference=True the ORIGINAL MBPP solution is used instead and only
|
|
177
|
+
the tests differ from plain MBPP."""
|
|
178
|
+
ds = _load_dataset("evalplus/mbppplus")["test"]
|
|
179
|
+
orig = None
|
|
180
|
+
if original_reference:
|
|
181
|
+
full = _load_dataset("google-research-datasets/mbpp", "full")
|
|
182
|
+
orig = {str(r["task_id"]): r["code"] for n, s in full.items() if n != "prompt" for r in s}
|
|
183
|
+
problems, dropped = [], 0
|
|
184
|
+
for r in ds:
|
|
185
|
+
if orig is not None:
|
|
186
|
+
if str(r["task_id"]) not in orig:
|
|
187
|
+
dropped += 1
|
|
188
|
+
continue
|
|
189
|
+
r = dict(r, code=orig[str(r["task_id"])])
|
|
190
|
+
base = list(r.get("test_list") or [])
|
|
191
|
+
entry = _entry(r["code"], base) if base else None
|
|
192
|
+
sp = _split_evalplus(r["test"]) if entry else None
|
|
193
|
+
if sp is None or sp[3] != entry:
|
|
194
|
+
dropped += 1
|
|
195
|
+
continue
|
|
196
|
+
prelude, pairs, atol, _ = sp
|
|
197
|
+
setup = "\n".join(list(r.get("test_imports") or []) + [prelude])
|
|
198
|
+
tests = ["assertion(%s(%s), %s, %s)" % (entry, a, e, atol) for a, e in pairs]
|
|
199
|
+
problems.append(Problem(pid=str(r["task_id"]), reference=r["code"], tests=tests, setup=setup,
|
|
200
|
+
entry=entry, harvest=base))
|
|
201
|
+
return problems, dropped
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def humaneval_plus(original_reference=False):
|
|
205
|
+
"""EvalPlus HumanEval+: visible suite = the full EvalPlus input set; fresh inputs
|
|
206
|
+
are harvested from the ORIGINAL HumanEval test of the same task. With
|
|
207
|
+
original_reference=True the original HumanEval solution is used as well."""
|
|
208
|
+
ds = _load_dataset("evalplus/humanevalplus")["test"]
|
|
209
|
+
he = {r["task_id"]: r for r in _load_dataset("openai/openai_humaneval")["test"]}
|
|
210
|
+
orig = {t: r["test"] for t, r in he.items()}
|
|
211
|
+
problems, dropped = [], 0
|
|
212
|
+
for r in ds:
|
|
213
|
+
sp = _split_evalplus(r["test"])
|
|
214
|
+
if sp is None or r["task_id"] not in orig:
|
|
215
|
+
dropped += 1
|
|
216
|
+
continue
|
|
217
|
+
prelude, pairs, atol, called = sp
|
|
218
|
+
entry = r["entry_point"]
|
|
219
|
+
tests = ["assertion(%s(%s), %s, %s)" % (entry, a, e, atol) for a, e in pairs]
|
|
220
|
+
src = he[r["task_id"]] if original_reference else r
|
|
221
|
+
problems.append(Problem(pid=r["task_id"], reference=src["prompt"] + src["canonical_solution"], tests=tests,
|
|
222
|
+
setup=prelude, entry=entry, call_alias="candidate",
|
|
223
|
+
harvest=[orig[r["task_id"]]]))
|
|
224
|
+
return problems, dropped
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def mbpp_sanitized():
|
|
228
|
+
ds = _load_dataset("google-research-datasets/mbpp", "sanitized")
|
|
229
|
+
problems, dropped = [], 0
|
|
230
|
+
for name, split in ds.items():
|
|
231
|
+
if name == "prompt":
|
|
232
|
+
continue
|
|
233
|
+
for r in split:
|
|
234
|
+
tests = list(r.get("test_list") or [])
|
|
235
|
+
entry = _entry(r["code"], tests) if tests and r.get("code") else None
|
|
236
|
+
if entry is None:
|
|
237
|
+
dropped += 1
|
|
238
|
+
continue
|
|
239
|
+
problems.append(Problem(pid=str(r["task_id"]), reference=r["code"], tests=tests,
|
|
240
|
+
setup="\n".join(r.get("test_imports") or []), entry=entry))
|
|
241
|
+
return problems, dropped
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def kodcode(path):
|
|
245
|
+
"""KodCode rows saved as jsonl (fields: question_id, solution, test, test_info).
|
|
246
|
+
|
|
247
|
+
The test is a pytest module that imports the solution (`from solution import f`).
|
|
248
|
+
The import is removed (the solution is already in the namespace) and every plain
|
|
249
|
+
`test_*()` function -- no arguments, no decorators -- becomes its own test, so each
|
|
250
|
+
gets its own step budget. Fixture and parametrised tests cannot be called without
|
|
251
|
+
pytest's runner and are skipped; a problem with none left is dropped and counted.
|
|
252
|
+
"""
|
|
253
|
+
import re
|
|
254
|
+
problems, dropped = [], 0
|
|
255
|
+
with open(path, encoding="utf-8") as f:
|
|
256
|
+
for line in f:
|
|
257
|
+
if not line.strip():
|
|
258
|
+
continue
|
|
259
|
+
r = json.loads(line)
|
|
260
|
+
info = r.get("test_info") or []
|
|
261
|
+
entry = info[0].get("function_name") if info else None
|
|
262
|
+
try:
|
|
263
|
+
top = {n.name for n in ast.parse(r["solution"]).body if isinstance(n, ast.FunctionDef)}
|
|
264
|
+
except SyntaxError:
|
|
265
|
+
top = set()
|
|
266
|
+
if not entry or entry not in top:
|
|
267
|
+
dropped += 1
|
|
268
|
+
continue
|
|
269
|
+
src = re.sub(r"(?m)^\s*(from\s+solution\s+import\s+.*|import\s+solution\s*)$", "", r["test"])
|
|
270
|
+
src = re.sub(r"\bsolution\.", "", src)
|
|
271
|
+
try:
|
|
272
|
+
tree = ast.parse(src)
|
|
273
|
+
except SyntaxError:
|
|
274
|
+
dropped += 1
|
|
275
|
+
continue
|
|
276
|
+
names = [n.name for n in tree.body if isinstance(n, ast.FunctionDef) and n.name.startswith("test")
|
|
277
|
+
and not n.decorator_list and not n.args.args]
|
|
278
|
+
if not names:
|
|
279
|
+
dropped += 1
|
|
280
|
+
continue
|
|
281
|
+
tests = ["%s\n\n%s()\n" % (src, n) for n in names]
|
|
282
|
+
problems.append(Problem(pid=str(r.get("question_id", r.get("_row_idx"))), reference=r["solution"],
|
|
283
|
+
tests=tests, entry=entry, harvest=[src]))
|
|
284
|
+
return problems, dropped
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
SOURCES = {"mbpp": mbpp, "humaneval": humaneval, "mbpp-sanitized": mbpp_sanitized,
|
|
288
|
+
"mbpp-plus": mbpp_plus, "humaneval-plus": humaneval_plus,
|
|
289
|
+
"mbpp-plus-origref": lambda: mbpp_plus(original_reference=True),
|
|
290
|
+
"humaneval-plus-origref": lambda: humaneval_plus(original_reference=True)}
|
gameability/mutate.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Single-site semantic mutation of a reference solution.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS
|
|
4
|
+
A benchmark claims to measure a capability. The claim is only as strong as its
|
|
5
|
+
ability to REJECT a solution that lacks the capability. So we manufacture such
|
|
6
|
+
solutions on purpose: take the reference, break it at exactly one site, and ask
|
|
7
|
+
whether the benchmark notices.
|
|
8
|
+
|
|
9
|
+
This is not bug-finding. The reference is assumed correct. The mutant is the
|
|
10
|
+
adversary, and the benchmark's test suite is the thing under test.
|
|
11
|
+
|
|
12
|
+
Each mutant differs from the reference at ONE site, so a surviving mutant names
|
|
13
|
+
a specific behaviour the suite does not constrain.
|
|
14
|
+
"""
|
|
15
|
+
import ast
|
|
16
|
+
import copy
|
|
17
|
+
|
|
18
|
+
CMP_SWAP = {ast.Lt: ast.LtE, ast.LtE: ast.Lt, ast.Gt: ast.GtE, ast.GtE: ast.Gt,
|
|
19
|
+
ast.Eq: ast.NotEq, ast.NotEq: ast.Eq, ast.In: ast.NotIn,
|
|
20
|
+
ast.NotIn: ast.In}
|
|
21
|
+
BIN_SWAP = {ast.Add: ast.Sub, ast.Sub: ast.Add, ast.Mult: ast.FloorDiv,
|
|
22
|
+
ast.FloorDiv: ast.Mult, ast.Div: ast.Mult, ast.Mod: ast.Mult,
|
|
23
|
+
ast.Pow: ast.Mult}
|
|
24
|
+
BOOL_SWAP = {ast.And: ast.Or, ast.Or: ast.And}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sites(src):
|
|
28
|
+
"""Every position where a single-site mutation is defined.
|
|
29
|
+
|
|
30
|
+
Indices are positions in ast.walk order, which is deterministic for a given
|
|
31
|
+
source string, so a site index is a stable name for a mutation.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
tree = ast.parse(src)
|
|
35
|
+
except SyntaxError:
|
|
36
|
+
return []
|
|
37
|
+
out = []
|
|
38
|
+
for i, n in enumerate(ast.walk(tree)):
|
|
39
|
+
if isinstance(n, ast.Compare) and len(n.ops) == 1 and type(n.ops[0]) in CMP_SWAP:
|
|
40
|
+
out.append((i, "cmp"))
|
|
41
|
+
elif isinstance(n, ast.BinOp) and type(n.op) in BIN_SWAP:
|
|
42
|
+
out.append((i, "arith"))
|
|
43
|
+
elif isinstance(n, ast.BoolOp) and type(n.op) in BOOL_SWAP:
|
|
44
|
+
out.append((i, "bool"))
|
|
45
|
+
elif isinstance(n, ast.Constant) and isinstance(n.value, bool):
|
|
46
|
+
out.append((i, "boolconst"))
|
|
47
|
+
elif isinstance(n, ast.Constant) and isinstance(n.value, int):
|
|
48
|
+
# bool is a subclass of int, so the branch above must come first
|
|
49
|
+
out.append((i, "intconst"))
|
|
50
|
+
elif isinstance(n, ast.Return) and n.value is not None:
|
|
51
|
+
out.append((i, "return_none"))
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mutate(src, index, kind):
|
|
56
|
+
"""Apply one mutation and return the new source, or None if inapplicable."""
|
|
57
|
+
tree = ast.parse(src)
|
|
58
|
+
nodes = list(ast.walk(tree))
|
|
59
|
+
if index >= len(nodes):
|
|
60
|
+
return None
|
|
61
|
+
n = nodes[index]
|
|
62
|
+
try:
|
|
63
|
+
if kind == "cmp":
|
|
64
|
+
n.ops = [CMP_SWAP[type(n.ops[0])]()]
|
|
65
|
+
elif kind == "arith":
|
|
66
|
+
n.op = BIN_SWAP[type(n.op)]()
|
|
67
|
+
elif kind == "bool":
|
|
68
|
+
n.op = BOOL_SWAP[type(n.op)]()
|
|
69
|
+
elif kind == "boolconst":
|
|
70
|
+
n.value = not n.value
|
|
71
|
+
elif kind == "intconst":
|
|
72
|
+
n.value = n.value + 1
|
|
73
|
+
elif kind == "return_none":
|
|
74
|
+
n.value = ast.Constant(value=None)
|
|
75
|
+
else:
|
|
76
|
+
return None
|
|
77
|
+
except (KeyError, AttributeError, IndexError):
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
return ast.unparse(ast.fix_missing_locations(tree))
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_multi(src, limit=24, k=2, seed=0):
|
|
86
|
+
"""Mutants that break the reference at `k` sites at once.
|
|
87
|
+
|
|
88
|
+
Single-site mutation is the WEAKEST possible adversary, so a survival rate
|
|
89
|
+
measured with it is a floor and not an estimate. A real policy optimising
|
|
90
|
+
against a test suite explores an incomparably larger space, and the honest
|
|
91
|
+
way to move toward that is to let the adversary combine edits.
|
|
92
|
+
|
|
93
|
+
A k-site mutant is not automatically harder to catch: two errors can cancel,
|
|
94
|
+
which is why the hidden-input oracle still has to certify every one of them
|
|
95
|
+
as genuinely broken. What changes is the size of the space being searched.
|
|
96
|
+
"""
|
|
97
|
+
import random as _r
|
|
98
|
+
rng = _r.Random(seed)
|
|
99
|
+
all_sites = sites(src)
|
|
100
|
+
if len(all_sites) < k:
|
|
101
|
+
return []
|
|
102
|
+
out, seen = [], set()
|
|
103
|
+
for _ in range(limit * 12):
|
|
104
|
+
if len(out) >= limit:
|
|
105
|
+
break
|
|
106
|
+
picked = rng.sample(all_sites, k)
|
|
107
|
+
key = tuple(sorted(i for i, _ in picked))
|
|
108
|
+
if key in seen:
|
|
109
|
+
continue
|
|
110
|
+
seen.add(key)
|
|
111
|
+
cur = src
|
|
112
|
+
ok = True
|
|
113
|
+
for idx, kind in picked:
|
|
114
|
+
# Re-derive the site list after each edit: ast.walk indices shift
|
|
115
|
+
# when a node is replaced, and reusing stale indices would silently
|
|
116
|
+
# mutate the wrong node -- producing a mutant nobody designed.
|
|
117
|
+
live = dict(sites(cur))
|
|
118
|
+
if idx not in live or live[idx] != kind:
|
|
119
|
+
ok = False
|
|
120
|
+
break
|
|
121
|
+
nxt = mutate(cur, idx, kind)
|
|
122
|
+
if nxt is None:
|
|
123
|
+
ok = False
|
|
124
|
+
break
|
|
125
|
+
cur = nxt
|
|
126
|
+
if ok and cur.strip() != src.strip():
|
|
127
|
+
out.append({"kind": "multi%d" % k, "site": key, "src": cur})
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generate(src, limit=24):
|
|
132
|
+
"""All single-site mutants of `src`, capped at `limit`, deterministically.
|
|
133
|
+
|
|
134
|
+
The cap is spread ACROSS operator kinds rather than taking the first N
|
|
135
|
+
sites, because the first N sites of a long function are all in its opening
|
|
136
|
+
lines and would test only one region of the behaviour.
|
|
137
|
+
"""
|
|
138
|
+
by_kind = {}
|
|
139
|
+
for i, kind in sites(src):
|
|
140
|
+
by_kind.setdefault(kind, []).append(i)
|
|
141
|
+
out, exhausted = [], False
|
|
142
|
+
round_no = 0
|
|
143
|
+
while len(out) < limit and not exhausted:
|
|
144
|
+
exhausted = True
|
|
145
|
+
for kind in sorted(by_kind):
|
|
146
|
+
idxs = by_kind[kind]
|
|
147
|
+
if round_no < len(idxs):
|
|
148
|
+
exhausted = False
|
|
149
|
+
m = mutate(src, idxs[round_no], kind)
|
|
150
|
+
if m is not None and m.strip() != src.strip():
|
|
151
|
+
out.append({"kind": kind, "site": idxs[round_no], "src": m})
|
|
152
|
+
if len(out) >= limit:
|
|
153
|
+
break
|
|
154
|
+
round_no += 1
|
|
155
|
+
return out
|