"""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".*?", "", 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()