| """ |
| GRPO training script for the API Contract Validator environment. |
| |
| Designed to be re-runnable by judges from a Colab notebook OR via HF |
| Jobs: |
| |
| # HF Jobs (T4 small, ~$0.50/hr — uses your $30 credit) |
| hf jobs uv run \ |
| --with trl --with unsloth --with openenv-core --with wandb \ |
| --flavor t4-small \ |
| -s HF_TOKEN -s WANDB_API_KEY \ |
| -- python training/train.py |
| |
| The script: |
| |
| 1. Connects to a deployed HF Space (or local docker) running the env |
| 2. Loads a small base model with Unsloth 4-bit quantisation |
| 3. Applies LoRA adapters |
| 4. Rolls out episodes through the env, collecting (prompt, completion, |
| reward) tuples |
| 5. Trains the LoRA adapters with GRPO from TRL |
| 6. Logs reward curves to WandB and writes results/reward_curve.png |
| 7. Pushes the trained adapter to the HuggingFace Hub |
| |
| The reward function uses the env's grader directly — no synthetic |
| shaping. This is the key difference from a static-dataset SFT run: |
| the model learns from the env's verifiable signal, which is exactly |
| what the hackathon's "Improvement in Rewards" criterion rewards. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import sys |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any, Dict, List |
|
|
| import matplotlib.pyplot as plt |
|
|
| |
| ROOT = Path(__file__).resolve().parent.parent |
| sys.path.insert(0, str(ROOT)) |
|
|
| |
| try: |
| from dotenv import load_dotenv |
| _ENV_FILE = ROOT / ".env" |
| if _ENV_FILE.exists(): |
| load_dotenv(_ENV_FILE) |
| except ImportError: |
| pass |
|
|
|
|
| |
|
|
|
|
| @dataclass |
| class TrainConfig: |
| """Training configuration. All fields read from env vars at instantiation. |
| |
| Recommended HF Jobs configurations: |
| |
| Smoke test ($0.30, 5 min): |
| BASE_MODEL=unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit, MAX_STEPS=10, |
| flavor t4-small |
| |
| Main run on L4 ($2.40, ~2 hr): |
| BASE_MODEL=unsloth/Qwen2.5-7B-Instruct-bnb-4bit, MAX_STEPS=300, |
| flavor l4x1 |
| |
| Insurance run on T4 ($0.40, ~45 min): |
| BASE_MODEL=unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit, MAX_STEPS=200, |
| flavor t4-small |
| """ |
|
|
| base_model: str = os.getenv( |
| "BASE_MODEL", "unsloth/Qwen2.5-7B-Instruct-bnb-4bit" |
| ) |
| env_url: str = os.getenv("ENV_URL", "http://localhost:7860") |
| push_to_hub_id: str | None = os.getenv("PUSH_TO_HUB", None) |
| output_dir: str = os.getenv("OUTPUT_DIR", "checkpoints/grpo") |
| results_dir: str = os.getenv( |
| "RESULTS_DIR", str(ROOT / "results") |
| ) |
| seed: int = int(os.getenv("SEED", "42")) |
|
|
| |
| lora_r: int = int(os.getenv("LORA_R", "16")) |
| lora_alpha: int = int(os.getenv("LORA_ALPHA", "32")) |
|
|
| |
| max_seq_length: int = int(os.getenv("MAX_SEQ_LEN", "2048")) |
| num_generations: int = int(os.getenv("NUM_GENERATIONS", "4")) |
| max_steps: int = int(os.getenv("MAX_STEPS", "300")) |
| learning_rate: float = float(os.getenv("LR", "5e-6")) |
| per_device_batch_size: int = int(os.getenv("BATCH_SIZE", "1")) |
| grad_accum: int = int(os.getenv("GRAD_ACCUM", "4")) |
|
|
| |
| train_tasks: List[str] | None = None |
|
|
| |
| wandb_project: str = os.getenv("WANDB_PROJECT", "openenv-contract-guardian") |
| wandb_run: str = os.getenv("WANDB_RUN", "grpo-onsite") |
|
|
|
|
| |
|
|
|
|
| def _list_value(values: Any, index: int, default: Any) -> Any: |
| """Return ``values[index]`` for TRL batch kwargs, with a safe fallback.""" |
| if isinstance(values, list) and index < len(values): |
| return values[index] |
| return default |
|
|
|
|
| def make_reward_fn(env_url: str, task_pool: List[str]): |
| """Return a TRL-compatible reward_fn that grades each completion via env. |
| |
| A fresh ``ValidatorEnv`` (WebSocket) is created per ``reward_fn`` |
| invocation and closed at the end. HF Spaces drops idle WebSockets |
| after ~30 s, but GRPO's model-generation and backprop pauses are |
| longer than that — sharing one WebSocket across batches caused |
| "received 1011 keepalive ping timeout" on every batch after the |
| first. A per-call client adds ~50 ms of TCP setup but eliminates |
| the keepalive failures entirely. |
| |
| Within a single ``reward_fn`` call, all completions are graded |
| through one client (calls are rapid so keepalive is not at risk). |
| """ |
| from inference import _build_action, parse_llm_response |
| import asyncio |
|
|
| from client import ValidatorEnv |
|
|
| def reward_fn(prompts, completions, **kwargs): |
| rewards: List[float] = [] |
| try: |
| loop = asyncio.get_event_loop() |
| if loop.is_closed(): |
| raise RuntimeError("loop closed") |
| except RuntimeError: |
| loop = asyncio.new_event_loop() |
| asyncio.set_event_loop(loop) |
|
|
| task_names = kwargs.get("task") or [] |
| seeds = kwargs.get("seed") or [] |
|
|
| env_client = ValidatorEnv(base_url=env_url) |
| try: |
| for idx, completion in enumerate(completions): |
| text = ( |
| completion |
| if isinstance(completion, str) |
| else completion[0]["content"] |
| ) |
| task_name = _list_value(task_names, idx, task_pool[0]) |
| seed = _list_value(seeds, idx, 0) |
| try: |
| loop.run_until_complete( |
| env_client.reset(task_name=task_name, seed=int(seed)) |
| ) |
| action_data = parse_llm_response(text) |
| action = _build_action(action_data) |
| step_result = loop.run_until_complete( |
| env_client.step(action) |
| ) |
| rewards.append(float(step_result.reward or 0.0)) |
| except Exception as exc: |
| print(f"[WARN] reward_fn error: {exc}") |
| rewards.append(-0.5) |
| finally: |
| try: |
| loop.run_until_complete(env_client.close()) |
| except Exception: |
| pass |
|
|
| return rewards |
|
|
| return reward_fn |
|
|
|
|
| |
|
|
|
|
| def build_train_dataset(env_client, tasks: List[str], episodes_per_task: int = 50): |
| """Roll out reset() to capture initial observations as training prompts. |
| |
| Each row is one episode start. During training, GRPO samples |
| ``num_generations`` completions per prompt and uses the env to |
| grade them. |
| """ |
| import asyncio |
| from datasets import Dataset |
|
|
| from inference import build_user_prompt, _system_prompt_for_phase |
|
|
| rows: List[Dict[str, Any]] = [] |
| loop = asyncio.get_event_loop() |
|
|
| for task in tasks: |
| for ep in range(episodes_per_task): |
| seed = ep |
| result = loop.run_until_complete( |
| env_client.reset(task_name=task, seed=seed) |
| ) |
| obs = result.observation.model_dump() |
| phase = obs.get("phase", "detection") |
| system = _system_prompt_for_phase(phase, task) |
| user = build_user_prompt(obs, step=1, history=[]) |
| rows.append({ |
| "prompt": [ |
| {"role": "system", "content": system}, |
| {"role": "user", "content": user}, |
| ], |
| "task": task, |
| "seed": seed, |
| }) |
|
|
| return Dataset.from_list(rows) |
|
|
|
|
| |
|
|
|
|
| def main() -> None: |
| cfg = TrainConfig() |
|
|
| |
| |
| import asyncio |
| from openenv.core.client_types import StepResult |
|
|
| try: |
| from unsloth import FastLanguageModel |
| except ImportError as exc: |
| sys.exit( |
| "unsloth not installed. Install with: pip install unsloth trl wandb. " |
| f"({exc})" |
| ) |
|
|
| from trl import GRPOConfig, GRPOTrainer |
| import wandb |
|
|
| from client import ValidatorEnv |
|
|
| if os.getenv("WANDB_API_KEY"): |
| wandb.init( |
| project=cfg.wandb_project, |
| name=cfg.wandb_run, |
| config=cfg.__dict__, |
| ) |
|
|
| |
| env = ValidatorEnv(base_url=cfg.env_url) |
| print(f"[INFO] connected to env at {cfg.env_url}") |
|
|
| |
| train_tasks = cfg.train_tasks or [ |
| "find_type_mismatches", |
| "validate_nested_objects", |
| "detect_breaking_changes", |
| "validate_response_schema", |
| "trace_downstream_blast_radius", |
| "propose_backward_compat_fix", |
| ] |
|
|
| |
| print(f"[INFO] building dataset for tasks={train_tasks}") |
| train_dataset = build_train_dataset(env, train_tasks) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import torch |
| use_bf16 = False |
| torch_dtype = torch.float16 |
| print(f"[INFO] mixed precision: fp16 (bf16 disabled due to unsloth LoRA issue)") |
|
|
| |
| |
| |
| |
| |
| print(f"[INFO] loading model: {cfg.base_model}") |
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=cfg.base_model, |
| max_seq_length=cfg.max_seq_length, |
| load_in_4bit=True, |
| dtype=torch_dtype, |
| ) |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=cfg.lora_r, |
| lora_alpha=cfg.lora_alpha, |
| target_modules=[ |
| "q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj", |
| ], |
| random_state=cfg.seed, |
| ) |
|
|
| grpo_cfg = GRPOConfig( |
| output_dir=cfg.output_dir, |
| learning_rate=cfg.learning_rate, |
| per_device_train_batch_size=cfg.per_device_batch_size, |
| gradient_accumulation_steps=cfg.grad_accum, |
| num_generations=cfg.num_generations, |
| max_steps=cfg.max_steps, |
| max_prompt_length=cfg.max_seq_length // 2, |
| max_completion_length=cfg.max_seq_length // 2, |
| logging_steps=1, |
| save_steps=50, |
| report_to="wandb" if os.getenv("WANDB_API_KEY") else "none", |
| bf16=use_bf16, |
| fp16=not use_bf16, |
| ) |
|
|
| reward_fn = make_reward_fn(cfg.env_url, train_tasks) |
|
|
| trainer = GRPOTrainer( |
| model=model, |
| processing_class=tokenizer, |
| reward_funcs=[reward_fn], |
| args=grpo_cfg, |
| train_dataset=train_dataset, |
| ) |
|
|
| |
| print("[INFO] starting GRPO training") |
| trainer.train() |
|
|
| |
| results_dir = Path(cfg.results_dir) |
| results_dir.mkdir(parents=True, exist_ok=True) |
|
|
| history = [ |
| h for h in trainer.state.log_history if "reward" in h |
| ] |
| if history: |
| steps = [h["step"] for h in history] |
| rewards = [h["reward"] for h in history] |
| plt.figure(figsize=(8, 5)) |
| plt.plot(steps, rewards, label="train reward", linewidth=2) |
| plt.xlabel("Training step") |
| plt.ylabel("Mean episode reward") |
| plt.title("GRPO Training — Enterprise Contract Guardian") |
| plt.grid(alpha=0.3) |
| plt.legend() |
| plt.tight_layout() |
| out = results_dir / "reward_curve.png" |
| plt.savefig(out, dpi=150) |
| print(f"[INFO] wrote {out}") |
|
|
| |
| state_path = results_dir / "training_state.json" |
| state_path.write_text(json.dumps(trainer.state.log_history, indent=2)) |
| print(f"[INFO] wrote {state_path}") |
|
|
| |
| |
| |
| |
| |
| |
| if cfg.push_to_hub_id: |
| print(f"[INFO] pushing adapter to {cfg.push_to_hub_id}") |
| model.push_to_hub(cfg.push_to_hub_id, token=os.getenv("HF_TOKEN")) |
|
|
| |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi(token=os.getenv("HF_TOKEN")) |
| for fname in ("reward_curve.png", "training_state.json"): |
| local = results_dir / fname |
| if local.exists(): |
| print(f"[INFO] uploading {fname} -> {cfg.push_to_hub_id}/training_artifacts/{fname}") |
| api.upload_file( |
| path_or_fileobj=str(local), |
| path_in_repo=f"training_artifacts/{fname}", |
| repo_id=cfg.push_to_hub_id, |
| repo_type="model", |
| commit_message=f"Upload {fname} from GRPO run", |
| ) |
| else: |
| print(f"[WARN] {local} not found — skipping upload") |
| except Exception as exc: |
| print(f"[WARN] artefact upload failed: {exc}") |
|
|
| |
| try: |
| asyncio.get_event_loop().run_until_complete(env.close()) |
| except Exception: |
| pass |
|
|
| print("[INFO] done.") |
|
|
| |
| |
| |
| |
| |
| import os as _os |
| _os._exit(0) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|