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.
@@ -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"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)