File size: 10,111 Bytes
898a901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""DPO retrain via AgentTune's closed-loop retrain path (run inside an HF GPU Job).



Consumes the healer's verified pairs, builds agenttune TrainingExample objects, and

trains an adapter-only LoRA through agenttune's own

  examples_to_dpo_dataset -> build_retrainer (create_agentic_trainer) -> train()

exactly as the closed loop's run_retrain does (we call build_retrainer so we can save

the adapter explicitly afterwards).



Train/eval consistency: the student evaluates with the Cohere chat template

(student.build_prompt). examples_to_dpo_dataset flattens prompt messages as

"role: content", BUT with role="" it emits content verbatim - so we pre-apply the

chat template here and hand it the final string. Training sees exactly the text the

model sees at inference.



Launched by launch_job.py, which injects --data/--out/--push-repo/--push-path.

"""

from __future__ import annotations

import argparse
import json
import os
import sys


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", default=None)  # injected by launcher; unused
    ap.add_argument("--out", required=True)
    ap.add_argument("--push-repo", default=None)
    ap.add_argument("--push-path", default=None)
    ap.add_argument("--pairs-repo", default="HideIron/selfheal-sql-runs")
    ap.add_argument("--pairs-path", default="heal/baseline-pool600/pairs.jsonl")
    ap.add_argument("--model", default="CohereLabs/tiny-aya-global")
    ap.add_argument("--adapter-repo", default="HideIron/tiny-aya-global-sql-selfheal")
    ap.add_argument("--epochs", type=float, default=3.0)
    ap.add_argument("--lr", type=float, default=1e-5)
    ap.add_argument("--beta", type=float, default=0.1)
    ap.add_argument("--lora-r", type=int, default=16)
    ap.add_argument("--batch-size", type=int, default=2)
    ap.add_argument("--grad-accum", type=int, default=8)
    ap.add_argument("--max-steps", type=int, default=None, help="smoke-test cap")
    ap.add_argument("--max-prompt-len", type=int, default=1408)
    ap.add_argument("--rpo-alpha", type=float, default=0.0)
    ap.add_argument("--algorithm", default="dpo", choices=["dpo", "bco"])
    ap.add_argument("--replay-run", default=None,
                    help="BCO only: student run whose CORRECT answers are added as "
                         "positive (label=True) replay rows against forgetting")
    ap.add_argument("--no-push-adapter", action="store_true")
    args = ap.parse_args()

    from huggingface_hub import HfApi, hf_hub_download
    from transformers import AutoTokenizer

    from agenttune.decide.closed_loop.contracts import TrainingExample
    from agenttune.decide.closed_loop.retrain_config import RetrainConfig, build_retrainer

    pp = hf_hub_download(args.pairs_repo, args.pairs_path, repo_type="dataset")
    pairs = [json.loads(l) for l in open(pp, encoding="utf-8")]
    healed = [p for p in pairs if p.get("healed") and p.get("chosen_sql")]
    print(f"[dpo] {len(healed)} verified pairs of {len(pairs)} failures", flush=True)

    tok = AutoTokenizer.from_pretrained(args.model)

    def templ(user_content: str) -> str:
        return tok.apply_chat_template(
            [{"role": "user", "content": user_content}],
            add_generation_prompt=True, tokenize=False,
        )

    examples = [
        TrainingExample(
            trajectory_id=p["qid"],
            original_failure_type=p["status_before"],
            root_cause=p["root_cause"],
            # role "" -> _messages_to_text emits content verbatim (no "user: " prefix)
            prompt=[{"role": "", "content": templ(p["prompt_user_content"])}],
            chosen=[{"role": "", "content": p["chosen_sql"]}],
            rejected=[{"role": "", "content": p["rejected_sql"]}],
        )
        for p in healed
    ]

    if args.replay_run:
        assert args.algorithm == "bco", "success replay needs BCO's independent rows"
        import re as _re

        def _extract(t):  # minimal extract_sql (fences + first statement)
            t = _re.sub(r"<think>.*?</think>", "", t, flags=_re.S)
            m = _re.search(r"```(?:sql)?\s*(.*?)```", t, _re.S | _re.I)
            if m:
                t = m.group(1)
            m = _re.search(r"\b(select|with)\b", t, _re.I)
            return t[m.start():].split(";")[0].strip() if m else t.strip()

        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        from student import PROMPT  # the exact student prompt builder

        split_rows = {}
        sp = os.path.join(args.data or "/data", "splits", "pool.jsonl")
        for s in map(json.loads, open(sp, encoding="utf-8")):
            split_rows[s["qid"]] = s
        rp2 = hf_hub_download(args.pairs_repo, f"runs/{args.replay_run}/results.jsonl",
                              repo_type="dataset")
        n0 = len(examples)
        for r in map(json.loads, open(rp2, encoding="utf-8")):
            if r["status"] == "correct" and r["qid"] in split_rows:
                s = split_rows[r["qid"]]
                examples.append(TrainingExample(
                    trajectory_id=r["qid"] + "-replay",
                    original_failure_type="success_replay", root_cause="none",
                    prompt=[{"role": "", "content": templ(
                        PROMPT.format(schema=s["schema"], question=s["question"]))}],
                    chosen=[{"role": "", "content": _extract(r["raw_output"])}],
                ))
        print(f"[dpo] +{len(examples)-n0} success-replay rows (label=True)", flush=True)

    cfg = RetrainConfig(
        model=args.model,
        algorithm=args.algorithm,
        output_dir=os.path.join(args.out, "adapter"),
        lora_r=args.lora_r,
        lora_alpha=args.lora_r * 2,
        lora_dropout=0.05,
        lora_target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                             "gate_proj", "up_proj", "down_proj"],
        num_train_epochs=args.epochs,
        per_device_train_batch_size=args.batch_size,
        gradient_accumulation_steps=args.grad_accum,
        learning_rate=args.lr,
        beta=args.beta,
        max_steps=args.max_steps,
        extra={
            "bf16": True,
            "logging_steps": 2,
            "save_strategy": "no",
            "report_to": "none",
            "gradient_checkpointing": True,
        },
    )
    if args.algorithm == "dpo":
        # The BCO wrapper instantiates the model itself and rejects model_init_kwargs.
        cfg.extra["model_init_kwargs"] = {"dtype": "bfloat16"}

    # Cohere vocab is ~256k: full-sequence fp32 logits are ~4 GiB per copy and OOM a
    # 24 GB card. use_logits_to_keep restricts logits to completion tokens (~60 here).
    # If this TRL lacks it, shrink sequence caps hard instead.
    import inspect

    import trl

    cfg_keys = set(inspect.signature(trl.DPOConfig.__init__).parameters)
    if "use_logits_to_keep" in cfg_keys:
        cfg.extra.update({"use_logits_to_keep": True,
                          "max_prompt_length": 2944, "max_length": 3072})
        print("[dpo] use_logits_to_keep=True (trl", trl.__version__, ")", flush=True)
    else:
        cfg.extra.update({"max_prompt_length": args.max_prompt_len,
                          "max_length": args.max_prompt_len + 128})
        print("[dpo] trl lacks use_logits_to_keep -> caps", args.max_prompt_len, flush=True)
    # rpo_alpha adds an NLL term on the chosen completion (DPO+SFT hybrid): the model
    # must LEARN to produce the verified fix, not merely prefer it. v1 (pure DPO,
    # 30 steps) failed to move pool accuracy at all - this is the main v2 fix.
    if args.rpo_alpha and "rpo_alpha" in cfg_keys:
        cfg.extra["rpo_alpha"] = args.rpo_alpha
        print("[dpo] rpo_alpha =", args.rpo_alpha, flush=True)
    elif args.rpo_alpha:
        print("[dpo] WARNING: trl lacks rpo_alpha; pure DPO", flush=True)

    lens = sorted(len(tok(templ(p["prompt_user_content"]))["input_ids"]) for p in healed)
    print(f"[dpo] prompt tokens: median={lens[len(lens)//2]} p90={lens[int(len(lens)*.9)]} "
          f"max={lens[-1]} | cap={cfg.extra['max_prompt_length']}", flush=True)

    trainer = build_retrainer(examples, cfg)
    stats = trainer.train()
    print("[dpo] train() returned:", str(stats)[:500], flush=True)

    # Save the adapter explicitly (find the underlying HF trainer/model).
    os.makedirs(cfg.output_dir, exist_ok=True)
    inner = None
    for attr in ("trainer", "_trainer", "dpo_trainer"):
        inner = getattr(trainer, attr, None)
        if inner is not None and hasattr(inner, "model"):
            break
    model_obj = getattr(inner, "model", None) or getattr(trainer, "model", None)
    if model_obj is None:
        raise RuntimeError(
            f"cannot locate trained model on {type(trainer).__name__}; attrs: "
            + ", ".join(a for a in dir(trainer) if not a.startswith("__"))[:800]
        )
    model_obj.save_pretrained(cfg.output_dir)  # PeftModel -> adapter only
    print("[dpo] adapter saved:", os.listdir(cfg.output_dir), flush=True)

    with open(os.path.join(args.out, "train_stats.json"), "w", encoding="utf-8") as f:
        json.dump({"n_pairs": len(healed), "stats": str(stats)[:2000],
                   "config": {k: v for k, v in vars(args).items()}}, f, indent=2)

    api = HfApi()
    if not args.no_push_adapter:
        api.create_repo(args.adapter_repo, private=True, exist_ok=True)
        api.upload_folder(repo_id=args.adapter_repo, folder_path=cfg.output_dir,
                          commit_message=f"DPO LoRA ({len(healed)} self-heal pairs)")
        print("[dpo] adapter pushed:", args.adapter_repo, flush=True)
    if args.push_repo:
        api.upload_folder(repo_id=args.push_repo, repo_type="dataset",
                          folder_path=args.out, path_in_repo=args.push_path or "dpo",
                          commit_message="dpo training stats")


if __name__ == "__main__":
    main()