File size: 38,323 Bytes
4397e12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
"""Workspace tasks with invented facts: the answer exists only in the workspace files.

Each task = a small fake project (configs in yaml/json/toml/env, code, tests, docs, logs, CSVs,
a long job log) built from made-up names and random values, plus one instruction with a
checkable outcome. Kinds:
  lookups      config_value, code_constant, csv_lookup, doc_fact, long_file, code_search
  compute      code_eval (run it), log_count (grep -c), aggregate (count across files), csv_sum
  multi-step   multi_hop (two files), write_fact (look up, then write a file)
  change       edit (change a config value), fix_test (fix a bug until the test passes)
  refusal      not_found (answer NOT_FOUND)
`oracle_trajectory` solves a task with real tool calls in the sandbox; its runs are used as
pretraining/decay data so the base model knows the format before RL.
"""
from __future__ import annotations

import json
import random
import re
import tomllib
from dataclasses import dataclass, field

from tiny_agent.chat import SYSTEM_TA_V1
from tiny_agent.tools import Workspace

KINDS = ["config_value", "code_constant", "code_eval", "log_count", "csv_lookup", "doc_fact",
         "multi_hop", "not_found", "edit", "fix_test", "long_file", "code_search", "aggregate",
         "csv_sum", "write_fact"]
# Never in pretraining trajectories and not trained on by RL by default: eval on these measures
# tool use that transfers to new task shapes, not recall of the training templates.
HELDOUT_KINDS = ("multi_hop", "log_count", "write_fact")
# training-only kinds, kept out of KINDS so the eval set (which cycles through KINDS) never changes.
# They teach primitives the held-out kinds need without copying them: save_value is the only
# demonstration of the write tool (write_fact = config owner -> notes/owner.txt stays held out), and
# service_hop follows a pointer module -> service -> config (multi_hop follows config -> backup host).
EXTRA_TRAIN_KINDS = ["save_value", "service_hop"]
TRAIN_KINDS = [k for k in KINDS if k not in HELDOUT_KINDS] + EXTRA_TRAIN_KINDS
DONE_KINDS = ("edit", "fix_test", "write_fact", "save_value")   # success is a workspace state, answer DONE

_ON = ["b", "d", "f", "g", "k", "l", "m", "n", "p", "r", "s", "t", "v", "z", "br", "dr", "kl", "st", "tr", "qu"]
_NU = ["a", "e", "i", "o", "u", "ai", "ei", "ou"]
_CO = ["", "n", "r", "s", "x", "l", "th", "rk", "nd"]
REGIONS = ["north", "south", "east", "west", "central"]
LEVELS = ["INFO", "INFO", "INFO", "WARN", "ERROR"]
FMTS = ["yaml", "json", "toml", "env"]
JOB_STATUS = ["queued", "running", "done", "failed", "cancelled"]


def word(rng, syll=(2, 3)) -> str:
    return "".join(rng.choice(_ON) + rng.choice(_NU) + rng.choice(_CO) for _ in range(rng.randint(*syll)))


def person(rng) -> str:
    return f"{word(rng, (1, 2)).capitalize()} {word(rng, (2, 3)).capitalize()}"


@dataclass
class Task:
    kind: str
    question: str
    answer: str
    files: dict[str, str]
    meta: dict = field(default_factory=dict)  # facts the oracle and checker need

    def messages(self) -> list[dict]:
        return [{"role": "system", "content": SYSTEM_TA_V1}, {"role": "user", "content": self.question}]


def _unique_words(rng, n, syll=(2, 3)):
    out = set()
    while len(out) < n:
        out.add(word(rng, syll))
    return sorted(out)


# ---------------------------------------------------------------- config formats

def render_config(fmt: str, d: dict) -> str:
    if fmt == "yaml":
        return "".join(f"{k}: {v}\n" for k, v in d.items())
    if fmt == "json":
        return json.dumps(d, indent=2) + "\n"
    if fmt == "toml":
        return "".join(f"{k} = {v}\n" if isinstance(v, int) else f'{k} = "{v}"\n' for k, v in d.items())
    if fmt == "env":
        return "".join(f'{k.upper()}="{v}"\n' if " " in str(v) else f"{k.upper()}={v}\n" for k, v in d.items())
    raise ValueError(fmt)


def config_line(fmt: str, k: str, v) -> str:
    """The exact text holding key k (what an edit replaces)."""
    return {"yaml": f"{k}: {v}", "json": f'"{k}": {v}', "toml": f"{k} = {v}", "env": f"{k.upper()}={v}"}[fmt]


def parse_config(fmt: str, text: str) -> dict[str, str]:
    if fmt == "json":
        return {k: str(v) for k, v in json.loads(text).items()}
    if fmt == "toml":
        return {k: str(v) for k, v in tomllib.loads(text).items()}
    sep = ":" if fmt == "yaml" else "="
    out = {}
    for line in text.splitlines():
        if sep in line:
            k, v = line.split(sep, 1)
            out[k.strip().lower()] = v.strip().strip('"')
    return out


