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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""gameability -- find the problems in a code benchmark that accept broken solutions.
|
|
2
|
+
|
|
3
|
+
A benchmark is only as strong as its ability to REJECT a solution that provably
|
|
4
|
+
lacks the capability it claims to measure. This package manufactures such
|
|
5
|
+
solutions, proves they are genuinely broken against inputs the benchmark never
|
|
6
|
+
shows, and reports what fraction the benchmark accepts anyway.
|
|
7
|
+
|
|
8
|
+
from gameability import Problem, census, summarise, report
|
|
9
|
+
|
|
10
|
+
rows = census([Problem("p1", reference_src, ["assert f(1) == 2"])])
|
|
11
|
+
report(summarise(rows, "my benchmark"))
|
|
12
|
+
|
|
13
|
+
The only thing a benchmark must supply is (reference solution, visible tests).
|
|
14
|
+
"""
|
|
15
|
+
from .engine import Problem, audit, census, report, summarise
|
|
16
|
+
from .harden import witnesses
|
|
17
|
+
|
|
18
|
+
__all__ = ["Problem", "audit", "census", "summarise", "report", "witnesses"]
|
|
19
|
+
__version__ = "0.2.0"
|
gameability/__main__.py
ADDED
gameability/_worker.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Child process: execute one problem's reference and mutants in isolation.
|
|
2
|
+
|
|
3
|
+
Results are emitted as one JSON line per candidate and flushed immediately, so
|
|
4
|
+
a candidate that fails to terminate costs only the candidates after it rather
|
|
5
|
+
than the whole problem. The parent records the shortfall as `truncated` instead
|
|
6
|
+
of silently treating unreached mutants as killed, which would bias the headline
|
|
7
|
+
number downward -- toward the flattering answer.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
STEP_BUDGET = 200_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Budget:
|
|
17
|
+
"""Abort runaway code by counting traced events. Windows has no SIGALRM."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, limit):
|
|
20
|
+
self.n, self.limit = 0, limit
|
|
21
|
+
|
|
22
|
+
def __call__(self, frame, event, arg):
|
|
23
|
+
self.n += 1
|
|
24
|
+
if self.n > self.limit:
|
|
25
|
+
raise TimeoutError("step budget exceeded")
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def outcome(ns, expr):
|
|
30
|
+
"""('ok', repr) or ('exc', type) -- repr avoids trusting a custom __eq__."""
|
|
31
|
+
sys.settrace(_Budget(STEP_BUDGET))
|
|
32
|
+
try:
|
|
33
|
+
return ["ok", repr(eval(expr, ns))]
|
|
34
|
+
except BaseException as e:
|
|
35
|
+
return ["exc", type(e).__name__]
|
|
36
|
+
finally:
|
|
37
|
+
sys.settrace(None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_asserts(ns, tests):
|
|
41
|
+
for t in tests:
|
|
42
|
+
sys.settrace(_Budget(STEP_BUDGET))
|
|
43
|
+
try:
|
|
44
|
+
exec(t, ns)
|
|
45
|
+
except BaseException:
|
|
46
|
+
return False
|
|
47
|
+
finally:
|
|
48
|
+
sys.settrace(None)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate(src, setup, tests, fn, fresh):
|
|
53
|
+
ns = {"__name__": "__candidate__"}
|
|
54
|
+
try:
|
|
55
|
+
if setup:
|
|
56
|
+
exec(setup, ns)
|
|
57
|
+
exec(src, ns)
|
|
58
|
+
except BaseException as e:
|
|
59
|
+
return {"loaded": False, "error": type(e).__name__}
|
|
60
|
+
if fn not in ns:
|
|
61
|
+
return {"loaded": False, "error": "entry point %r not defined" % fn}
|
|
62
|
+
return {
|
|
63
|
+
"loaded": True,
|
|
64
|
+
"visible": run_asserts(dict(ns), tests),
|
|
65
|
+
"hidden": [outcome(dict(ns), "%s(%s)" % (fn, a)) for a in fresh],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
job = json.loads(sys.stdin.read())
|
|
71
|
+
setup, tests = job.get("setup", ""), job["tests"]
|
|
72
|
+
fn, fresh = job["fn"], job["fresh"]
|
|
73
|
+
# Benchmark code prints. Its output must never reach the result stream, where a
|
|
74
|
+
# printed number or dict would be read as a record (2026-10-17: a KodCode test
|
|
75
|
+
# printed an int and crashed the reader). Results go to the real stdout only.
|
|
76
|
+
out = sys.stdout
|
|
77
|
+
sys.stdout = open(os.devnull, "w")
|
|
78
|
+
for tag, src in [("ref", job["ref"])] + [
|
|
79
|
+
("m%d" % i, m) for i, m in enumerate(job["mutants"])]:
|
|
80
|
+
rec = evaluate(src, setup, tests, fn, fresh)
|
|
81
|
+
rec["tag"] = tag
|
|
82
|
+
out.write(json.dumps(rec) + "\n")
|
|
83
|
+
out.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
gameability/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command line: gameability check <benchmark>
|
|
2
|
+
|
|
3
|
+
gameability check mbpp
|
|
4
|
+
gameability check humaneval --limit 50
|
|
5
|
+
gameability check my_benchmark.jsonl --fixes fixes.py --json report.json
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import ast
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
# Benchmark references are third-party code; old-style escapes in them ('\w')
|
|
18
|
+
# make Python warn on every parse. The warnings say nothing about the audit.
|
|
19
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .engine import census, report, summarise
|
|
23
|
+
from .loaders import SOURCES, from_jsonl, kodcode
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _changed_lines(reference, mutant):
|
|
27
|
+
"""The one line the mutation changed, before and after (normalised by ast)."""
|
|
28
|
+
try:
|
|
29
|
+
a = ast.unparse(ast.parse(reference)).splitlines()
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
a = reference.splitlines()
|
|
32
|
+
b = mutant.splitlines()
|
|
33
|
+
out = []
|
|
34
|
+
for line in difflib.unified_diff(a, b, lineterm="", n=0):
|
|
35
|
+
if line.startswith(("---", "+++", "@@")):
|
|
36
|
+
continue
|
|
37
|
+
out.append(line)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(target):
|
|
42
|
+
if target in SOURCES:
|
|
43
|
+
return SOURCES[target]()
|
|
44
|
+
if target.startswith("kodcode:"):
|
|
45
|
+
return kodcode(target[len("kodcode:"):])
|
|
46
|
+
if os.path.isfile(target):
|
|
47
|
+
return from_jsonl(target)
|
|
48
|
+
raise SystemExit("unknown benchmark %r: use %s, or a path to a .jsonl file" % (target, ", ".join(SOURCES)))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check(a):
|
|
52
|
+
problems, dropped = _load(a.benchmark)
|
|
53
|
+
label = os.path.basename(a.benchmark)
|
|
54
|
+
print("%s: %d problems usable as (reference, visible tests)" % (label, len(problems)))
|
|
55
|
+
if dropped:
|
|
56
|
+
print(" %d dropped before the audit (no tests, or the tests never call a "
|
|
57
|
+
"function the reference defines)" % dropped)
|
|
58
|
+
if a.limit:
|
|
59
|
+
random.Random(a.seed).shuffle(problems)
|
|
60
|
+
problems = problems[:a.limit]
|
|
61
|
+
print(" random sample of %d, seed %d" % (len(problems), a.seed))
|
|
62
|
+
if not problems:
|
|
63
|
+
raise SystemExit("nothing to audit")
|
|
64
|
+
|
|
65
|
+
t0 = time.time()
|
|
66
|
+
rows = census(problems, workers=a.workers, seed=a.seed, n_mutants=a.mutants,
|
|
67
|
+
timeout=a.timeout, want_witness=True, inputs=a.inputs)
|
|
68
|
+
s = summarise(rows, "%s (%d problems)" % (label, len(problems)))
|
|
69
|
+
print("\nelapsed %.0fs\n" % (time.time() - t0))
|
|
70
|
+
report(s)
|
|
71
|
+
|
|
72
|
+
by_id = {p.pid: p for p in problems}
|
|
73
|
+
weak = [r for r in rows if r["status"] == "ok" and r["n_survived"] > 0]
|
|
74
|
+
if weak and a.show:
|
|
75
|
+
print("\nPROBLEMS THAT ACCEPT A BROKEN SOLUTION (first %d of %d)" % (min(a.show, len(weak)), len(weak)))
|
|
76
|
+
for r in weak[:a.show]:
|
|
77
|
+
ex = r["examples"][0]
|
|
78
|
+
print("\n %s -- %d of %d broken solutions pass every visible test" % (r["pid"], r["n_survived"], r["n_broken"]))
|
|
79
|
+
for line in _changed_lines(by_id[r["pid"]].reference, ex["src"]):
|
|
80
|
+
print(" " + line)
|
|
81
|
+
if r.get("witness"):
|
|
82
|
+
args, out = r["witness"][0]
|
|
83
|
+
print(" a test that catches it: assert %s(%s) == %s" % (by_id[r["pid"]].entry, args, out))
|
|
84
|
+
|
|
85
|
+
if a.fixes:
|
|
86
|
+
n = 0
|
|
87
|
+
with open(a.fixes, "w", encoding="utf-8") as f:
|
|
88
|
+
f.write("# Asserts that reject the broken solutions gameability found.\n")
|
|
89
|
+
f.write("# Expected values come from each problem's own reference solution;\n")
|
|
90
|
+
f.write("# review them before adding them to the benchmark.\n")
|
|
91
|
+
for r in weak:
|
|
92
|
+
seen = set()
|
|
93
|
+
for args, out in r.get("witness", []):
|
|
94
|
+
t = "assert %s(%s) == %s" % (by_id[r["pid"]].entry, args, out)
|
|
95
|
+
if t not in seen:
|
|
96
|
+
seen.add(t)
|
|
97
|
+
if len(seen) == 1:
|
|
98
|
+
f.write("\n# %s\n" % r["pid"])
|
|
99
|
+
f.write(t + "\n")
|
|
100
|
+
n += 1
|
|
101
|
+
print("\nwrote %d suggested asserts for %d problems to %s" % (n, len(weak), a.fixes))
|
|
102
|
+
|
|
103
|
+
if a.json:
|
|
104
|
+
with open(a.json, "w", encoding="utf-8") as f:
|
|
105
|
+
json.dump({"version": __version__, "summary": s, "rows": rows}, f, indent=1)
|
|
106
|
+
print("wrote %s" % a.json)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv=None):
|
|
111
|
+
ap = argparse.ArgumentParser(prog="gameability",
|
|
112
|
+
description="Find the problems in a code benchmark that accept broken solutions.")
|
|
113
|
+
ap.add_argument("--version", action="version", version="gameability " + __version__)
|
|
114
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
115
|
+
c = sub.add_parser("check", help="audit a benchmark")
|
|
116
|
+
c.add_argument("benchmark", help="mbpp, humaneval, or a .jsonl file")
|
|
117
|
+
c.add_argument("--limit", type=int, default=0, help="audit a random sample of N problems")
|
|
118
|
+
c.add_argument("--seed", type=int, default=0)
|
|
119
|
+
c.add_argument("--workers", type=int, default=8)
|
|
120
|
+
c.add_argument("--mutants", type=int, default=24, help="broken solutions to try per problem")
|
|
121
|
+
c.add_argument("--inputs", choices=["contract", "free"], default="contract",
|
|
122
|
+
help="contract (default): fresh inputs stay inside each argument's observed domain; "
|
|
123
|
+
"free: v0.1's unconstrained generator, to reproduce its numbers")
|
|
124
|
+
c.add_argument("--timeout", type=int, default=40,
|
|
125
|
+
help="wall-clock backstop per problem in seconds (the step budget is the real limit)")
|
|
126
|
+
c.add_argument("--show", type=int, default=5, help="print this many weak problems with the changed line")
|
|
127
|
+
c.add_argument("--fixes", help="write suggested asserts to this file")
|
|
128
|
+
c.add_argument("--json", help="write the full report to this file")
|
|
129
|
+
a = ap.parse_args(argv)
|
|
130
|
+
if a.cmd == "check":
|
|
131
|
+
return check(a)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
gameability/engine.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""GAMEABILITY CENSUS -- can a benchmark's tests be passed without the capability?
|
|
2
|
+
|
|
3
|
+
THE MEASUREMENT
|
|
4
|
+
For each problem the benchmark supplies (reference solution, visible tests):
|
|
5
|
+
|
|
6
|
+
1. the reference must pass its own visible tests -- else EXCLUDED
|
|
7
|
+
2. build fresh inputs by perturbing the visible tests' own arguments, and
|
|
8
|
+
record the reference's behaviour on them: this is the HIDDEN ORACLE
|
|
9
|
+
3. manufacture single-site mutants of the reference
|
|
10
|
+
4. a mutant is GENUINELY BROKEN only if it differs from the reference on at
|
|
11
|
+
least one hidden input -- equivalent mutants are discarded, not counted
|
|
12
|
+
5. GAMEABLE = genuinely broken AND still passes every visible test
|
|
13
|
+
|
|
14
|
+
The headline number is survivors / genuinely-broken. It is a property of the
|
|
15
|
+
BENCHMARK, not of any model: it says what fraction of provably-incapable
|
|
16
|
+
solutions the benchmark would accept.
|
|
17
|
+
|
|
18
|
+
WHAT WOULD MAKE THIS WRONG, and what the code does about it:
|
|
19
|
+
* counting equivalent mutants as gameable -> step 4, the hidden oracle
|
|
20
|
+
* a hanging mutant scored as 'killed' -> reported as `truncated`
|
|
21
|
+
* problems with no usable fresh inputs -> EXCLUDED and counted
|
|
22
|
+
* a weak mutation set that only breaks obvious -> mutants are spread across
|
|
23
|
+
things operator kinds, and the
|
|
24
|
+
per-kind survival rate is
|
|
25
|
+
reported separately
|
|
26
|
+
"""
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
34
|
+
|
|
35
|
+
from . import mutate, oracle
|
|
36
|
+
|
|
37
|
+
WORKER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_worker.py")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Problem:
|
|
41
|
+
"""(reference, visible tests) -- the only thing a benchmark must supply."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, pid, reference, tests, setup="", entry=None, hidden=(),
|
|
44
|
+
call_alias=None, harvest=None):
|
|
45
|
+
self.pid = pid
|
|
46
|
+
self.reference = reference
|
|
47
|
+
self.tests = list(tests)
|
|
48
|
+
self.setup = setup
|
|
49
|
+
self.entry = entry or oracle.entry_name(self.tests)
|
|
50
|
+
# Some benchmarks call the solution through a placeholder name -- HumanEval
|
|
51
|
+
# wraps every assert in `def check(candidate)` -- so the name to look for
|
|
52
|
+
# when harvesting argument tuples is not the name to call.
|
|
53
|
+
self.call_alias = call_alias or self.entry
|
|
54
|
+
# Some benchmarks ship held-out tests of their own. When present they
|
|
55
|
+
# are used as extra hidden inputs rather than ignored -- a benchmark
|
|
56
|
+
# should get credit for the defence it already has.
|
|
57
|
+
self.declared_hidden = list(hidden)
|
|
58
|
+
# Where fresh inputs are harvested from. Defaults to the visible tests.
|
|
59
|
+
# A hardened benchmark (EvalPlus) is compared with its original by
|
|
60
|
+
# harvesting from the ORIGINAL tests, so both face the same adversary
|
|
61
|
+
# inputs and only the test suite differs.
|
|
62
|
+
self.harvest = list(harvest) if harvest is not None else self.tests
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run(job, timeout):
|
|
66
|
+
# Benchmark code writes files (shelve databases, zip archives, logs). Each job runs in its own
|
|
67
|
+
# throwaway directory, so nothing lands in the user's folder and no problem can read another's
|
|
68
|
+
# files (2026-10-19: MBPP/KodCode references had left 8 files in the working directory).
|
|
69
|
+
cwd = tempfile.mkdtemp(prefix="gameability_")
|
|
70
|
+
p = subprocess.Popen([sys.executable, WORKER],
|
|
71
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
72
|
+
stderr=subprocess.DEVNULL, text=True, cwd=cwd)
|
|
73
|
+
try:
|
|
74
|
+
out, _ = p.communicate(json.dumps(job), timeout=timeout)
|
|
75
|
+
except subprocess.TimeoutExpired:
|
|
76
|
+
p.kill()
|
|
77
|
+
out, _ = p.communicate()
|
|
78
|
+
finally:
|
|
79
|
+
shutil.rmtree(cwd, ignore_errors=True)
|
|
80
|
+
recs = {}
|
|
81
|
+
for line in (out or "").splitlines():
|
|
82
|
+
line = line.strip()
|
|
83
|
+
if not line:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
r = json.loads(line)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
continue
|
|
89
|
+
if isinstance(r, dict) and "tag" in r: # anything else is not a worker record
|
|
90
|
+
recs[r["tag"]] = r
|
|
91
|
+
return recs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def audit(problem, n_mutants=24, n_hidden=8, timeout=40, seed=0,
|
|
95
|
+
k_sites=1, want_witness=False, mutant_offset=None,
|
|
96
|
+
mutant_stride=1, inputs="contract"):
|
|
97
|
+
"""Audit one problem. Returns a dict; `status` says whether it counted."""
|
|
98
|
+
res = {"pid": problem.pid, "status": "ok", "n_broken": 0, "n_survived": 0,
|
|
99
|
+
"truncated": 0, "by_kind": {}}
|
|
100
|
+
|
|
101
|
+
if not problem.entry:
|
|
102
|
+
res["status"] = "excluded:no-entry-point"
|
|
103
|
+
return res
|
|
104
|
+
|
|
105
|
+
_, calls = oracle.extract_calls(problem.harvest, problem.call_alias)
|
|
106
|
+
fresh = oracle.fresh_calls(calls, n=n_hidden, seed=seed, inputs=inputs)
|
|
107
|
+
for t in problem.declared_hidden:
|
|
108
|
+
_, hc = oracle.extract_calls([t], problem.call_alias)
|
|
109
|
+
fresh += [", ".join(repr(a) for a in c) for c in hc]
|
|
110
|
+
if not fresh:
|
|
111
|
+
res["status"] = "excluded:no-hidden-inputs"
|
|
112
|
+
return res
|
|
113
|
+
|
|
114
|
+
# k_sites>1 is a STRONGER adversary. Single-site mutation is the weakest
|
|
115
|
+
# attacker available, so any rate measured with it is a floor rather than an
|
|
116
|
+
# estimate; combining edits searches a larger space, closer to what a policy
|
|
117
|
+
# optimising against the tests would actually find.
|
|
118
|
+
if k_sites > 1:
|
|
119
|
+
mutants = mutate.generate_multi(problem.reference, limit=n_mutants,
|
|
120
|
+
k=k_sites, seed=seed)
|
|
121
|
+
else:
|
|
122
|
+
mutants = mutate.generate(problem.reference, limit=n_mutants)
|
|
123
|
+
# A genuine train/test split over ADVERSARIES. mutate.generate() is
|
|
124
|
+
# deterministic and ignores `seed` -- seeding only reseeds the hidden
|
|
125
|
+
# inputs -- so splitting by seed would hand both halves the same mutants.
|
|
126
|
+
# Partitioning the list by index is disjoint by construction.
|
|
127
|
+
if mutant_offset is not None:
|
|
128
|
+
mutants = mutants[mutant_offset::mutant_stride]
|
|
129
|
+
if not mutants:
|
|
130
|
+
res["status"] = "excluded:no-mutation-sites"
|
|
131
|
+
return res
|
|
132
|
+
|
|
133
|
+
recs = _run({"ref": problem.reference,
|
|
134
|
+
"mutants": [m["src"] for m in mutants],
|
|
135
|
+
"setup": problem.setup, "tests": problem.tests,
|
|
136
|
+
"fn": problem.entry, "fresh": fresh}, timeout)
|
|
137
|
+
|
|
138
|
+
ref = recs.get("ref")
|
|
139
|
+
if not ref or not ref.get("loaded"):
|
|
140
|
+
res["status"] = "excluded:reference-would-not-load"
|
|
141
|
+
return res
|
|
142
|
+
if not ref["visible"]:
|
|
143
|
+
res["status"] = "excluded:reference-fails-own-tests"
|
|
144
|
+
return res
|
|
145
|
+
# A reference that raises on every fresh input gives no oracle to compare
|
|
146
|
+
# against, so nothing about this problem can be concluded.
|
|
147
|
+
if all(o[0] == "exc" for o in ref["hidden"]):
|
|
148
|
+
res["status"] = "excluded:no-usable-oracle"
|
|
149
|
+
return res
|
|
150
|
+
|
|
151
|
+
res["n_mutants"] = len(mutants)
|
|
152
|
+
res["n_loaded"] = 0
|
|
153
|
+
res["n_equivalent"] = 0
|
|
154
|
+
# How often the reference itself raises on a fresh input. If this is high
|
|
155
|
+
# the oracle is weak -- two solutions that both raise look identical, so
|
|
156
|
+
# broken mutants get discarded as equivalent and the survival rate is
|
|
157
|
+
# measured against a shrunken, unrepresentative denominator.
|
|
158
|
+
res["oracle_exc"] = sum(1 for o in ref["hidden"] if o[0] == "exc")
|
|
159
|
+
res["oracle_n"] = len(ref["hidden"])
|
|
160
|
+
for i, m in enumerate(mutants):
|
|
161
|
+
r = recs.get("m%d" % i)
|
|
162
|
+
if r is None:
|
|
163
|
+
res["truncated"] += 1
|
|
164
|
+
continue
|
|
165
|
+
if not r.get("loaded"):
|
|
166
|
+
continue
|
|
167
|
+
res["n_loaded"] += 1
|
|
168
|
+
# The index where they disagree is not incidental -- it is the WITNESS,
|
|
169
|
+
# the single input that proves this mutant wrong. Recording it turns a
|
|
170
|
+
# report into a patch, at no extra cost.
|
|
171
|
+
diff = [i for i, (a, b) in enumerate(zip(ref["hidden"], r["hidden"]))
|
|
172
|
+
if a != b]
|
|
173
|
+
broken = bool(diff)
|
|
174
|
+
if not broken:
|
|
175
|
+
res["n_equivalent"] += 1
|
|
176
|
+
continue # equivalent mutant -- not evidence
|
|
177
|
+
res["n_broken"] += 1
|
|
178
|
+
k = res["by_kind"].setdefault(m["kind"], {"broken": 0, "survived": 0})
|
|
179
|
+
k["broken"] += 1
|
|
180
|
+
if r["visible"]:
|
|
181
|
+
res["n_survived"] += 1
|
|
182
|
+
k["survived"] += 1
|
|
183
|
+
res.setdefault("examples", []).append(
|
|
184
|
+
{"kind": m["kind"], "src": m["src"]})
|
|
185
|
+
if want_witness:
|
|
186
|
+
# Only "ok" outcomes make usable asserts; an exception witness
|
|
187
|
+
# would need pytest.raises and is skipped rather than guessed at.
|
|
188
|
+
for i in diff:
|
|
189
|
+
if ref["hidden"][i][0] == "ok":
|
|
190
|
+
res.setdefault("witness", []).append(
|
|
191
|
+
(fresh[i], ref["hidden"][i][1]))
|
|
192
|
+
break
|
|
193
|
+
if res["n_broken"] == 0:
|
|
194
|
+
res["status"] = "excluded:no-genuinely-broken-mutant"
|
|
195
|
+
return res
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def census(problems, workers=8, **kw):
|
|
199
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
200
|
+
return list(ex.map(lambda p: audit(p, **kw), problems))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def summarise(rows, label=""):
|
|
204
|
+
counted = [r for r in rows if r["status"] == "ok"]
|
|
205
|
+
excl = {}
|
|
206
|
+
for r in rows:
|
|
207
|
+
if r["status"] != "ok":
|
|
208
|
+
excl[r["status"]] = excl.get(r["status"], 0) + 1
|
|
209
|
+
broken = sum(r["n_broken"] for r in counted)
|
|
210
|
+
surv = sum(r["n_survived"] for r in counted)
|
|
211
|
+
trunc = sum(r["truncated"] for r in rows)
|
|
212
|
+
probs_gameable = sum(1 for r in counted if r["n_survived"] > 0)
|
|
213
|
+
kinds = {}
|
|
214
|
+
for r in counted:
|
|
215
|
+
for k, v in r["by_kind"].items():
|
|
216
|
+
d = kinds.setdefault(k, {"broken": 0, "survived": 0})
|
|
217
|
+
d["broken"] += v["broken"]
|
|
218
|
+
d["survived"] += v["survived"]
|
|
219
|
+
rate = surv / broken if broken else 0.0
|
|
220
|
+
# 95% interval on the survival rate, treating mutants as independent trials
|
|
221
|
+
se = (rate * (1 - rate) / broken) ** 0.5 if broken else 0.0
|
|
222
|
+
return {
|
|
223
|
+
"label": label,
|
|
224
|
+
"problems_total": len(rows),
|
|
225
|
+
"problems_counted": len(counted),
|
|
226
|
+
"problems_with_a_survivor": probs_gameable,
|
|
227
|
+
"problem_gameable_rate": probs_gameable / len(counted) if counted else 0.0,
|
|
228
|
+
"mutants_genuinely_broken": broken,
|
|
229
|
+
"mutants_survived": surv,
|
|
230
|
+
"survival_rate": rate,
|
|
231
|
+
"survival_ci": [max(0.0, rate - 1.96 * se), min(1.0, rate + 1.96 * se)],
|
|
232
|
+
"truncated_mutants": trunc,
|
|
233
|
+
"mutants_generated": sum(r.get("n_mutants", 0) for r in counted),
|
|
234
|
+
"mutants_equivalent_discarded": sum(r.get("n_equivalent", 0) for r in counted),
|
|
235
|
+
"oracle_exception_rate": (
|
|
236
|
+
sum(r.get("oracle_exc", 0) for r in counted)
|
|
237
|
+
/ max(1, sum(r.get("oracle_n", 0) for r in counted))),
|
|
238
|
+
"excluded": excl,
|
|
239
|
+
"by_kind": {k: {**v, "rate": v["survived"] / v["broken"] if v["broken"] else 0.0}
|
|
240
|
+
for k, v in sorted(kinds.items())},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def report(s):
|
|
245
|
+
print("=" * 78)
|
|
246
|
+
print("GAMEABILITY CENSUS -- %s" % s["label"])
|
|
247
|
+
print("=" * 78)
|
|
248
|
+
print(" problems supplied %d" % s["problems_total"])
|
|
249
|
+
print(" problems that could be judged %d" % s["problems_counted"])
|
|
250
|
+
for k, v in sorted(s["excluded"].items()):
|
|
251
|
+
print(" %-38s %d" % (k, v))
|
|
252
|
+
print(" single-site mutants built %d" % s["mutants_generated"])
|
|
253
|
+
print(" discarded as equivalent %d (%.0f%%) -- not evidence of anything"
|
|
254
|
+
% (s["mutants_equivalent_discarded"],
|
|
255
|
+
100 * s["mutants_equivalent_discarded"] / max(1, s["mutants_generated"])))
|
|
256
|
+
print(" broken solutions manufactured %d" % s["mutants_genuinely_broken"])
|
|
257
|
+
print(" ...of which the tests ACCEPTED %d" % s["mutants_survived"])
|
|
258
|
+
print("\n oracle strength: the reference raised on %.0f%% of fresh inputs"
|
|
259
|
+
% (100 * s["oracle_exception_rate"]))
|
|
260
|
+
print(" (a high rate would mean broken mutants are being lost as")
|
|
261
|
+
print(" equivalent, shrinking the denominator and flattering nobody)")
|
|
262
|
+
print()
|
|
263
|
+
print(" SURVIVAL RATE %.3f 95%% CI [%.3f, %.3f]"
|
|
264
|
+
% (s["survival_rate"], s["survival_ci"][0], s["survival_ci"][1]))
|
|
265
|
+
print(" PROBLEMS with at least one accepted broken solution %.1f%% (%d/%d)"
|
|
266
|
+
% (100 * s["problem_gameable_rate"], s["problems_with_a_survivor"],
|
|
267
|
+
s["problems_counted"]))
|
|
268
|
+
if s["truncated_mutants"]:
|
|
269
|
+
print(" (%d mutants never returned and are NOT counted as killed)"
|
|
270
|
+
% s["truncated_mutants"])
|
|
271
|
+
print("\n by mutation kind:")
|
|
272
|
+
for k, v in s["by_kind"].items():
|
|
273
|
+
print(" %-12s %4d broken, %4d accepted %.3f" % (k, v["broken"],
|
|
274
|
+
v["survived"], v["rate"]))
|
|
275
|
+
print("=" * 78)
|