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/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