# ---------------------------------------------------------------- buggy-function templates (fix_test)

def _bug_templates(rng, fn):
    a, b, c = rng.randint(1, 30), rng.randint(1, 30), rng.randint(1, 30)
    k = rng.randint(3, 25)
    lim = rng.randint(5, 20)
    vals = [lim - 3, lim, lim + 4, lim - 1, lim]
    tag, num = word(rng, (1, 2)), rng.randint(1, 99)
    return [
        dict(doc="Return the sum of values.", sig="values",
             good="    total = 0\n    for v in values:\n        total += v\n    return total\n",
             bug_old="total += v", bug_new="total -= v",
             tests=[(f"{fn}([{a}, {b}, {c}])", a + b + c), (f"{fn}([])", 0)]),
        dict(doc="Return the sum of 1..n inclusive.", sig="n",
             good="    return sum(range(1, n + 1))\n", bug_old="range(1, n + 1)", bug_new="range(1, n)",
             tests=[(f"{fn}({k})", k * (k + 1) // 2), (f"{fn}(1)", 1)]),
        dict(doc="Count values that are at least limit.", sig="values, limit",
             good="    return sum(1 for v in values if v >= limit)\n", bug_old="v >= limit", bug_new="v > limit",
             tests=[(f"{fn}({vals}, {lim})", sum(v >= lim for v in vals))]),
        dict(doc="Build an id like name-num.", sig="name, num",
             good='    return f"{name}-{num}"\n', bug_old='f"{name}-{num}"', bug_new='f"{name}_{num}"',
             tests=[(f"{fn}('{tag}', {num})", f"{tag}-{num}")]),
    ]


# ---------------------------------------------------------------- project builder

def build_project(rng: random.Random) -> dict:
    svcs = _unique_words(rng, rng.randint(3, 8))
    services, files = {}, {}
    for s in svcs:
        services[s] = dict(port=rng.randint(1024, 65000), timeout_seconds=rng.choice([5, 10, 15, 20, 30, 45, 60, 90]),
                           owner=person(rng), replicas=rng.randint(1, 12), region=rng.choice(REGIONS),
                           fmt=rng.choice(FMTS))
    for s in svcs:
        services[s]["backup_host"] = rng.choice([x for x in svcs if x != s])
    for s, c in services.items():
        d = {"service": s, "port": c["port"], "timeout_seconds": c["timeout_seconds"], "owner": c["owner"],
             "replicas": c["replicas"], "region": c["region"], "backup_host": c["backup_host"]}
        c["file"] = f"config/{s}.{c['fmt']}"
        files[c["file"]] = render_config(c["fmt"], d)
    modules = {}
    for m in _unique_words(rng, rng.randint(2, 4)):
        fn = word(rng, (2, 2))
        a, b = rng.randint(2, 19), rng.randint(-50, 99)
        consts = {"MAX_RETRIES": rng.randint(1, 15), "BATCH_SIZE": rng.choice([8, 16, 32, 64, 100, 128, 250, 512]),
                  "CACHE_TTL": rng.randint(30, 7200)}
        modules[m] = dict(fn=fn, a=a, b=b, consts=consts)
        files[f"src/{m}.py"] = (
            f'"""{m}: helpers for the {rng.choice(svcs)} service."""\n\n'
            + "".join(f"{k} = {v}\n" for k, v in consts.items())
            + f'DEFAULT_REGION = "{rng.choice(REGIONS)}"\n\n\n'
            f"def {fn}(n):\n    \"\"\"Scale n for {m}.\"\"\"\n    return n * {a} + ({b})\n\n\n"
            f"def describe():\n    return \"{m} v{rng.randint(1, 9)}.{rng.randint(0, 20)}\"\n")
    files["src/__init__.py"] = ""
    # one module with a function and a test (buggy only for fix_test tasks)
    tm, tfn = word(rng, (2, 2)), word(rng, (2, 2))
    tpl = rng.choice(_bug_templates(rng, tfn))
    files[f"src/{tm}.py"] = f'"""{tm} utilities."""\n\n\ndef {tfn}({tpl["sig"]}):\n    """{tpl["doc"]}"""\n{tpl["good"]}'
    files[f"tests/test_{tm}.py"] = (
        f"import sys\nsys.path.insert(0, \".\")\nfrom src.{tm} import {tfn}\n\n\ndef main():\n"
        + "".join(f"    assert {call} == {exp!r}, f\"{call} returned {{{call}!r}}, expected {exp!r}\"\n"
                  for call, exp in tpl["tests"])
        + '    print("OK")\n\n\nmain()\n')
    testmod = dict(module=tm, fn=tfn, tpl=tpl)
    components = {c: dict(owner=person(rng), codename=word(rng, (2, 2)).capitalize(),
                          since=rng.randint(2012, 2026)) for c in _unique_words(rng, rng.randint(3, 5))}
    paras = []
    for c, d in components.items():
        paras.append(rng.choice([
            f"The {c} component is owned by {d['owner']}. It was introduced in {d['since']} under the codename {d['codename']}.",
            f"{d['owner']} maintains {c}, which dates from {d['since']}. Internally it is called {d['codename']}.",
            f"Since {d['since']}, {c} (codename {d['codename']}) has been maintained by {d['owner']}.",
            f"{c} was added in {d['since']}. Its codename is {d['codename']} and its maintainer is {d['owner']}.",
        ]))
        paras.append(f"Questions about {c} usually concern {rng.choice(svcs)} latency and {word(rng)} retries.")
    rng.shuffle(paras)
    files["docs/architecture.md"] = "# Architecture\n\n" + "\n\n".join(paras) + "\n"
    logs = {}
    for s in rng.sample(svcs, k=min(2, len(svcs))):
        codes = [f"E{rng.randint(100, 999)}" for _ in range(3)]
        lines = []
        for i in range(rng.randint(40, 160)):
            lvl = rng.choice(LEVELS)
            msg = f"{lvl} code={rng.choice(codes)} {word(rng)} took {rng.randint(1, 900)}ms" if lvl != "INFO" \
                else f"INFO request {word(rng)} ok"
            lines.append(f"2026-09-{rng.randint(1, 30):02d}T{rng.randint(0, 23):02d}:{rng.randint(0, 59):02d}:00 {msg}")
        logs[s] = dict(codes=codes, lines=lines)
        files[f"logs/{s}.log"] = "\n".join(lines) + "\n"
    tables = {}
    for t in _unique_words(rng, rng.randint(1, 2), (2, 2)):
        rows = [dict(id=i + 1, name=w, quantity=rng.randint(0, 500), price=round(rng.uniform(0.5, 99), 2))
                for i, w in enumerate(_unique_words(rng, rng.randint(6, 25), (2, 2)))]
        tables[t] = rows
        files[f"data/{t}.csv"] = "id,name,quantity,price\n" + "".join(
            f"{r['id']},{r['name']},{r['quantity']},{r['price']}\n" for r in rows)
    ids = rng.sample(range(10000, 99999), rng.randint(600, 2500))
    jobs = {i: dict(status=rng.choice(JOB_STATUS), owner=word(rng, (1, 2)), duration=rng.randint(1, 5000)) for i in ids}
    files["data/jobs.txt"] = "".join(f"job-{i} status={j['status']} owner={j['owner']} duration={j['duration']}s\n"
                                     for i, j in jobs.items())
    files["README.md"] = (f"# {word(rng).capitalize()} platform\n\nServices: {', '.join(svcs)}.\n"
                          "Configs live in config/, code in src/, tests in tests/, docs in docs/, logs in logs/, "
                          "data in data/.\n")
    files["scripts/deploy.sh"] = (f"#!/bin/sh\n# deploy all services\nfor s in {' '.join(svcs)}; do\n"
                                  f"  echo \"deploying $s\"\ndone\n")
    return dict(files=files, services=services, modules=modules, components=components, logs=logs,
                tables=tables, testmod=testmod, jobs=jobs)


# ---------------------------------------------------------------- tasks

def _q(rng, *forms):
    return rng.choice(forms)


def make_task(rng: random.Random, kind: str | None = None) -> Task:
    p = build_project(rng)
    kind = kind or rng.choice(KINDS)
    S, M = p["services"], p["modules"]
    F = p["files"]
    if kind == "config_value":
        s = rng.choice(sorted(S))
        key = rng.choice(["port", "timeout_seconds", "owner", "replicas", "region"])
        q = {"port": _q(rng, f"What port does the {s} service listen on?", f"Which port is {s} configured to use?",
                        f"{s} listens on which port?", f"Look up the port number for {s}."),
             "timeout_seconds": _q(rng, f"What is the timeout (in seconds) configured for {s}?",
                                   f"How many seconds is the {s} timeout?", f"What timeout does {s} use?"),
             "owner": _q(rng, f"Who is the owner of the {s} service?", f"Who owns {s}?",
                         f"Which person is listed as owner of {s}?"),
             "replicas": _q(rng, f"How many replicas does {s} run?", f"What is the replica count for {s}?",
                            f"{s} is configured with how many replicas?"),
             "region": _q(rng, f"In which region is {s} deployed?", f"What region does {s} run in?",
                          f"Which region is configured for the {s} service?")}[key]
        return Task(kind, q, str(S[s][key]), F, dict(svc=s, key=key, file=S[s]["file"]))
    if kind == "code_constant":
        m = rng.choice(sorted(M))
        k = rng.choice(sorted(M[m]["consts"]))
        q = _q(rng, f"What is {k} set to in src/{m}.py?", f"What value does the {m} module use for {k}?",
               f"Find the value of {k} in the {m} module.", f"In {m}, what is {k}?")
        return Task(kind, q, str(M[m]["consts"][k]), F, dict(module=m, const=k, file=f"src/{m}.py"))
    if kind == "code_eval":
        m = rng.choice(sorted(M))
        d, n = M[m], rng.randint(2, 40)
        q = _q(rng, f"What does {d['fn']}({n}) in src/{m}.py return?", f"What is the result of {d['fn']}({n})?",
               f"Compute {d['fn']}({n}) using the code in the {m} module.")
        return Task(kind, q, str(n * d["a"] + d["b"]), F, dict(module=m, fn=d["fn"], n=n, file=f"src/{m}.py"))
    if kind == "log_count":
        s = rng.choice(sorted(p["logs"]))
        lg = p["logs"][s]
        code = rng.choice(lg["codes"])
        lvl = rng.choice(["ERROR", "WARN"])
        n = sum(1 for l in lg["lines"] if f" {lvl} " in l and f"code={code}" in l)
        q = _q(rng, f"How many {lvl} lines with code {code} are in logs/{s}.log?",
               f"Count the {lvl} entries with code {code} in the {s} log.")
        return Task(kind, q, str(n), F, dict(svc=s, code=code, level=lvl, file=f"logs/{s}.log"))
    if kind == "csv_lookup":
        t = rng.choice(sorted(p["tables"]))
        r = rng.choice(p["tables"][t])
        col = rng.choice(["quantity", "price"])
        q = _q(rng, f"What is the {col} of {r['name']} in data/{t}.csv?", f"Look up the {col} for {r['name']} in the {t} table.",
               f"In data/{t}.csv, what {col} is listed for {r['name']}?")
        return Task(kind, q, str(r[col]), F, dict(table=t, name=r["name"], col=col, file=f"data/{t}.csv"))
    if kind == "doc_fact":
        c = rng.choice(sorted(p["components"]))
        key = rng.choice(["owner", "codename", "since"])
        q = {"owner": _q(rng, f"According to the docs, who owns the {c} component?", f"Who maintains {c}?"),
             "codename": _q(rng, f"What is the internal codename of {c}?", f"What is {c} called internally?"),
             "since": _q(rng, f"In which year was {c} introduced?", f"When was {c} added? Give the year.")}[key]
        return Task(kind, q, str(p["components"][c][key]), F, dict(component=c, key=key, file="docs/architecture.md"))
    if kind == "multi_hop":
        s = rng.choice(sorted(S))
        b = S[s]["backup_host"]
        key = rng.choice(["port", "owner"])
        q = _q(rng, f"What is the {key} of the service that {s} uses as its backup host?",
               f"{s} has a backup host. What is that host's {key}?")
        return Task(kind, q, str(S[b][key]), F, dict(svc=s, backup=b, key=key, file=S[s]["file"], file2=S[b]["file"]))
    if kind == "not_found":
        ghost = word(rng)
        q = rng.choice([f"What port does the {ghost} service listen on?", f"Who owns the {ghost} component?",
                        f"What is MAX_RETRIES set to in src/{ghost}.py?", f"What is the status of job-{ghost}?"])
        return Task(kind, q, "NOT_FOUND", F, dict(ghost=ghost))
    if kind == "edit":
        s = rng.choice(sorted(S))
        old = S[s]["timeout_seconds"]
        new = rng.choice([v for v in [5, 10, 15, 20, 25, 30, 40, 45, 60, 90, 120] if v != old])
        q = _q(rng, f"Change the timeout_seconds of the {s} service to {new}, then submit DONE.",
               f"Set the {s} timeout to {new} seconds in its config and submit DONE.")
        return Task(kind, q, "DONE", F, dict(svc=s, old=old, new=new, file=S[s]["file"], fmt=S[s]["fmt"]))
    if kind == "fix_test":
        tm = p["testmod"]
        tpl = tm["tpl"]
        path = f"src/{tm['module']}.py"
        F = dict(F)
        F[path] = F[path].replace(tpl["bug_old"], tpl["bug_new"])
        q = _q(rng, f"The test tests/test_{tm['module']}.py fails. Fix the bug in the code (not the test), then submit DONE.",
               f"Make `python3 tests/test_{tm['module']}.py` pass by fixing src/{tm['module']}.py. Submit DONE when it passes.")
        return Task(kind, q, "DONE", F, dict(module=tm["module"], file=path, test=f"tests/test_{tm['module']}.py",
                                             bug_old=tpl["bug_old"], bug_new=tpl["bug_new"]))
    if kind == "long_file":
        jid = rng.choice(sorted(p["jobs"]))
        j = p["jobs"][jid]
        key = rng.choice(["status", "owner", "duration"])
        q = {"status": _q(rng, f"What is the status of job-{jid} in data/jobs.txt?", f"Is job-{jid} done? Give its status."),
             "owner": _q(rng, f"Who is the owner of job-{jid}?", f"Which owner is listed for job-{jid} in data/jobs.txt?"),
             "duration": _q(rng, f"How many seconds did job-{jid} take?", f"What duration (in seconds) is recorded for job-{jid}?")}[key]
        return Task(kind, q, str(j[key]), F, dict(job=jid, key=key, file="data/jobs.txt"))
    if kind == "code_search":
        m = rng.choice(sorted(M))
        fn = M[m]["fn"]
        q = _q(rng, f"Which file defines the function {fn}?", f"Where is {fn} defined? Give the file path.")
        return Task(kind, q, f"src/{m}.py", F, dict(fn=fn, module=m))
    if kind == "aggregate":
        r = rng.choice(sorted({c["region"] for c in S.values()}))
        n = sum(1 for c in S.values() if c["region"] == r)
        q = _q(rng, f"How many services are deployed in the {r} region?", f"Count the services configured for region {r}.")
        return Task(kind, q, str(n), F, dict(region=r))
    if kind == "csv_sum":
        t = rng.choice(sorted(p["tables"]))
        total = sum(r["quantity"] for r in p["tables"][t])
        q = _q(rng, f"What is the total quantity of all items in data/{t}.csv?", f"Sum the quantity column of data/{t}.csv.")
        return Task(kind, q, str(total), F, dict(table=t, file=f"data/{t}.csv"))
    if kind == "write_fact":
        s = rng.choice(sorted(S))
        q = _q(rng, f"Find the owner of the {s} service and write just their name to notes/owner.txt, then submit DONE.",
               f"Write the name of whoever owns {s} into notes/owner.txt and submit DONE.")
        return Task(kind, q, "DONE", F, dict(svc=s, file=S[s]["file"], owner=S[s]["owner"], out="notes/owner.txt"))
    if kind == "save_value":
        src = rng.choice(["csv_sum", "job", "const", "codename"])
        out = rng.choice(["out/result.txt", f"reports/{word(rng, (1, 2))}.txt", "tmp/value.txt", "build/value.txt"])
        if src == "csv_sum":
            t = rng.choice(sorted(p["tables"]))
            val, f1 = sum(r["quantity"] for r in p["tables"][t]), f"data/{t}.csv"
            what = _q(rng, f"the total quantity in data/{t}.csv", f"the sum of the quantity column of the {t} table")
        elif src == "job":
            jid = rng.choice(sorted(p["jobs"]))
            key = rng.choice(["status", "owner", "duration"])
            val, f1 = p["jobs"][jid][key], "data/jobs.txt"
            what = f"the {key} of job-{jid}"
        elif src == "const":
            m = rng.choice(sorted(M))
            key = rng.choice(sorted(M[m]["consts"]))
            val, f1 = M[m]["consts"][key], f"src/{m}.py"
            what = _q(rng, f"the value of {key} in the {m} module", f"{key} from src/{m}.py")
        else:
            c = rng.choice(sorted(p["components"]))
            val, f1 = p["components"][c]["codename"], "docs/architecture.md"
            what = f"the internal codename of the {c} component"
        # worded unlike the held-out write_fact ("Find the owner ... and write just their name to notes/owner.txt")
        q = _q(rng, f"Store {what} in {out}. Reply DONE once the file exists.",
               f"Record {what} as the only content of {out}; then submit DONE.",
               f"Look up {what}. Save that value alone to {out}, then answer DONE.")
        return Task(kind, q, "DONE", F, dict(src=src, value=str(val), file=f1, out=out, what=what))
    if kind == "service_hop":
        m = rng.choice(sorted(M))
        svc = re.search(r"helpers for the (\w+) service", F[f"src/{m}.py"]).group(1)
        key = rng.choice(["port", "replicas", "timeout_seconds", "region"])
        label = {"port": "port", "replicas": "replica count", "timeout_seconds": "timeout in seconds", "region": "region"}[key]
        q = _q(rng, f"The {m} module contains helpers for one of the services. What is that service's {label}?",
               f"Which {label} is configured for the service that src/{m}.py serves?",
               f"src/{m}.py says which service it helps. Report that service's {label}.")
        return Task(kind, q, str(S[svc][key]), F, dict(module=m, svc=svc, key=key, file=f"src/{m}.py", file2=S[svc]["file"]))
    raise ValueError(kind)


_LABEL = {"port": "port", "timeout_seconds": "timeout (seconds)", "owner": "owner", "replicas": "replica count",
          "region": "region", "status": "status", "duration": "duration in seconds", "codename": "codename",
          "since": "year it was introduced", "quantity": "quantity", "price": "price"}
_PREFIX = ["", "", "Quick question: ", "I'm going through this project. ", "For a status report: ",
           "Can you check something for me? ", "Using only the files here: "]
_SUFFIX = ["", "", " Answer with just the value.", " Look it up in the workspace.", " Thanks."]


def vary_question(task: Task, rng: random.Random, p: float = 0.7) -> Task:
    """Training-only rewording (RL and SFT data; the eval keeps make_task's questions): an unseen
    core phrasing with probability p, plus optional framing, so the policy reads the request
    instead of keying on a fixed template."""
    m, k = task.meta, task.kind
    L = lambda key: _LABEL.get(key, key)
    bank = {
        "config_value": lambda: [f"Tell me the configured {L(m['key'])} of {m['svc']}.",
                                 f"I need the {L(m['key'])} setting for the {m['svc']} service.",
                                 f"What does the config say the {L(m['key'])} of {m['svc']} is?",
                                 f"{m['svc']}: what {L(m['key'])} is set?"],
        "code_constant": lambda: [f"Open the {m['module']} source and report the number assigned to {m['const']}.",
                                  f"{m['const']} is defined somewhere in {m['module']}. What's its value?",
                                  f"What number is {m['const']} in src/{m['module']}.py?"],
        "code_eval": lambda: [f"If you call {m['fn']} with {m['n']}, what comes back?",
                              f"Evaluate {m['fn']}({m['n']}) from the {m['module']} module.",
                              f"Run {m['fn']} on {m['n']} and tell me the result."],
        "csv_lookup": lambda: [f"I need {m['name']}'s {m['col']} from the {m['table']} spreadsheet.",
                               f"Report the {m['col']} recorded for the row named {m['name']} in {m['table']}.",
                               f"In the {m['table']} data, what {m['col']} does {m['name']} have?"],
        "doc_fact": lambda: [f"The architecture docs mention {m['component']}. What is its {L(m['key'])}?",
                             f"Check the documentation: {L(m['key'])} of the {m['component']} component?",
                             f"What do the docs list as the {L(m['key'])} for {m['component']}?"],
        "edit": lambda: [f"Update {m['svc']}'s config so its timeout_seconds is {m['new']}. Submit DONE afterwards.",
                         f"{m['svc']} needs a timeout of {m['new']} seconds; change the config and answer DONE."],
        "fix_test": lambda: [f"{m['test']} is failing. Repair the code under test (leave the test alone) and submit DONE.",
                             f"Get {m['test']} passing by fixing src/{m['module']}.py, then answer DONE."],
        "long_file": lambda: [f"There is a record for job-{m['job']} in the jobs list. Report its {L(m['key'])}.",
                              f"For job-{m['job']}, what {L(m['key'])} does data/jobs.txt record?",
                              f"job-{m['job']}: what is its {L(m['key'])}?"],
        "code_search": lambda: [f"In which source file is `{m['fn']}` implemented?",
                                f"Locate the implementation of {m['fn']} and give me the path.",
                                f"Which .py file has the def for {m['fn']}?"],
        "aggregate": lambda: [f"In the {m['region']} region, how many services are there in total?",
                              f"Give me a count of services whose region is {m['region']}.",
                              f"Number of services configured with region {m['region']}?"],
        "csv_sum": lambda: [f"Add up every quantity in data/{m['table']}.csv. What's the total?",
                            f"What do the quantities in the {m['table']} table sum to?"],
        "service_hop": lambda: [f"src/{m['module']}.py helps some service. What {L(m['key'])} does that service have?",
                                f"Find the service the {m['module']} module is written for, then give its {L(m['key'])}."],
    }.get(k)
    q = task.question
    if bank and rng.random() < p:
        q = rng.choice(bank())
    if rng.random() < 0.5:
        pre = rng.choice(_PREFIX)
        suf = rng.choice(_SUFFIX if task.answer not in ("DONE", "NOT_FOUND") else _SUFFIX[:2] + _SUFFIX[3:])
        q = pre + q + suf
    return Task(task.kind, q, task.answer, task.files, task.meta)


def normalize(s: str) -> str:
    s = str(s).strip().strip(".").strip('"').strip("'").strip("`")
    if s.startswith("/work/"):
        s = s[len("/work/"):]
    if s.startswith("./"):
        s = s[2:]
    return re.sub(r"\s+", " ", s).lower()


def _read(ws, rel):
    try:
        return open(f"{ws.root}/{rel}").read()
    except OSError:
        return None


def check(task: Task, submitted: str | None, ws: Workspace | None = None) -> bool:
    if submitted is None:
        return False
    if task.kind in DONE_KINDS:
        if ws is None or normalize(submitted) != "done":
            return False
        m = task.meta
        if task.kind == "edit":
            text = _read(ws, m["file"])
            if text is None:
                return False
            try:
                got, orig = parse_config(m["fmt"], text), parse_config(m["fmt"], task.files[m["file"]])
            except Exception:
                return False
            orig["timeout_seconds"] = str(m["new"])
            return got == orig
        if task.kind == "fix_test":
            if _read(ws, m["test"]) != task.files[m["test"]]:
                return False  # editing the test does not count
            out = ws.tool_bash(f"python3 {m['test']}")
            return out.strip().endswith("OK")
        if task.kind == "write_fact":
            text = _read(ws, m["out"])
            return text is not None and normalize(text) == normalize(m["owner"])
        if task.kind == "save_value":
            text = _read(ws, m["out"])
            return text is not None and normalize(text) == normalize(m["value"])
    return normalize(submitted) == normalize(task.answer)


# ---------------------------------------------------------------- oracle (scripted solver)

def _call(name, **args):
    return {"name": name, "arguments": args}


def _wrong_path(path, rng):
    stem, _, ext = path.rpartition(".")
    if path.startswith("config/"):
        return f"{stem}.{rng.choice([f for f in FMTS + ['yml'] if f != ext])}"
    return {"py": f"{stem}_util.py", "log": f"{stem}.txt", "csv": f"{stem}.tsv"}.get(ext, "notes/" + path.split("/")[-1])


def oracle_plan(task: Task, rng: random.Random) -> list[tuple[str, list[dict]]]:
    """A list of (thought, calls) steps; the final step submits. Results are filled in by running."""
    k, m = task.kind, task.meta
    steps: list[tuple[str, list[dict]]] = []
    if rng.random() < 0.15 and "file" in m and k not in ("fix_test", "long_file"):
        # a wrong first guess, so the data also shows recovering from a tool error
        steps.append(("Let me try the likely file directly.", [_call("read", path=_wrong_path(m["file"], rng))]))
        steps.append(("That path does not exist. List the files to find the right one.",
                      [_call("bash", command=rng.choice(["find . -type f | sort", "ls -R"]))]))
    elif rng.random() < 0.4:
        steps.append(("Let me see what is in the workspace first.",
                      [_call("bash", command=rng.choice(["ls -R", "ls config src docs logs data", "find . -type f | sort",
                                                         "ls; cat README.md"]))]))
    if k == "config_value":
        steps.append(rng.choice([
            (f"The setting for {m['svc']} should be in its config file.", [_call("read", path=m["file"])]),
            (f"I'll grep the config for {m['key']}.", [_call("bash", command=f"grep -in {m['key']} {m['file']}")]),
            (f"I don't know the file name, so search the configs for {m['svc']}.",
             [_call("bash", command=f"grep -rl {m['svc']} config")]),
        ]))
        if steps[-1][1][0]["arguments"].get("command", "").startswith("grep -rl"):
            steps.append((f"Its config is {m['file']}.", [_call("read", path=m["file"])]))
    elif k == "code_constant":
        steps.append((f"{m['const']} should be defined near the top of {m['file']}.",
                      [rng.choice([_call("read", path=m["file"]), _call("bash", command=f"grep -n {m['const']} {m['file']}")])]))
    elif k == "code_eval":
        steps.append((f"I need the definition of {m['fn']}.", [_call("read", path=m["file"])]))
        steps.append(("Running it is more reliable than computing by hand.",
                      [_call("bash", command=f"python3 -c 'from src.{m['module']} import {m['fn']}; print({m['fn']}({m['n']}))'")]))
    elif k == "log_count":
        steps.append(("Count matching lines with grep -c.",
                      [_call("bash", command=f"grep -c '{m['level']} code={m['code']}' {m['file']}")]))
    elif k == "csv_lookup":
        steps.append((f"Find the row for {m['name']} and the header to know which column is which.",
                      [_call("bash", command=f"head -1 {m['file']}; grep -n '{m['name']}' {m['file']}")]))
    elif k == "doc_fact":
        steps.append((f"Search the docs for {m['component']}.", [_call("bash", command=f"grep -n '{m['component']}' {m['file']}")]))
    elif k == "multi_hop":
        steps.append((f"First find which host {m['svc']} uses as backup.", [_call("read", path=m["file"])]))
        steps.append((f"The backup host is {m['backup']}; now read its config.", [_call("read", path=m["file2"])]))
    elif k == "not_found":
        g = m["ghost"]
        steps.append((f"Search everything for {g}.", [_call("bash", command=f"grep -rn '{g}' . | head")]))
    elif k == "edit":
        steps.append(("Read the config before editing it.", [_call("read", path=m["file"])]))
        steps.append(("Replace the timeout value.",
                      [_call("edit", path=m["file"], old_string=config_line(m["fmt"], "timeout_seconds", m["old"]),
                             new_string=config_line(m["fmt"], "timeout_seconds", m["new"]))]))
        steps.append(("Check the change.", [_call("bash", command=f"grep -in timeout {m['file']}")]))
    elif k == "fix_test":
        steps.append(("Run the test to see the failure.", [_call("bash", command=f"python3 {m['test']}")]))
        steps.append(("Read the code under test.", [_call("read", path=m["file"])]))
        steps.append(("The failing assertion points at this line; fix it.",
                      [_call("edit", path=m["file"], old_string=m["bug_new"], new_string=m["bug_old"])]))
        steps.append(("Run the test again.", [_call("bash", command=f"python3 {m['test']}")]))
    elif k == "long_file":
        if rng.random() < 0.6:
            steps.append((f"data/jobs.txt is long, so grep for job-{m['job']}.",
                          [_call("bash", command=f"grep -n 'job-{m['job']} ' {m['file']}")]))
        else:
            steps.append(("The file is long. Find the line number first.",
                          [_call("bash", command=f"grep -n 'job-{m['job']} ' {m['file']} | cut -d: -f1")]))
            # the line number is only known after running; the oracle knows it from the file
            line = next(i for i, l in enumerate(task.files[m["file"]].split("\n"), 1) if l.startswith(f"job-{m['job']} "))
            steps.append(("Read around that line.", [_call("read", path=m["file"], offset=max(1, line - 2), limit=5)]))
    elif k == "code_search":
        steps.append((f"Search for the definition of {m['fn']}.", [_call("bash", command=f"grep -rn 'def {m['fn']}' .")]))
    elif k == "aggregate":
        steps.append(("Configs use different formats, so match the region key case-insensitively and count files.",
                      [_call("bash", command=f"grep -rilE 'region[^a-z]+{m['region']}' config | wc -l")]))
    elif k == "csv_sum":
        cmd = rng.choice([f"awk -F, 'NR>1 {{s+=$3}} END {{print s}}' {m['file']}",
                          f"python3 -c \"import csv; print(sum(int(r['quantity']) for r in csv.DictReader(open('{m['file']}'))))\""])
        steps.append(("Sum the quantity column with a command instead of by hand.", [_call("bash", command=cmd)]))
    elif k == "save_value":
        look = {"csv_sum": (f"Sum the quantity column with a command.",
                            _call("bash", command=f"awk -F, 'NR>1 {{s+=$3}} END {{print s}}' {m['file']}")),
                "job": ("data/jobs.txt is long, so grep for the job.",
                        _call("bash", command=f"grep -n '{m['what'].split()[-1]} ' {m['file']}")),
                "const": (f"Read the module to find the constant.", _call("read", path=m["file"])),
                "codename": ("Search the docs for the component.",
                             _call("bash", command=f"grep -n '{m['what'].split()[-2]}' {m['file']}"))}[m["src"]]
        steps.append((look[0], [look[1]]))
        steps.append((f"The value is {m['value']}. Write it to {m['out']}.", [_call("write", path=m["out"], content=m["value"] + "\n")]))
        steps.append(("Check the file.", [_call("bash", command=f"cat {m['out']}")]))
    elif k == "service_hop":
        steps.append((f"The module docstring names the service it helps.", [_call("bash", command=f"head -3 {m['file']}")]))
        steps.append((f"It helps {m['svc']}. Now read that service's config.", [_call("read", path=m["file2"])]))
    elif k == "write_fact":
        steps.append((f"Look up the owner of {m['svc']}.", [_call("read", path=m["file"])]))
        steps.append(("Write the name to the file.", [_call("write", path=m["out"], content=m["owner"] + "\n")]))
    final = {"not_found": f"Nothing in the workspace mentions {m.get('ghost')}, so the answer is not available.",
             "edit": "The file now has the new timeout.", "fix_test": "The test passes now.",
             "write_fact": "The file has been written.", "save_value": "The file has the value."}.get(k, f"The answer is {task.answer}.")
    steps.append((final, [_call("submit", answer=task.answer)]))
    return steps


def run_trajectory(task: Task, steps, ws: Workspace) -> list[dict]:
    msgs = task.messages()
    for thought, calls in steps:
        msgs.append({"role": "assistant", "think": thought, "content": "", "tool_calls": calls})
        msgs.append({"role": "tool", "results": [ws.call(c["name"], c["arguments"]) for c in calls]})
        if ws.submitted is not None:
            break
    return msgs


def oracle_trajectory(task: Task, rng: random.Random) -> tuple[list[dict], bool]:
    with Workspace(task.files) as ws:
        msgs = run_trajectory(task, oracle_plan(task, rng), ws)
        return msgs, check(task, ws.submitted, ws)


_GUTTER = re.compile(r"^\s*\d+\t", re.M)   # read's line-number column


def _seen(text: str, messages: list[dict]) -> bool:
    ans = normalize(text)
    if not ans:
        return False
    if re.fullmatch(r"-?\d+(\.\d+)?", ans):   # numbers may carry units: "4751s", "12ms"
        pat = re.compile(r"(?<!\d)(?<!\d\.)" + re.escape(ans) + r"(?!\d|\.\d)")
    else:
        pat = re.compile(r"(?<![\w.])" + re.escape(ans) + r"(?![\w])")
    return any(pat.search(normalize(_GUTTER.sub("", r)))
               for m in messages if m["role"] == "tool" for r in m["results"])


def invented(task: Task, submitted, messages: list[dict]) -> bool:
    """A wrong submitted answer that appears in no tool result: recalled or made up instead of read
    (DONE/NOT_FOUND exempt). The thing this model must not do."""
    if submitted is None or normalize(submitted) in ("done", "not_found"):
        return False
    return not _seen(str(submitted), messages)


def grounded(task: Task, messages: list[dict]) -> bool:
    """Is the submitted answer visible in some tool result the agent saw? (NOT_FOUND/DONE exempt.)
    Line numbers from read are stripped and matching is on token boundaries, so an answer like
    "5" does not count as seen just because some file has a line 5."""
    if task.kind == "not_found" or task.kind in DONE_KINDS:
        return True
    return _seen(task.answer, messages)


if __name__ == "__main__":
    rng = random.Random(0)
    for kind in KINDS:
        t = make_task(rng, kind)
        msgs, ok = oracle_trajectory(t, rng)
        print(f"== {kind}: {t.question} -> {t.answer}  oracle_ok={ok} grounded={grounded(t, msgs)}")