File size: 10,680 Bytes
0572321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Build every dataset Sanad needs, in one go.

    python scripts/make_all_data.py

data/
  operator/  operator_train.jsonl  operator_val.jsonl  operator_eval.jsonl (120 held-out scenarios)
  guardian/  guardian_train.jsonl  guardian_val.jsonl  guardian_eval.jsonl (252 review packets + expected decision)
  verifier/  verifier_train.jsonl  verifier_val.jsonl  verifier_eval.jsonl (re-checks + expected status)
  stats.json counts, label balance, diversity and length
Each agent is trained separately (its own LoRA adapter on the same base model).

Every row: {"messages": [...], "tools": [...], "meta": {"role": ..., ...}}
Names of hosts, VMs, datastores, cluster and vCenter change in every episode.
"""
from __future__ import annotations

import json
import random
import sys
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from sanad_sim import FAULTS, Simulator  # noqa: E402
from sanad_sim.checks import check_grounding  # noqa: E402
from sanad_sim.guardian_data import EVAL_MODES, MODE_CYCLE, GuardianBuilder  # noqa: E402
from sanad_sim.teacher import ScriptedTeacher  # noqa: E402
from sanad_sim.verifier_data import CASE_CYCLE, CASES as V_EVAL_CASES, VerifierBuilder  # noqa: E402

FIDS = list(FAULTS)
SEEDS = {  # role -> split -> base seed (ranges never overlap)
    "operator": {"train": 0, "val": 500_000, "eval": 900_000},
    "guardian": {"train": 1_100_000, "val": 1_500_000, "eval": 1_900_000},
    "verifier": {"train": 2_100_000, "val": 2_500_000, "eval": 2_900_000},
}
SIZES = {"operator": {"train": 70, "val": 8}, "guardian": {"train": 80, "val": 10},
         "verifier": {"train": 40, "val": 5}}
SPECIAL_SIZES = {"train": {"OK00": 150, "UNK00": 100}, "val": {"OK00": 20, "UNK00": 15}}
P_TOOL_ERROR = 0.15


# operator
def operator_row(fid, seed, lang, teacher):
    rng = random.Random(seed * 7919 + 13)
    probe = Simulator(seed).new_episode(fid, lang=lang)
    errs = None
    if fid not in ("UNK00",) and rng.random() < P_TOOL_ERROR:
        errs = [{"tool": probe.inj.evidence[0][0], "call": 1,
                 "error": rng.choice(["timeout after 30s", "connection reset by vCenter", "503 Service Unavailable"])}]
    ep = Simulator(seed).new_episode(fid, lang=lang, tool_errors=errs)
    s = teacher.run(ep, rng)
    fa = s["first_action"]
    if fid == "UNK00":
        if check_grounding(s["final_answer"], ep.trace):
            return None, "grounding"
    else:
        chk = ep.code_checks(s["final_answer"], fa and fa["action"], fa and fa["args"])
        if not chk["pass"]:
            return None, f"checks {[i['type'] for i in chk['issues']]}"
        for action, args in ep.inj.solution:
            ep.execute(action, args)
        if not ep.resolved():
            return None, "fix did not resolve"
    msgs = ep.display(s["messages"])
    # re-check grounding on what the model will actually see (after renaming)
    shown = [{"output": json.loads(m["content"])} for m in msgs if m["role"] == "tool"]
    shown += [{"tool": "propose_action", "args": tc["function"]["arguments"], "output": {}}
              for m in msgs if m["role"] == "assistant" for tc in m.get("tool_calls", [])
              if tc["function"]["name"] == "propose_action"]
    if fid != "UNK00" and check_grounding(msgs[-1]["content"], shown):
        return None, "grounding after rename"
    meta = {"role": "operator", "fault_id": fid, "seed": seed, "lang": lang, "tool_error": bool(errs),
            "ground_truth": ep.display(ep.ground_truth())}
    return {"messages": msgs, "tools": s["tools"], "meta": meta}, None


def build_operator(split, teacher, drops):
    rows, base, n = [], SEEDS["operator"][split], SIZES["operator"][split]
    plan = [(fid, n) for fid in FIDS] + list(SPECIAL_SIZES[split].items())
    for k, (fid, count) in enumerate(plan):
        for i in range(count):
            seed = base + k * 1000 + i
            row, why = operator_row(fid, seed, "ar" if i % 2 else "en", teacher)
            if row:
                rows.append(row)
            else:
                drops[f"operator {fid}: {why}"] += 1
    return rows


# guardian / verifier
FALLBACK_MODES = ["wrong", "policy", "ignored_error", "correct"]


def build_guardian(split, gb, drops):
    rows, base, n = [], SEEDS["guardian"][split], SIZES["guardian"][split]
    for k, fid in enumerate(FIDS):
        for i in range(n):
            seed = base + k * 1000 + i
            lang = "ar" if i % 2 else "en"
            mode = MODE_CYCLE[(i * 7 + k) % len(MODE_CYCLE)]      # spread modes evenly in every split
            row = gb.build(fid, seed, lang, mode)
            for alt in FALLBACK_MODES:
                if row:
                    break
                drops[f"guardian {fid}: {mode} -> {alt}"] += 1
                row = gb.build(fid, seed, lang, alt)
            if row:
                rows.append(row)
    return rows


def build_verifier(split, vb):
    rows, base, n = [], SEEDS["verifier"][split], SIZES["verifier"][split]
    for k, fid in enumerate(FIDS):
        for i in range(n):
            seed = base + k * 1000 + i
            case = CASE_CYCLE[(i * 4 + k) % len(CASE_CYCLE)]
            rows.append(vb.build(fid, seed, "ar" if (i + k) % 2 else "en", case))
    return rows


# eval sets
def build_evals(teacher, gb, vb):
    ev_op = []
    order = FIDS * 2 + random.Random(7).sample(FIDS, 16) + ["OK00"] * 10 + ["UNK00"] * 10
    for j, fid in enumerate(order):
        seed = SEEDS["operator"]["eval"] + j
        lang = "ar" if j % 2 else "en"
        ep = Simulator(seed).new_episode(fid, lang=lang)
        s = teacher.run(Simulator(seed).new_episode(fid, lang=lang), random.Random(seed))
        ev_op.append({"id": f"op-{j:03d}", "fault_id": fid, "seed": seed, "lang": lang,
                      "messages": ep.display(s["messages"][:2]), "tools": s["tools"],
                      "ground_truth": ep.display(ep.ground_truth()),
                      "rebuild": f"Simulator({seed}).new_episode('{fid}', lang='{lang}')"})
    ev_g = []
    for k, fid in enumerate(FIDS):
        for i, mode in enumerate(EVAL_MODES):
            seed = SEEDS["guardian"]["eval"] + k * 1000 + i
            row = gb.build(fid, seed, "ar" if i % 2 else "en", mode)
            for alt in FALLBACK_MODES:
                if row:
                    break
                row = gb.build(fid, seed, "ar" if i % 2 else "en", alt)
            if row:
                ev_g.append({"id": f"g-{len(ev_g):03d}", "fault_id": fid, "seed": seed,
                             "lang": row["meta"]["lang"], "mode": row["meta"]["mode"],
                             "messages": row["messages"][:2], "tools": row["tools"],
                             "expected": json.loads(row["messages"][-1]["content"]), "gold_messages": row["messages"],
                             "meta": row["meta"]})
    ev_v = []
    for k, fid in enumerate(FIDS):
        for i, case in enumerate(V_EVAL_CASES):
            seed = SEEDS["verifier"]["eval"] + k * 1000 + i
            lang = "ar" if (i + k) % 2 else "en"
            row = vb.build(fid, seed, lang, case)
            if row["meta"]["case"] != case:
                continue                                   # this case does not exist for this fault
            ev_v.append({"id": f"v-{len(ev_v):03d}", "fault_id": fid, "seed": seed, "lang": lang, "case": case,
                         "messages": row["messages"][:2], "tools": row["tools"],
                         "expected": json.loads(row["messages"][-1]["content"]), "gold_messages": row["messages"],
                         "meta": row["meta"]})
    return ev_op, ev_g, ev_v


# stats
def tok_est(row) -> int:
    return int(len(json.dumps(row["messages"], ensure_ascii=False)) / 3.2
               + len(json.dumps(row["tools"])) / 3.6)


def diversity(rows):
    users = [r["messages"][1]["content"] for r in rows]
    finals = [r["messages"][-1]["content"] for r in rows]
    return {"unique_requests_pct": round(100 * len(set(users)) / len(users), 1),
            "unique_final_answers_pct": round(100 * len(set(finals)) / len(finals), 1)}


def write(path: Path, rows):
    with open(path, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")


def main():
    out = ROOT / "data"
    for d in ("operator", "guardian", "verifier"):
        (out / d).mkdir(parents=True, exist_ok=True)
    teacher, gb, vb = ScriptedTeacher(), GuardianBuilder(), VerifierBuilder()
    stats, drops = {}, Counter()
    allrows = {}
    for split in ("train", "val"):
        op = build_operator(split, teacher, drops)
        g = build_guardian(split, gb, drops)
        v = build_verifier(split, vb)
        for name, rows in (("operator", op), ("guardian", g), ("verifier", v)):
            write(out / name / f"{name}_{split}.jsonl", rows)
            toks = [tok_est(r) for r in rows]
            labels = Counter(r["meta"].get("label") or r["meta"]["fault_id"][:1] for r in rows)
            stats[f"{name}_{split}"] = {
                "rows": len(rows), "arabic_pct": round(100 * sum(r["meta"]["lang"] == "ar" for r in rows) / len(rows), 1),
                "labels": dict(labels) if name != "operator" else None,
                "tokens_avg": int(sum(toks) / len(toks)), "tokens_max": max(toks), "tokens_total": sum(toks),
                **diversity(rows)}
        allrows[split] = op + g + v
        print(split, {k: v["rows"] for k, v in stats.items() if k.endswith(split)})

    ev_op, ev_g, ev_v = build_evals(teacher, gb, vb)
    write(out / "operator" / "operator_eval.jsonl", ev_op)
    write(out / "guardian" / "guardian_eval.jsonl", ev_g)
    write(out / "verifier" / "verifier_eval.jsonl", ev_v)
    stats["eval"] = {"operator": len(ev_op), "guardian": len(ev_g), "verifier": len(ev_v),
                     "guardian_labels": dict(Counter(e["expected"]["decision"] for e in ev_g)),
                     "verifier_labels": dict(Counter(e["expected"]["status"] for e in ev_v))}
    stats["dropped_or_replaced"] = dict(drops.most_common(25))
    stats["train_total_rows"] = len(allrows["train"])
    stats["train_total_tokens_est"] = sum(tok_est(r) for r in allrows["train"])
    leak = sum("__BADVM__" in json.dumps(r, ensure_ascii=False) for r in allrows["train"])
    stats["placeholder_leaks"] = leak
    (out / "stats.json").write_text(json.dumps(stats, indent=2, ensure_ascii=False))
    print(json.dumps({k: stats[k] for k in ("eval", "train_total_rows", "train_total_tokens_est",
                                             "placeholder_leaks")}, ensure_ascii=False))


if __name__ == "__main__":
    main()