@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1

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.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -579
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -65
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -106
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -81
  62. package/scripts/test_mcp.py +178 -178
  63. package/scripts/test_merged.py +214 -214
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -438
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3764
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1042
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -425
  93. package/ui/bench.js +1756 -1576
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/suite/seed.py CHANGED
@@ -1,421 +1,421 @@
1
- """Generate seeded-bug tasks from real private code, each paired with a control.
2
-
3
- python suite/seed.py --count 6 # six functions -> six bug + six control drafts
4
- python suite/seed.py --count 6 --seed 42 # reproducible
5
- python suite/seed.py --repo myapp --count 4 # a key in config/repos.json
6
- python suite/seed.py --hard --count 4 --kinds args,off-by-one,remove-call
7
-
8
- WHY
9
- ---
10
- Review is the work the bench hands out most, and hand-built review tasks are
11
- slow to make: every answer key had to be found and verified first. A seeded
12
- bug has its answer key by construction. Take a real function from a private
13
- repository — so no model has seen it — change ONE thing so it no longer does
14
- what its author intended, and ask which line changed. Fresh variants are
15
- unlimited.
16
-
17
- EVERY BUG HAS A CONTROL
18
- -----------------------
19
- The same function, untouched, where the right answer is NO FAULT. Without it a
20
- model that always names a line would look like a good reviewer; with it,
21
- claiming a fault in clean code is scored as invention, which is the failure
22
- that costs the most.
23
-
24
- WHAT IS CHANGED
25
- ---------------
26
- Ten kinds (--kinds picks some; the run's kind is chosen first, at random, so
27
- common ones do not crowd out rare ones):
28
- compare < <= > >= == != flipped to its neighbour
29
- boolop and <-> or
30
- return-bool a returned True/False
31
- not a `not` removed after if/elif/while
32
- number a small int (-2..2) moved by one or set to 0
33
- args two adjacent positional arguments swapped
34
- off-by-one range(n) -> range(n - 1) or range(1, n); x[i:] -> x[i + 1:]; x[:j] -> x[:j - 1]
35
- break-continue break <-> continue
36
- remove-call a call statement deleted (`pass` where the block would be empty)
37
- augassign += <-> -=, *= -> /=
38
- The first four are single tokens found with `tokenize`; the rest (ideas from
39
- the operator lists of mutmut and cosmic-ray, no code taken) are found with
40
- `ast`. Every edit replaces text on one line, or deletes one line, so the
41
- author's formatting survives: no style tell, the leak the panel warned about.
42
- Without --kinds, function mode uses the four token kinds (its prompt names
43
- them) and --hard uses all ten.
44
-
45
- Changes that rarely alter behaviour are not offered: code in an
46
- `if __name__ == "__main__":` block or after a return/raise/break/continue,
47
- keyword-argument and default values, calls to print/logging/sleep, and
48
- argument swaps for max/min/gcd/lcm/hypot.
49
-
50
- The prompt numbers the lines, because a model that miscounts has not reviewed
51
- badly, and asks for `FAULT: line <n>` or `NO FAULT` on the first line, scored
52
- by exact regex (a deleted line accepts the line before or after the gap).
53
- Seeded and control tasks are `"shape": "seeded"` and stay out of the routing
54
- recall for now.
55
-
56
- DRAFTS
57
- ------
58
- Written as drafts. A change can still leave behaviour unchanged (a branch
59
- that is unreachable, a comparison of equal values), which would make the key
60
- wrong; read each bug task's `why` before removing "draft": true.
61
- """
62
-
63
- from __future__ import annotations
64
-
65
- import argparse
66
- import ast
67
- import io
68
- import json
69
- import pathlib
70
- import random
71
- import re
72
- import sys
73
- import tokenize
74
-
75
- ROOT = pathlib.Path(__file__).resolve().parent.parent
76
- sys.path.insert(0, str(ROOT / "metrics"))
77
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
78
- TASKS = DATA / "suite" / "tasks"
79
- sys.path.insert(0, str(ROOT / "suite"))
80
- from run import DEFAULT_REPO, REPOS # noqa: E402
81
-
82
- FLIP = {"<": "<=", "<=": "<", ">": ">=", ">=": ">", "==": "!=", "!=": "==",
83
- "and": "or", "or": "and", "True": "False", "False": "True"}
84
- SKIP_DIRS = ("test", "migrations", "vendor", "__pycache__", ".venv", "build", "dist")
85
- # Each kind as the function prompt names it (--hard names none).
86
- KINDS = {
87
- "compare": "a comparison operator",
88
- "boolop": "`and`/`or`",
89
- "return-bool": "a returned True/False",
90
- "not": "a removed `not`",
91
- "number": "a small number",
92
- "args": "two adjacent arguments swapped",
93
- "off-by-one": "an off-by-one in a range() or slice",
94
- "break-continue": "`break` and `continue` swapped",
95
- "remove-call": "a removed call",
96
- "augassign": "an augmented assignment operator",
97
- }
98
- TOKEN_KINDS = ("compare", "boolop", "return-bool", "not")
99
- SYMMETRIC = {"max", "min", "gcd", "lcm", "hypot"}
100
- QUIET = re.compile(r"(^|\.)(print|echo|write|log|debug|info|warn|warning|error|exception|critical|sleep)$")
101
- AUG = {"+=": "-=", "-=": "+=", "*=": "/="}
102
- AUG_OP = {"Add": "+=", "Sub": "-=", "Mult": "*="}
103
- # An expression that `x - 1` would not simply shift by one.
104
- LOOSE = (ast.IfExp, ast.Lambda, ast.BoolOp, ast.Compare, ast.NamedExpr, ast.Starred)
105
- # --hard: the whole file, and no hint at what kind of change to look for. The
106
- # first batch (2026-10-17) saturated: three of four models found 6/6 seeded
107
- # bugs and passed 6/6 controls with the function alone and the hint.
108
- HARD_PROMPT = ("The Python file below is either exactly as its author wrote it, or {change}"
109
- " so that the code no longer does what the author intended."
110
- " Lines are numbered.\n\n"
111
- "Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or"
112
- " `NO FAULT` if nothing was changed. Then explain in at most three sentences."
113
- " Plain ASCII.\n\n{code}")
114
- PROMPT = ("The Python function below is either exactly as its author wrote it, or {change}"
115
- " so that it no longer does what the author intended ({hint}). Lines are numbered.\n\n"
116
- "Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or `NO FAULT`"
117
- " if nothing was changed. Then explain in at most three sentences. Plain ASCII.\n\n"
118
- "{code}")
119
-
120
-
121
- def prompt_for(hard: bool, kinds: list[str], code: str) -> str:
122
- """One wording per run, never per task, so the prompt cannot give away a
123
- task's kind. With the four token kinds it is the wording of the first batches."""
124
- if set(kinds) <= set(TOKEN_KINDS):
125
- change = "ONE token in it has been changed"
126
- elif "remove-call" in kinds:
127
- change = "ONE small change has been made to it (one line edited, or one line removed)"
128
- else:
129
- change = "ONE small change has been made to one line"
130
- gap = " (for a removed line, the line just before or after the gap)" if "remove-call" in kinds else ""
131
- if hard:
132
- return HARD_PROMPT.format(change=change, gap=gap, code=code)
133
- hints = [KINDS[k] for k in kinds]
134
- hint = hints[0] if len(hints) == 1 else ", ".join(hints[:-1]) + ", or " + hints[-1]
135
- return PROMPT.format(change=change, hint=hint, gap=gap, code=code)
136
-
137
-
138
- def files(repo: pathlib.Path):
139
- """(relative path, "file", every line) for --hard: whole files of 100-400 lines."""
140
- for path in sorted(repo.rglob("*.py")):
141
- rel = path.relative_to(repo)
142
- if any(part.startswith(SKIP_DIRS) for part in rel.parts):
143
- continue
144
- try:
145
- lines = path.read_text(encoding="utf-8").splitlines()
146
- except (UnicodeDecodeError, OSError):
147
- continue
148
- if 100 <= len(lines) <= 400:
149
- yield str(rel).replace("\\", "/"), "file", lines
150
-
151
-
152
- def functions(repo: pathlib.Path):
153
- """(relative path, name, source lines) for every function of 12-60 lines."""
154
- for path in sorted(repo.rglob("*.py")):
155
- rel = path.relative_to(repo)
156
- if any(part.startswith(SKIP_DIRS) for part in rel.parts):
157
- continue
158
- try:
159
- text = path.read_text(encoding="utf-8")
160
- tree = ast.parse(text)
161
- except (SyntaxError, UnicodeDecodeError, OSError):
162
- continue
163
- lines = text.splitlines()
164
- for node in ast.walk(tree):
165
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
166
- start = (node.decorator_list[0].lineno if node.decorator_list else node.lineno) - 1
167
- body = lines[start:node.end_lineno]
168
- if 12 <= len(body) <= 60:
169
- yield str(rel).replace("\\", "/"), node.name, body
170
-
171
-
172
- def candidates(body: list[str], kinds=tuple(KINDS)) -> list[tuple[str, int, int, str, str | None]]:
173
- """(kind, line index, column, old text, new text) for each possible change;
174
- new text None deletes the line."""
175
- src_lines = _dedent(body)
176
- src = "\n".join(src_lines) + "\n"
177
- try:
178
- tree = ast.parse(src)
179
- toks = list(tokenize.generate_tokens(io.StringIO(src).readline))
180
- except (SyntaxError, tokenize.TokenError, IndentationError):
181
- return []
182
- indent = _indent(body)
183
- out = []
184
- for i, t in enumerate(toks):
185
- prev = toks[i - 1].string if i else ""
186
- kind = None
187
- if t.type == tokenize.OP and t.string in FLIP:
188
- kind, old, new = "compare", t.string, FLIP[t.string]
189
- elif t.type == tokenize.NAME and t.string in ("and", "or"):
190
- kind, old, new = "boolop", t.string, FLIP[t.string]
191
- elif t.type == tokenize.NAME and t.string in ("True", "False") and prev == "return":
192
- kind, old, new = "return-bool", t.string, FLIP[t.string]
193
- elif t.type == tokenize.NAME and t.string == "not" and prev in ("if", "elif", "while"):
194
- kind, old, new = "not", "not ", ""
195
- if kind:
196
- out.append((kind, t.start[0] - 1, t.start[1] + indent, old, new))
197
- out += [(k, ln, col + indent, old, new) for k, ln, col, old, new in _ast_candidates(tree, src_lines)]
198
- dead = _dead_lines(tree)
199
- return [c for c in out if c[0] in kinds and c[1] + 1 not in dead]
200
-
201
-
202
- def _ast_candidates(tree: ast.AST, lines: list[str]) -> list[tuple[str, int, int, str, str | None]]:
203
- parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
204
-
205
- def seg(n):
206
- """(line index, start, end, text) of a node that sits on one line."""
207
- if n.lineno != n.end_lineno:
208
- return None
209
- ln = lines[n.lineno - 1]
210
- a, b = _col(ln, n.col_offset), _col(ln, n.end_col_offset)
211
- return n.lineno - 1, a, b, ln[a:b]
212
-
213
- def inside(n, kind):
214
- while n in parent:
215
- n = parent[n]
216
- if isinstance(n, kind):
217
- return True
218
- return False
219
-
220
- out = []
221
- for n in ast.walk(tree):
222
- if isinstance(n, ast.Constant) and type(n.value) is int and abs(n.value) <= 2:
223
- p = parent.get(n)
224
- s = seg(n)
225
- digits = isinstance(p, ast.Call) and ast.unparse(p.func) == "round" and n in p.args[1:]
226
- if (s and s[3] == str(n.value) and not isinstance(p, (ast.keyword, ast.arguments))
227
- and not digits and not inside(n, ast.JoinedStr)):
228
- for v in sorted({n.value + 1, n.value - 1, 0} - {n.value}):
229
- if v > 0 or not isinstance(p, ast.UnaryOp): # no -0, no --1
230
- out.append(("number", s[0], s[1], s[3], str(v)))
231
- if isinstance(n, ast.Call):
232
- name = ast.unparse(n.func)
233
- if name.split(".")[-1] not in SYMMETRIC and not any(isinstance(a, ast.Starred) for a in n.args):
234
- for x, y in zip(n.args, n.args[1:]):
235
- sx, sy = seg(x), seg(y)
236
- if sx and sy and sx[0] == sy[0] and sx[3] != sy[3]:
237
- ln = lines[sx[0]]
238
- out.append(("args", sx[0], sx[1], ln[sx[1]:sy[2]], sy[3] + ln[sx[2]:sy[1]] + sx[3]))
239
- if name == "range" and len(n.args) == 1 and not n.keywords and not isinstance(n.args[0], LOOSE):
240
- s = seg(n.args[0])
241
- if s:
242
- out.append(("off-by-one", s[0], s[1], s[3], _shift(n.args[0], s[3], -1)))
243
- out.append(("off-by-one", s[0], s[1], s[3], f"1, {s[3]}"))
244
- if isinstance(n, ast.Slice) and n.step is None and (n.lower is None) != (n.upper is None):
245
- bound, by = (n.lower, 1) if n.lower is not None else (n.upper, -1)
246
- s = None if isinstance(bound, LOOSE) else seg(bound)
247
- if s:
248
- out.append(("off-by-one", s[0], s[1], s[3], _shift(bound, s[3], by)))
249
- if isinstance(n, (ast.Break, ast.Continue)):
250
- s = seg(n)
251
- if s:
252
- out.append(("break-continue", s[0], s[1], s[3], "continue" if s[3] == "break" else "break"))
253
- if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call) and not QUIET.search(ast.unparse(n.value.func)):
254
- s = seg(n)
255
- if s and lines[s[0]].strip() == s[3]:
256
- p = parent[n]
257
- block = next((getattr(p, f) for f in ("body", "orelse", "finalbody")
258
- if any(x is n for x in getattr(p, f, []))), [n, n])
259
- if len(block) == 1:
260
- out.append(("remove-call", s[0], s[1], s[3], "pass"))
261
- else:
262
- out.append(("remove-call", s[0], 0, lines[s[0]], None))
263
- if isinstance(n, ast.AugAssign) and type(n.op).__name__ in AUG_OP \
264
- and n.target.end_lineno == n.value.lineno == n.lineno:
265
- ln = lines[n.lineno - 1]
266
- op = AUG_OP[type(n.op).__name__]
267
- k = ln.find(op, _col(ln, n.target.end_col_offset), _col(ln, n.value.col_offset))
268
- if k >= 0:
269
- out.append(("augassign", n.lineno - 1, k, op, AUG[op]))
270
- return out
271
-
272
-
273
- def _shift(node: ast.AST, text: str, by: int) -> str:
274
- """`text` moved by one: a literal is computed (`v[:0]`, never the tell-tale
275
- `v[:1 - 1]`), anything else gets `+ 1` / `- 1`."""
276
- value = node.value if isinstance(node, ast.Constant) else None
277
- if isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant) \
278
- and isinstance(node.op, (ast.USub, ast.UAdd)) and type(node.operand.value) is int:
279
- value = -node.operand.value if isinstance(node.op, ast.USub) else node.operand.value
280
- if type(value) is int:
281
- return str(value + by)
282
- return f"{text} {'+' if by > 0 else '-'} 1"
283
-
284
-
285
- def _dead_lines(tree: ast.AST) -> set[int]:
286
- """Lines where a change rarely changes behaviour: an `if __name__ ==
287
- "__main__":` block, and statements after a return/raise/break/continue."""
288
- dead = set()
289
- for n in ast.walk(tree):
290
- if (isinstance(n, ast.If) and isinstance(n.test, ast.Compare)
291
- and isinstance(n.test.left, ast.Name) and n.test.left.id == "__name__"):
292
- dead.update(range(n.lineno, n.end_lineno + 1))
293
- for field in ("body", "orelse", "finalbody"):
294
- stmts = getattr(n, field, None)
295
- if not isinstance(stmts, list):
296
- continue
297
- stop = next((i for i, s in enumerate(stmts)
298
- if isinstance(s, (ast.Return, ast.Raise, ast.Break, ast.Continue))), None)
299
- for s in stmts[stop + 1:] if stop is not None else []:
300
- dead.update(range(s.lineno, s.end_lineno + 1))
301
- return dead
302
-
303
-
304
- def _col(line: str, byte_offset: int) -> int:
305
- """ast columns count UTF-8 bytes; slicing needs characters."""
306
- return len(line.encode("utf-8")[:byte_offset].decode("utf-8", "ignore"))
307
-
308
-
309
- def apply(body: list[str], cand: tuple) -> list[str] | None:
310
- """The changed lines, or None if the text is not where the candidate says."""
311
- _, line, col, old, new = cand
312
- if new is None:
313
- return body[:line] + body[line + 1:]
314
- if body[line][col:col + len(old)] != old:
315
- return None
316
- out = list(body)
317
- out[line] = out[line][:col] + new + out[line][col + len(old):]
318
- return out
319
-
320
-
321
- def answer_lines(cand: tuple, length: int) -> list[int]:
322
- """Lines a correct answer may name: the changed one, or either side of a deleted one."""
323
- _, line, _, _, new = cand
324
- if new is None:
325
- return [n for n in (line, line + 1) if 1 <= n <= length]
326
- return [line + 1]
327
-
328
-
329
- def parses(body: list[str]) -> bool:
330
- try:
331
- ast.parse("\n".join(_dedent(body)) + "\n")
332
- return True
333
- except SyntaxError:
334
- return False
335
-
336
-
337
- def _indent(body: list[str]) -> int:
338
- return len(body[0]) - len(body[0].lstrip())
339
-
340
-
341
- def _dedent(body: list[str]) -> list[str]:
342
- n = _indent(body)
343
- return [ln[n:] if ln[:n].strip() == "" else ln for ln in body]
344
-
345
-
346
- def numbered(body: list[str]) -> str:
347
- width = len(str(len(body)))
348
- return "\n".join(f"{i:>{width}}| {ln}" for i, ln in enumerate(body, 1))
349
-
350
-
351
- def write(task_id: str, spec: dict) -> None:
352
- d = TASKS / task_id
353
- d.mkdir(parents=True, exist_ok=True)
354
- (d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
355
-
356
-
357
- def main() -> None:
358
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
359
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
360
- ap.add_argument("--repo", default=DEFAULT_REPO, choices=sorted(REPOS),
361
- help="a key in config/repos.json")
362
- ap.add_argument("--count", type=int, default=6)
363
- ap.add_argument("--seed", type=int, default=None)
364
- ap.add_argument("--hard", action="store_true",
365
- help="whole files of 100-400 lines, and no hint at the kind of change")
366
- ap.add_argument("--kinds", help=f"comma-separated, from: {', '.join(KINDS)}")
367
- args = ap.parse_args()
368
- wanted = args.kinds.split(",") if args.kinds else (list(KINDS) if args.hard else list(TOKEN_KINDS))
369
- unknown = set(wanted) - set(KINDS)
370
- if unknown:
371
- sys.exit(f"unknown kind(s): {', '.join(sorted(unknown))}; choose from {', '.join(KINDS)}")
372
- kinds = [k for k in KINDS if k in wanted]
373
- rng = random.Random(args.seed)
374
- source_of = files if args.hard else functions
375
- pool = [(rel, name, body, c) for rel, name, body in source_of(REPOS[args.repo])
376
- if (c := candidates(body, kinds))]
377
- taken = {json.loads(p.read_text(encoding="utf-8")).get("source", "")
378
- for p in TASKS.glob("seeded-*/task.json")}
379
- pool = [p for p in pool if f"{p[0]}::{p[1]}" not in taken]
380
- if not pool:
381
- sys.exit("no unused function with a change of these kinds")
382
- for rel, name, body, cands in rng.sample(pool, min(args.count, len(pool))):
383
- bugged = cand = None
384
- while cands and bugged is None:
385
- kind = rng.choice(sorted({c[0] for c in cands})) # kind first, so rare kinds get picked
386
- cand = rng.choice([c for c in cands if c[0] == kind])
387
- cands.remove(cand)
388
- b = apply(body, cand)
389
- bugged = b if b and b != body and parses(b) else None
390
- if bugged is None:
391
- continue
392
- kind, line, _, old, new = cand
393
- slug = ("hard-" if args.hard else "") + re.sub(r"[^a-z0-9]+", "-", f"{pathlib.Path(rel).stem}-{name}".lower()).strip("-")[:40]
394
- source = f"{rel}::{name}"
395
- common = {"draft": True, "shape": "seeded", "repo": args.repo, "source": source,
396
- "score_in": "first_line", "min_answer_chars": 8}
397
- n = line + 1
398
- keys = answer_lines(cand, len(bugged))
399
- change = "(line removed)" if new is None else repr(new.strip() or "(removed)")
400
- write(f"seeded-{slug}-bug", {
401
- **common, "kind": kind, "id": f"seeded-{slug}-bug", "title": "Seeded bug: name the changed line",
402
- "why": (f"Seeded in {source}, line {n} ({kind}): {old.strip()!r} -> {change}.\n"
403
- f" before: {body[line].strip()}\n"
404
- f" after: {'(line removed)' if new is None else bugged[line].strip()}\n"
405
- "Check the change alters behaviour before removing draft."),
406
- "prompt": prompt_for(args.hard, kinds, numbered(bugged)),
407
- "must_find": [{"regex": rf"^\W*fault:\s*line\s*(?:{'|'.join(map(str, keys))})\b",
408
- "label": f"fault: line {' or '.join(map(str, keys))}"}],
409
- "must_not_claim": ["no fault"]})
410
- write(f"seeded-{slug}-control", {
411
- **common, "id": f"seeded-{slug}-control", "title": "Seeded-bug control: say NO FAULT",
412
- "why": f"Untouched {source}; the paired bug task changed line {n}. The answer is NO FAULT.",
413
- "prompt": prompt_for(args.hard, kinds, numbered(body)),
414
- "must_find": [{"any_of": ["no fault"]}],
415
- "must_not_claim": [{"regex": r"^\W*fault:\s*line", "label": "fault: line"}]})
416
- print(f" {source}: line {n} {kind} {old.strip()!r} -> {change} -> seeded-{slug}-bug / -control")
417
- print("drafts written; review each bug task's `why`, then remove \"draft\": true")
418
-
419
-
420
- if __name__ == "__main__":
421
- main()
1
+ """Generate seeded-bug tasks from real private code, each paired with a control.
2
+
3
+ python suite/seed.py --count 6 # six functions -> six bug + six control drafts
4
+ python suite/seed.py --count 6 --seed 42 # reproducible
5
+ python suite/seed.py --repo myapp --count 4 # a key in config/repos.json
6
+ python suite/seed.py --hard --count 4 --kinds args,off-by-one,remove-call
7
+
8
+ WHY
9
+ ---
10
+ Review is the work the bench hands out most, and hand-built review tasks are
11
+ slow to make: every answer key had to be found and verified first. A seeded
12
+ bug has its answer key by construction. Take a real function from a private
13
+ repository — so no model has seen it — change ONE thing so it no longer does
14
+ what its author intended, and ask which line changed. Fresh variants are
15
+ unlimited.
16
+
17
+ EVERY BUG HAS A CONTROL
18
+ -----------------------
19
+ The same function, untouched, where the right answer is NO FAULT. Without it a
20
+ model that always names a line would look like a good reviewer; with it,
21
+ claiming a fault in clean code is scored as invention, which is the failure
22
+ that costs the most.
23
+
24
+ WHAT IS CHANGED
25
+ ---------------
26
+ Ten kinds (--kinds picks some; the run's kind is chosen first, at random, so
27
+ common ones do not crowd out rare ones):
28
+ compare < <= > >= == != flipped to its neighbour
29
+ boolop and <-> or
30
+ return-bool a returned True/False
31
+ not a `not` removed after if/elif/while
32
+ number a small int (-2..2) moved by one or set to 0
33
+ args two adjacent positional arguments swapped
34
+ off-by-one range(n) -> range(n - 1) or range(1, n); x[i:] -> x[i + 1:]; x[:j] -> x[:j - 1]
35
+ break-continue break <-> continue
36
+ remove-call a call statement deleted (`pass` where the block would be empty)
37
+ augassign += <-> -=, *= -> /=
38
+ The first four are single tokens found with `tokenize`; the rest (ideas from
39
+ the operator lists of mutmut and cosmic-ray, no code taken) are found with
40
+ `ast`. Every edit replaces text on one line, or deletes one line, so the
41
+ author's formatting survives: no style tell, the leak the panel warned about.
42
+ Without --kinds, function mode uses the four token kinds (its prompt names
43
+ them) and --hard uses all ten.
44
+
45
+ Changes that rarely alter behaviour are not offered: code in an
46
+ `if __name__ == "__main__":` block or after a return/raise/break/continue,
47
+ keyword-argument and default values, calls to print/logging/sleep, and
48
+ argument swaps for max/min/gcd/lcm/hypot.
49
+
50
+ The prompt numbers the lines, because a model that miscounts has not reviewed
51
+ badly, and asks for `FAULT: line <n>` or `NO FAULT` on the first line, scored
52
+ by exact regex (a deleted line accepts the line before or after the gap).
53
+ Seeded and control tasks are `"shape": "seeded"` and stay out of the routing
54
+ recall for now.
55
+
56
+ DRAFTS
57
+ ------
58
+ Written as drafts. A change can still leave behaviour unchanged (a branch
59
+ that is unreachable, a comparison of equal values), which would make the key
60
+ wrong; read each bug task's `why` before removing "draft": true.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import argparse
66
+ import ast
67
+ import io
68
+ import json
69
+ import pathlib
70
+ import random
71
+ import re
72
+ import sys
73
+ import tokenize
74
+
75
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
76
+ sys.path.insert(0, str(ROOT / "metrics"))
77
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
78
+ TASKS = DATA / "suite" / "tasks"
79
+ sys.path.insert(0, str(ROOT / "suite"))
80
+ from run import DEFAULT_REPO, REPOS # noqa: E402
81
+
82
+ FLIP = {"<": "<=", "<=": "<", ">": ">=", ">=": ">", "==": "!=", "!=": "==",
83
+ "and": "or", "or": "and", "True": "False", "False": "True"}
84
+ SKIP_DIRS = ("test", "migrations", "vendor", "__pycache__", ".venv", "build", "dist")
85
+ # Each kind as the function prompt names it (--hard names none).
86
+ KINDS = {
87
+ "compare": "a comparison operator",
88
+ "boolop": "`and`/`or`",
89
+ "return-bool": "a returned True/False",
90
+ "not": "a removed `not`",
91
+ "number": "a small number",
92
+ "args": "two adjacent arguments swapped",
93
+ "off-by-one": "an off-by-one in a range() or slice",
94
+ "break-continue": "`break` and `continue` swapped",
95
+ "remove-call": "a removed call",
96
+ "augassign": "an augmented assignment operator",
97
+ }
98
+ TOKEN_KINDS = ("compare", "boolop", "return-bool", "not")
99
+ SYMMETRIC = {"max", "min", "gcd", "lcm", "hypot"}
100
+ QUIET = re.compile(r"(^|\.)(print|echo|write|log|debug|info|warn|warning|error|exception|critical|sleep)$")
101
+ AUG = {"+=": "-=", "-=": "+=", "*=": "/="}
102
+ AUG_OP = {"Add": "+=", "Sub": "-=", "Mult": "*="}
103
+ # An expression that `x - 1` would not simply shift by one.
104
+ LOOSE = (ast.IfExp, ast.Lambda, ast.BoolOp, ast.Compare, ast.NamedExpr, ast.Starred)
105
+ # --hard: the whole file, and no hint at what kind of change to look for. The
106
+ # first batch (2026-10-17) saturated: three of four models found 6/6 seeded
107
+ # bugs and passed 6/6 controls with the function alone and the hint.
108
+ HARD_PROMPT = ("The Python file below is either exactly as its author wrote it, or {change}"
109
+ " so that the code no longer does what the author intended."
110
+ " Lines are numbered.\n\n"
111
+ "Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or"
112
+ " `NO FAULT` if nothing was changed. Then explain in at most three sentences."
113
+ " Plain ASCII.\n\n{code}")
114
+ PROMPT = ("The Python function below is either exactly as its author wrote it, or {change}"
115
+ " so that it no longer does what the author intended ({hint}). Lines are numbered.\n\n"
116
+ "Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or `NO FAULT`"
117
+ " if nothing was changed. Then explain in at most three sentences. Plain ASCII.\n\n"
118
+ "{code}")
119
+
120
+
121
+ def prompt_for(hard: bool, kinds: list[str], code: str) -> str:
122
+ """One wording per run, never per task, so the prompt cannot give away a
123
+ task's kind. With the four token kinds it is the wording of the first batches."""
124
+ if set(kinds) <= set(TOKEN_KINDS):
125
+ change = "ONE token in it has been changed"
126
+ elif "remove-call" in kinds:
127
+ change = "ONE small change has been made to it (one line edited, or one line removed)"
128
+ else:
129
+ change = "ONE small change has been made to one line"
130
+ gap = " (for a removed line, the line just before or after the gap)" if "remove-call" in kinds else ""
131
+ if hard:
132
+ return HARD_PROMPT.format(change=change, gap=gap, code=code)
133
+ hints = [KINDS[k] for k in kinds]
134
+ hint = hints[0] if len(hints) == 1 else ", ".join(hints[:-1]) + ", or " + hints[-1]
135
+ return PROMPT.format(change=change, hint=hint, gap=gap, code=code)
136
+
137
+
138
+ def files(repo: pathlib.Path):
139
+ """(relative path, "file", every line) for --hard: whole files of 100-400 lines."""
140
+ for path in sorted(repo.rglob("*.py")):
141
+ rel = path.relative_to(repo)
142
+ if any(part.startswith(SKIP_DIRS) for part in rel.parts):
143
+ continue
144
+ try:
145
+ lines = path.read_text(encoding="utf-8").splitlines()
146
+ except (UnicodeDecodeError, OSError):
147
+ continue
148
+ if 100 <= len(lines) <= 400:
149
+ yield str(rel).replace("\\", "/"), "file", lines
150
+
151
+
152
+ def functions(repo: pathlib.Path):
153
+ """(relative path, name, source lines) for every function of 12-60 lines."""
154
+ for path in sorted(repo.rglob("*.py")):
155
+ rel = path.relative_to(repo)
156
+ if any(part.startswith(SKIP_DIRS) for part in rel.parts):
157
+ continue
158
+ try:
159
+ text = path.read_text(encoding="utf-8")
160
+ tree = ast.parse(text)
161
+ except (SyntaxError, UnicodeDecodeError, OSError):
162
+ continue
163
+ lines = text.splitlines()
164
+ for node in ast.walk(tree):
165
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
166
+ start = (node.decorator_list[0].lineno if node.decorator_list else node.lineno) - 1
167
+ body = lines[start:node.end_lineno]
168
+ if 12 <= len(body) <= 60:
169
+ yield str(rel).replace("\\", "/"), node.name, body
170
+
171
+
172
+ def candidates(body: list[str], kinds=tuple(KINDS)) -> list[tuple[str, int, int, str, str | None]]:
173
+ """(kind, line index, column, old text, new text) for each possible change;
174
+ new text None deletes the line."""
175
+ src_lines = _dedent(body)
176
+ src = "\n".join(src_lines) + "\n"
177
+ try:
178
+ tree = ast.parse(src)
179
+ toks = list(tokenize.generate_tokens(io.StringIO(src).readline))
180
+ except (SyntaxError, tokenize.TokenError, IndentationError):
181
+ return []
182
+ indent = _indent(body)
183
+ out = []
184
+ for i, t in enumerate(toks):
185
+ prev = toks[i - 1].string if i else ""
186
+ kind = None
187
+ if t.type == tokenize.OP and t.string in FLIP:
188
+ kind, old, new = "compare", t.string, FLIP[t.string]
189
+ elif t.type == tokenize.NAME and t.string in ("and", "or"):
190
+ kind, old, new = "boolop", t.string, FLIP[t.string]
191
+ elif t.type == tokenize.NAME and t.string in ("True", "False") and prev == "return":
192
+ kind, old, new = "return-bool", t.string, FLIP[t.string]
193
+ elif t.type == tokenize.NAME and t.string == "not" and prev in ("if", "elif", "while"):
194
+ kind, old, new = "not", "not ", ""
195
+ if kind:
196
+ out.append((kind, t.start[0] - 1, t.start[1] + indent, old, new))
197
+ out += [(k, ln, col + indent, old, new) for k, ln, col, old, new in _ast_candidates(tree, src_lines)]
198
+ dead = _dead_lines(tree)
199
+ return [c for c in out if c[0] in kinds and c[1] + 1 not in dead]
200
+
201
+
202
+ def _ast_candidates(tree: ast.AST, lines: list[str]) -> list[tuple[str, int, int, str, str | None]]:
203
+ parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
204
+
205
+ def seg(n):
206
+ """(line index, start, end, text) of a node that sits on one line."""
207
+ if n.lineno != n.end_lineno:
208
+ return None
209
+ ln = lines[n.lineno - 1]
210
+ a, b = _col(ln, n.col_offset), _col(ln, n.end_col_offset)
211
+ return n.lineno - 1, a, b, ln[a:b]
212
+
213
+ def inside(n, kind):
214
+ while n in parent:
215
+ n = parent[n]
216
+ if isinstance(n, kind):
217
+ return True
218
+ return False
219
+
220
+ out = []
221
+ for n in ast.walk(tree):
222
+ if isinstance(n, ast.Constant) and type(n.value) is int and abs(n.value) <= 2:
223
+ p = parent.get(n)
224
+ s = seg(n)
225
+ digits = isinstance(p, ast.Call) and ast.unparse(p.func) == "round" and n in p.args[1:]
226
+ if (s and s[3] == str(n.value) and not isinstance(p, (ast.keyword, ast.arguments))
227
+ and not digits and not inside(n, ast.JoinedStr)):
228
+ for v in sorted({n.value + 1, n.value - 1, 0} - {n.value}):
229
+ if v > 0 or not isinstance(p, ast.UnaryOp): # no -0, no --1
230
+ out.append(("number", s[0], s[1], s[3], str(v)))
231
+ if isinstance(n, ast.Call):
232
+ name = ast.unparse(n.func)
233
+ if name.split(".")[-1] not in SYMMETRIC and not any(isinstance(a, ast.Starred) for a in n.args):
234
+ for x, y in zip(n.args, n.args[1:]):
235
+ sx, sy = seg(x), seg(y)
236
+ if sx and sy and sx[0] == sy[0] and sx[3] != sy[3]:
237
+ ln = lines[sx[0]]
238
+ out.append(("args", sx[0], sx[1], ln[sx[1]:sy[2]], sy[3] + ln[sx[2]:sy[1]] + sx[3]))
239
+ if name == "range" and len(n.args) == 1 and not n.keywords and not isinstance(n.args[0], LOOSE):
240
+ s = seg(n.args[0])
241
+ if s:
242
+ out.append(("off-by-one", s[0], s[1], s[3], _shift(n.args[0], s[3], -1)))
243
+ out.append(("off-by-one", s[0], s[1], s[3], f"1, {s[3]}"))
244
+ if isinstance(n, ast.Slice) and n.step is None and (n.lower is None) != (n.upper is None):
245
+ bound, by = (n.lower, 1) if n.lower is not None else (n.upper, -1)
246
+ s = None if isinstance(bound, LOOSE) else seg(bound)
247
+ if s:
248
+ out.append(("off-by-one", s[0], s[1], s[3], _shift(bound, s[3], by)))
249
+ if isinstance(n, (ast.Break, ast.Continue)):
250
+ s = seg(n)
251
+ if s:
252
+ out.append(("break-continue", s[0], s[1], s[3], "continue" if s[3] == "break" else "break"))
253
+ if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call) and not QUIET.search(ast.unparse(n.value.func)):
254
+ s = seg(n)
255
+ if s and lines[s[0]].strip() == s[3]:
256
+ p = parent[n]
257
+ block = next((getattr(p, f) for f in ("body", "orelse", "finalbody")
258
+ if any(x is n for x in getattr(p, f, []))), [n, n])
259
+ if len(block) == 1:
260
+ out.append(("remove-call", s[0], s[1], s[3], "pass"))
261
+ else:
262
+ out.append(("remove-call", s[0], 0, lines[s[0]], None))
263
+ if isinstance(n, ast.AugAssign) and type(n.op).__name__ in AUG_OP \
264
+ and n.target.end_lineno == n.value.lineno == n.lineno:
265
+ ln = lines[n.lineno - 1]
266
+ op = AUG_OP[type(n.op).__name__]
267
+ k = ln.find(op, _col(ln, n.target.end_col_offset), _col(ln, n.value.col_offset))
268
+ if k >= 0:
269
+ out.append(("augassign", n.lineno - 1, k, op, AUG[op]))
270
+ return out
271
+
272
+
273
+ def _shift(node: ast.AST, text: str, by: int) -> str:
274
+ """`text` moved by one: a literal is computed (`v[:0]`, never the tell-tale
275
+ `v[:1 - 1]`), anything else gets `+ 1` / `- 1`."""
276
+ value = node.value if isinstance(node, ast.Constant) else None
277
+ if isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant) \
278
+ and isinstance(node.op, (ast.USub, ast.UAdd)) and type(node.operand.value) is int:
279
+ value = -node.operand.value if isinstance(node.op, ast.USub) else node.operand.value
280
+ if type(value) is int:
281
+ return str(value + by)
282
+ return f"{text} {'+' if by > 0 else '-'} 1"
283
+
284
+
285
+ def _dead_lines(tree: ast.AST) -> set[int]:
286
+ """Lines where a change rarely changes behaviour: an `if __name__ ==
287
+ "__main__":` block, and statements after a return/raise/break/continue."""
288
+ dead = set()
289
+ for n in ast.walk(tree):
290
+ if (isinstance(n, ast.If) and isinstance(n.test, ast.Compare)
291
+ and isinstance(n.test.left, ast.Name) and n.test.left.id == "__name__"):
292
+ dead.update(range(n.lineno, n.end_lineno + 1))
293
+ for field in ("body", "orelse", "finalbody"):
294
+ stmts = getattr(n, field, None)
295
+ if not isinstance(stmts, list):
296
+ continue
297
+ stop = next((i for i, s in enumerate(stmts)
298
+ if isinstance(s, (ast.Return, ast.Raise, ast.Break, ast.Continue))), None)
299
+ for s in stmts[stop + 1:] if stop is not None else []:
300
+ dead.update(range(s.lineno, s.end_lineno + 1))
301
+ return dead
302
+
303
+
304
+ def _col(line: str, byte_offset: int) -> int:
305
+ """ast columns count UTF-8 bytes; slicing needs characters."""
306
+ return len(line.encode("utf-8")[:byte_offset].decode("utf-8", "ignore"))
307
+
308
+
309
+ def apply(body: list[str], cand: tuple) -> list[str] | None:
310
+ """The changed lines, or None if the text is not where the candidate says."""
311
+ _, line, col, old, new = cand
312
+ if new is None:
313
+ return body[:line] + body[line + 1:]
314
+ if body[line][col:col + len(old)] != old:
315
+ return None
316
+ out = list(body)
317
+ out[line] = out[line][:col] + new + out[line][col + len(old):]
318
+ return out
319
+
320
+
321
+ def answer_lines(cand: tuple, length: int) -> list[int]:
322
+ """Lines a correct answer may name: the changed one, or either side of a deleted one."""
323
+ _, line, _, _, new = cand
324
+ if new is None:
325
+ return [n for n in (line, line + 1) if 1 <= n <= length]
326
+ return [line + 1]
327
+
328
+
329
+ def parses(body: list[str]) -> bool:
330
+ try:
331
+ ast.parse("\n".join(_dedent(body)) + "\n")
332
+ return True
333
+ except SyntaxError:
334
+ return False
335
+
336
+
337
+ def _indent(body: list[str]) -> int:
338
+ return len(body[0]) - len(body[0].lstrip())
339
+
340
+
341
+ def _dedent(body: list[str]) -> list[str]:
342
+ n = _indent(body)
343
+ return [ln[n:] if ln[:n].strip() == "" else ln for ln in body]
344
+
345
+
346
+ def numbered(body: list[str]) -> str:
347
+ width = len(str(len(body)))
348
+ return "\n".join(f"{i:>{width}}| {ln}" for i, ln in enumerate(body, 1))
349
+
350
+
351
+ def write(task_id: str, spec: dict) -> None:
352
+ d = TASKS / task_id
353
+ d.mkdir(parents=True, exist_ok=True)
354
+ (d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
355
+
356
+
357
+ def main() -> None:
358
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
359
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
360
+ ap.add_argument("--repo", default=DEFAULT_REPO, choices=sorted(REPOS),
361
+ help="a key in config/repos.json")
362
+ ap.add_argument("--count", type=int, default=6)
363
+ ap.add_argument("--seed", type=int, default=None)
364
+ ap.add_argument("--hard", action="store_true",
365
+ help="whole files of 100-400 lines, and no hint at the kind of change")
366
+ ap.add_argument("--kinds", help=f"comma-separated, from: {', '.join(KINDS)}")
367
+ args = ap.parse_args()
368
+ wanted = args.kinds.split(",") if args.kinds else (list(KINDS) if args.hard else list(TOKEN_KINDS))
369
+ unknown = set(wanted) - set(KINDS)
370
+ if unknown:
371
+ sys.exit(f"unknown kind(s): {', '.join(sorted(unknown))}; choose from {', '.join(KINDS)}")
372
+ kinds = [k for k in KINDS if k in wanted]
373
+ rng = random.Random(args.seed)
374
+ source_of = files if args.hard else functions
375
+ pool = [(rel, name, body, c) for rel, name, body in source_of(REPOS[args.repo])
376
+ if (c := candidates(body, kinds))]
377
+ taken = {json.loads(p.read_text(encoding="utf-8")).get("source", "")
378
+ for p in TASKS.glob("seeded-*/task.json")}
379
+ pool = [p for p in pool if f"{p[0]}::{p[1]}" not in taken]
380
+ if not pool:
381
+ sys.exit("no unused function with a change of these kinds")
382
+ for rel, name, body, cands in rng.sample(pool, min(args.count, len(pool))):
383
+ bugged = cand = None
384
+ while cands and bugged is None:
385
+ kind = rng.choice(sorted({c[0] for c in cands})) # kind first, so rare kinds get picked
386
+ cand = rng.choice([c for c in cands if c[0] == kind])
387
+ cands.remove(cand)
388
+ b = apply(body, cand)
389
+ bugged = b if b and b != body and parses(b) else None
390
+ if bugged is None:
391
+ continue
392
+ kind, line, _, old, new = cand
393
+ slug = ("hard-" if args.hard else "") + re.sub(r"[^a-z0-9]+", "-", f"{pathlib.Path(rel).stem}-{name}".lower()).strip("-")[:40]
394
+ source = f"{rel}::{name}"
395
+ common = {"draft": True, "shape": "seeded", "repo": args.repo, "source": source,
396
+ "score_in": "first_line", "min_answer_chars": 8}
397
+ n = line + 1
398
+ keys = answer_lines(cand, len(bugged))
399
+ change = "(line removed)" if new is None else repr(new.strip() or "(removed)")
400
+ write(f"seeded-{slug}-bug", {
401
+ **common, "kind": kind, "id": f"seeded-{slug}-bug", "title": "Seeded bug: name the changed line",
402
+ "why": (f"Seeded in {source}, line {n} ({kind}): {old.strip()!r} -> {change}.\n"
403
+ f" before: {body[line].strip()}\n"
404
+ f" after: {'(line removed)' if new is None else bugged[line].strip()}\n"
405
+ "Check the change alters behaviour before removing draft."),
406
+ "prompt": prompt_for(args.hard, kinds, numbered(bugged)),
407
+ "must_find": [{"regex": rf"^\W*fault:\s*line\s*(?:{'|'.join(map(str, keys))})\b",
408
+ "label": f"fault: line {' or '.join(map(str, keys))}"}],
409
+ "must_not_claim": ["no fault"]})
410
+ write(f"seeded-{slug}-control", {
411
+ **common, "id": f"seeded-{slug}-control", "title": "Seeded-bug control: say NO FAULT",
412
+ "why": f"Untouched {source}; the paired bug task changed line {n}. The answer is NO FAULT.",
413
+ "prompt": prompt_for(args.hard, kinds, numbered(body)),
414
+ "must_find": [{"any_of": ["no fault"]}],
415
+ "must_not_claim": [{"regex": r"^\W*fault:\s*line", "label": "fault: line"}]})
416
+ print(f" {source}: line {n} {kind} {old.strip()!r} -> {change} -> seeded-{slug}-bug / -control")
417
+ print("drafts written; review each bug task's `why`, then remove \"draft\": true")
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()