trial1 / train_llm.py
priyaaaaaasharmaaaaa's picture
Upload trained LoRA adapter from Kaggle
30af6e8 verified
Raw
History Blame Contribute Delete
52.2 kB
"""
train_llm.py β€” AI Sprint Manager R1+R2 Training
================================================
TRAINING APPROACH: SFT warm-up β†’ GRPO fine-tuning (curriculum)
═══════════════════════════════════════════════════════════════
WHY THIS COMBINATION?
═══════════════════════════════════════════════════════════════
1. SFT WARM-UP (Phase 0, optional but recommended)
─────────────────────────────────────────────────
Why: Cold-start GRPO on Llama-3.1-8B with a random policy can collapse
early because all 4 generations get similar rewards β†’ GRPO gradient is
zero β†’ no learning signal. A brief SFT warm-up (1–2 epochs on rule-based
trajectories) teaches the model the output FORMAT (JSON schema) before
reward-driven exploration begins.
What we SFT on: (observation, rule-based_action) pairs. Rule-based is
NOT the optimal policy β€” it's just good enough to seed valid JSON output.
This is identical to how InstructGPT (and DeepSeek-R1) bootstraps: SFT
first for format, then RLHF/GRPO for quality.
2. GRPO FINE-TUNING (Main training)
──────────────────────────────────
Why GRPO over PPO: No value network required β†’ 40% less GPU memory.
On a T4 (16GB) with 4-bit QLoRA this is the difference between fitting
and OOMing.
Why GRPO over RLVR/RLVE: RLVR (RL with Verifiable Rewards) applies when
the reward is binary (correct/incorrect), e.g. maths problems. Our reward
is continuous and multi-component β†’ GRPO with group-relative normalisation
is a better fit.
GRPO mechanism:
- Sample num_generations=4 completions per prompt
- Each completion is an action JSON
- Call /step on the env β†’ get step_reward
- Compute group baseline = mean(rewards over 4 generations)
- Policy gradient = encourage completions above baseline, penalise below
- KL divergence penalty (beta) prevents policy from drifting too far
from the reference model (anti-reward-hacking measure #1)
3. CURRICULUM LEARNING (Phase both)
──────────────────────────────────
Ratio 2:2 (R1 then R2) per group of 4. R1 tasks (10 steps, simple) give
a denser reward signal early in training; R2 tasks (60 steps, complex)
build long-horizon planning. Without curriculum, GRPO on R2 cold-start
is extremely sample-inefficient.
═══════════════════════════════════════════════════════════════
REWARD DESIGN & ANTI-HACKING MEASURES
═══════════════════════════════════════════════════════════════
R1 REWARD (single sprint, 10 days):
step_reward from /step:
+1.5 to +2.0 : assign task β†’ task completes by deadline
+0.5 to +1.0 : correct skill match
-0.05 : skip (opportunity cost, not catastrophic)
-0.1 : assign already-in_progress task (invalid)
-2.0 to -2.5 : sprint ends with incomplete high-priority task
-0.2 : unknown action type
Normalised for GRPO:
r_norm = clip((step_reward + 3.0) / 5.0, 0, 1)
Shift +3 centres the no-op case at 0.60 so the model sees a gradient
even for neutral actions. Without this shift, most rewards cluster near
0.0 and GRPO training collapses.
ANTI-HACKING: R1 is graded by the graders.py functions which are
SERVER-SIDE and stateful. The agent cannot fake a task completion β€”
the server checks effort remaining, developer availability, deadlines.
R2 REWARD (multi-sprint, 60 days):
Three-level reward structure:
a) Step reward (dense, every action):
Same structure as R1 step reward.
b) Sprint-boundary bonus (every 10 days):
+0.5 per sprint completed above threshold delivery rate.
-0.3 per developer with burnout (productivity < 0.5).
-2.0 per task missed at sprint boundary.
c) Final project score (day 60 only, sparse):
delivery_rate = tasks_completed / tasks_total
team_health = max(0.01, 1.0 - tech_debt_items * 0.02)
final_score = delivery_rate * 0.55
+ instruction_following_score * 0.30
+ team_health * 0.15
Combined training reward:
step_norm = clip((step_reward + 3.0) / 5.0, 0, 1)
combined = step_norm * 0.6 + inst_score * 0.4
Why instruction_following_score as auxiliary reward?
- Without it, GRPO learns to ignore instructions (they're sparse signals).
- inst_score is a running average: 1.0 if agent always acts on active
instructions, 0.0 if it always ignores them.
- Adding it as 0.4 weight makes every step instruction-aware.
ANTI-HACKING MEASURES:
1. KL penalty (beta=0.04): penalises policy that diverges too far from
base model β€” prevents the model from finding degenerate strategies
like outputting skip forever.
2. Reward normalisation per GRPO group: absolute magnitudes don't matter,
only relative ordering within each batch β†’ can't inflate reward by
gaming normalisation scale.
3. Server-side state: the environment server is authoritative. The reward
function can't be gamed client-side because:
- task completion requires server-tracked effort countdown
- instruction_following_score computed by server from actual actions
- tech_debt is server state, can't be cleared by client action
4. Clamping: all scores clamped to [0.01, 0.99] β€” the model can't earn
a 1.0 reward by any single action, so it can't learn a trivial hack.
5. Episode-reset: each reward_fn call resets the environment to a fixed
seed. The model cannot carry state between reward evaluations.
6. Skip penalty (-0.05): prevents "always skip" degenerate policy since
skipping looks cheaper than risking a wrong assignment. The penalty
makes skipping costlier than a bad assignment in the medium term.
7. tech_debt permanent drag: each missed sprint task permanently reduces
a developer's productivity by 2%. This makes short-horizon hacking
(rush tasks to get early reward) self-defeating over 60 days.
FIXES IN THIS VERSION vs. original train_llm.py:
[FIX-T1] reward_fn now resets env BEFORE calling /step (was calling /step
on stale state from a previous episode β€” reward was meaningless).
[FIX-T2] Observation extracted from reset response, not from step (reset
returns obs directly, not wrapped in observation key).
[FIX-T3] SFT warmup phase added (--phase sft or --sft-epochs > 0).
[FIX-T4] Dataset now samples from MIDDLE of episodes (steps 3-8) not just
from step 0 β€” gives the model harder, more diverse states to learn
from. Step-0 prompts are trivially easy and over-represented.
[FIX-T5] Tokenizer pad_token fix: Llama has no pad_token by default β†’
setting to eos_token (standard practice).
[FIX-T6] GRPOConfig: removed unsupported fields for older trl versions;
added graceful version detection.
[FIX-T7] Push uses model.merge_and_unload() before push if Unsloth so
the pushed model is a full weight checkpoint, not just LoRA diff.
[FIX-T8] Neutral fallback reward changed from 0.3 to 0.5 (true neutral)
so env failures don't bias toward low-reward actions.
[FIX-T9] build_grpo_dataset wraps each episode in try/except so a single
HF Space timeout doesn't kill the whole dataset collection.
═══════════════════════════════════════════════════════════════
RECOMMENDED GPU USAGE
═══════════════════════════════════════════════════════════════
Model: Qwen2.5-1.5B for TRAINING (loaded locally β€” no HF router needed).
Llama-3.1-8B for INFERENCE via HF router.
| GPU | VRAM | Batch | Generations | Approx time (300ep) |
|------|------|-------|-------------|---------------------|
| T4 | 16GB | 1 | 2 | ~4-5 hours |
| A10G | 24GB | 2 | 4 | ~2-3 hours |
| A100 | 40GB | 4 | 4 | ~60-90 min |
Colab setup (9 cells) β€” see PROJECT_HANDOFF.md for details.
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import time
from pathlib import Path
from typing import Any, Optional
# ── Env config ────────────────────────────────────────────────────────────────
ENV_BASE_URL = os.getenv("ENV_BASE_URL", "https://sejal-k-ai-sprint-manager.hf.space")
HF_TOKEN = os.getenv("HF_TOKEN", "")
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct") # for LOCAL training
HF_REPO_ID = os.getenv("HF_REPO_ID", "")
# ── GRPO hyperparameters ───────────────────────────────────────────────────────
# Defaults for T4 (16GB). Adjust per GPU via CLI or env override.
GRPO_CONFIG = {
"learning_rate": 5e-6,
"num_train_epochs": 1,
"per_device_train_batch_size": 1, # T4-safe default; double for A10G+
"gradient_accumulation_steps": 8, # effective batch=8 even with bs=1
"max_prompt_length": 1024,
"max_completion_length": 96, # action JSON < 80 tokens; 96 gives margin
"num_generations": 2, # T4-safe; set 4 for A10G+
"temperature": 0.8, # high: encourage diverse candidates
"beta": 0.04, # KL penalty β€” anti-reward-hacking
"logging_steps": 5,
"save_steps": 50,
"warmup_ratio": 0.05,
"seed": 42,
}
# SFT warm-up config
SFT_CONFIG = {
"num_train_epochs": 2,
"per_device_train_batch_size": 2,
"gradient_accumulation_steps": 4,
"learning_rate": 2e-5, # higher than GRPO β€” SFT is supervised
"warmup_ratio": 0.05,
"logging_steps": 5,
"save_steps": 100,
}
R1_TASKS = ["easy_sprint", "medium_sprint", "hard_sprint"]
R2_TASKS = ["project_easy", "project_medium", "project_hard"]
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
# ── System prompts ────────────────────────────────────────────────────────────
# Must match inference_r2.py exactly so training and inference are aligned.
R1_SYSTEM_PROMPT = """You are an expert Tech Lead managing an agile sprint.
Each step output a JSON action with this exact schema:
{"action_type":"<assign|reassign|reprioritize|unblock|skip>","task_id":"<id or null>","dev_id":"<id or null>","new_priority":<1-5 or null>}
Rules:
- assign: backlog task onto available developer (prefer skill match)
- reassign: move in-progress task to different developer
- reprioritize: change priority (1=highest)
- unblock: only for BLOCKED tasks (not backlog)
- skip: do nothing
Output ONLY the JSON. No explanation."""
R2_SYSTEM_PROMPT = """You are an Engineering Manager running a 60-day software project.
Each step you MUST output exactly ONE JSON object and nothing else.
Schema (use null for unused fields):
{"action_type":"<assign|reassign|reprioritize|unblock|skip>","task_id":"<id or null>","dev_id":"<id or null>","new_priority":<1-5 or null>}
Rules (follow in order):
1. If ACTIVE INSTRUCTIONS exist, assign THEIR tasks first.
2. Only assign tasks with status=backlog (never in_progress or done).
3. Only assign if all dependency markers show βœ“.
4. Only assign to an AVAILABLE developer with matching or fullstack skill.
5. Use unblock ONLY for explicitly blocked tasks whose deps are βœ“.
6. skip is last resort.
Output ONLY the JSON. No explanation."""
# ── Rule-based fallback policies ─────────────────────────────────────────────
def smart_fallback_r1(obs: dict) -> dict:
"""R1: assign highest-priority backlog task with skill match."""
tasks = obs.get("tasks", [])
devs = obs.get("developers", [])
avail = [d for d in devs
if d.get("is_available", False) and d.get("current_load", 0) < d.get("capacity", 5)]
backlog = sorted(
[t for t in tasks if t.get("status") == "backlog"],
key=lambda t: (t.get("priority", 9), t.get("deadline", 99))
)
for task in backlog:
skill = task.get("required_skill", "")
match = [d for d in avail if d.get("skill") == skill or d.get("skill") == "fullstack"]
dev = match[0] if match else (avail[0] if avail else None)
if dev:
return {"action_type": "assign", "task_id": task["id"],
"dev_id": dev["id"], "new_priority": None}
return {"action_type": "skip", "task_id": None, "dev_id": None, "new_priority": None}
def smart_fallback_r2(obs: dict, assigned_set: Optional[set] = None) -> dict:
"""
R2: instruction-first, dep-aware, skill-matching fallback.
assigned_set prevents re-assigning tasks already assigned this episode.
"""
if assigned_set is None:
assigned_set = set()
tasks = obs.get("tasks", [])
devs = obs.get("developers", [])
done_ids = {t["id"] for t in tasks if t.get("status") == "done"}
available = [d for d in devs
if d.get("is_available", False)
and d.get("current_load", 0) < d.get("capacity", 5) * 2]
def best_dev(task: dict) -> Optional[dict]:
skill = task.get("required_skill", "")
match = [d for d in available if d.get("skill") == skill or d.get("skill") == "fullstack"]
return match[0] if match else (available[0] if available else None)
def can_assign(task: dict) -> bool:
if task.get("status") != "backlog":
return False
if task["id"] in assigned_set:
return False
deps = task.get("metadata", {}).get("depends_on", [])
return all(d in done_ids for d in deps)
skip = {"action_type": "skip", "task_id": None, "dev_id": None, "new_priority": None}
# 1. Instructions first
active = sorted(
[i for i in obs.get("instruction_queue", []) if not i.get("followed", False)],
key=lambda i: i.get("target_sprint", 99)
)
for inst in active:
for tid in inst.get("affects_tasks", []):
task = next((t for t in tasks if t["id"] == tid), None)
if task and can_assign(task):
dev = best_dev(task)
if dev:
return {"action_type": "assign", "task_id": task["id"],
"dev_id": dev["id"], "new_priority": None}
# 2. Highest-priority backlog with deps met
backlog = sorted(
[t for t in tasks if t.get("status") == "backlog"],
key=lambda t: (t.get("priority", 9), t.get("deadline", 99))
)
for task in backlog:
if can_assign(task):
dev = best_dev(task)
if dev:
return {"action_type": "assign", "task_id": task["id"],
"dev_id": dev["id"], "new_priority": None}
# 3. Unblock
for task in tasks:
if task.get("status") == "blocked":
deps = task.get("metadata", {}).get("depends_on", [])
if all(d in done_ids for d in deps):
return {"action_type": "unblock", "task_id": task["id"],
"dev_id": None, "new_priority": None}
return skip
# ── Action parser ─────────────────────────────────────────────────────────────
_VALID_ACTIONS = {"assign", "reassign", "reprioritize", "skip", "unblock"}
_NULL_STRINGS = {"null", "none", "None", "Null", "", "undefined", "N/A", "nil"}
def _parse_action(text) -> dict:
"""
Parse LLM completion β†’ action dict.
Takes the LAST JSON object in the text (handles chain-of-thought prefix).
FIX: TRL >=0.9 / Unsloth 2026.x passes completions as list[dict] in chat
message format instead of a plain str:
e.g. [{"role": "assistant", "content": '{"action_type":"assign"...}'}]
Extract the assistant content string before any string operations.
"""
if isinstance(text, list):
text = " ".join(
m.get("content", "") for m in text if m.get("role") == "assistant"
)
text = text.strip()
if "```" in text:
text = "\n".join(l for l in text.split("\n") if not l.strip().startswith("```"))
# Find the LAST balanced {...} block
d = None
depth = 0
obj_start = -1
last_start = -1
last_end = -1
for i, ch in enumerate(text):
if ch == "{":
if depth == 0:
obj_start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0 and obj_start >= 0:
last_start = obj_start
last_end = i + 1
if last_start >= 0:
try:
d = json.loads(text[last_start:last_end])
except json.JSONDecodeError:
pass
if d is None:
try:
d = json.loads(text)
except Exception:
pass
if d is None:
return {"action_type": "skip", "task_id": None, "dev_id": None, "new_priority": None}
raw = str(d.get("action_type", "skip")).lower().strip()
d["action_type"] = raw if raw in _VALID_ACTIONS else "skip"
for key in ("task_id", "dev_id", "new_priority"):
val = d.get(key)
if val is not None and str(val).strip() in _NULL_STRINGS:
d[key] = None
if d.get("new_priority") is not None:
try:
p = int(d["new_priority"])
d["new_priority"] = p if 1 <= p <= 5 else None
except (ValueError, TypeError):
d["new_priority"] = None
atype = d["action_type"]
if atype in ("assign", "reassign") and (not d.get("task_id") or not d.get("dev_id")):
d["action_type"] = "skip"
if atype == "reprioritize" and (not d.get("task_id") or d.get("new_priority") is None):
d["action_type"] = "skip"
if atype == "unblock" and not d.get("task_id"):
d["action_type"] = "skip"
return {"action_type": d["action_type"], "task_id": d.get("task_id"),
"dev_id": d.get("dev_id"), "new_priority": d.get("new_priority")}
# ── Prompt builders ───────────────────────────────────────────────────────────
def _build_r1_prompt(obs: dict) -> str:
tasks_summary = "\n".join(
f" [{t['id']}] {t.get('name','?')} | P{t.get('priority','?')} | effort={t.get('effort','?')} "
f"| due=Day{t.get('deadline','?')} | status={t.get('status','?')} | dev={t.get('assigned_to','none')}"
for t in obs.get("tasks", [])
)
devs_summary = "\n".join(
f" [{d['id']}] {d.get('name','?')} | skill={d.get('skill','?')} "
f"| load={d.get('current_load',0)}/{d.get('capacity',5)} | avail={d.get('is_available',False)}"
for d in obs.get("developers", [])
)
return (
f"Day: {obs.get('current_day',1)}/{obs.get('sprint_length',10)}\n"
f"Done:{obs.get('tasks_completed',0)} Missed:{obs.get('tasks_missed',0)} "
f"InProgress:{obs.get('tasks_in_progress',0)} Backlog:{obs.get('tasks_backlog',0)}\n"
f"Cumulative Reward: {obs.get('cumulative_reward',0):.2f}\n\n"
f"TASKS:\n{tasks_summary}\n\nDEVELOPERS:\n{devs_summary}\n\n"
f"Output your JSON action:"
)
def _build_r2_prompt(obs: dict) -> str:
"""Compact R2 prompt matching inference_r2.py format."""
current_sprint = obs.get("current_sprint", 1)
current_day = obs.get("current_day", 1)
days_left = max(0, current_sprint * 10 - current_day + 1)
tasks = obs.get("tasks", [])
done_ids = {t["id"] for t in tasks if t.get("status") == "done"}
active_insts = [i for i in obs.get("instruction_queue", []) if not i.get("followed", False)]
inst_section = (
"⚑FOLLOW: " + " | ".join(f"[{i['id']}] {i['text'][:50]}" for i in active_insts[:2])
) if active_insts else "No instructions."
debt_count = len(obs.get("tech_debt", []))
backlog = sorted([t for t in tasks if t.get("status") == "backlog"],
key=lambda t: (t.get("priority", 9), t.get("deadline", 99)))
in_prog = [t for t in tasks if t.get("status") == "in_progress"]
def fmt(t: dict) -> str:
deps = t.get("metadata", {}).get("depends_on", [])
dep_ok = "βœ“" if all(d in done_ids for d in deps) else "βœ—"
return f"[{t['id']}]P{t.get('priority','?')} {str(t.get('required_skill','?'))[:4]} {dep_ok} D{t.get('deadline','?')}"
backlog_str = " ".join(fmt(t) for t in backlog[:6])
if len(backlog) > 6:
backlog_str += f" +{len(backlog)-6}"
inprog_str = " ".join(f"[{t['id']}]β†’{t.get('assigned_to','?')}" for t in in_prog) or "none"
avail_devs = [d for d in obs.get("developers", []) if d.get("is_available", False)]
devs_str = " ".join(
f"[{d['id']}]{str(d.get('name','?'))[:4]}({str(d.get('skill','?'))[:3]}) "
f"{d.get('current_load',0)}/{d.get('capacity',5)}"
for d in avail_devs
)
return (
f"D{current_day}/60 S{current_sprint}/6 {days_left}d "
f"done={obs.get('tasks_completed',0)} miss={obs.get('tasks_missed',0)} "
f"inst={obs.get('instruction_following_score',0):.2f} debt={debt_count}\n"
f"{inst_section}\n"
f"BACKLOG(βœ“=deps_ok): {backlog_str}\n"
f"IN_PROG: {inprog_str}\n"
f"DEVS(avail): {devs_str}\n"
f"JSON:"
)
# ── GRPO reward functions ─────────────────────────────────────────────────────
#
# ANTI-HACKING design:
# - Each reward_fn call resets the environment to a FIXED seed before stepping.
# The model cannot exploit carry-over state between generations.
# - Reward is normalised within the GRPO group (not absolute), so inflating
# one generation's reward doesn't help unless it's relatively better.
# - KL penalty (beta=0.04) prevents the policy from drifting to degenerate
# strategies (e.g. "always assign T01 to dev1" for a cheap reward spike).
# - Neutral fallback = 0.5 (true neutral), not 0.3, so env failures don't
# bias learning toward low-reward actions.
def make_reward_fn(env_base_url: str, phase: str):
"""
Returns a GRPO reward function evaluating completions against the live env.
[FIX-T1] Resets env immediately before stepping β€” the env MUST be in the
correct initial state when we evaluate each action. Previously, the reset
was called but the obs was ignored and /step ran on whatever the env's
current state was (potentially mid-episode from a previous call).
"""
import requests
episode_counter = [0]
def _post(url: str, payload: dict) -> dict:
resp = requests.post(url, json=payload, timeout=60)
resp.raise_for_status()
return resp.json()
def reward_fn(prompts, completions, **kwargs) -> list[float]:
rewards = []
for prompt, completion in zip(prompts, completions):
episode_counter[0] += 1
n = episode_counter[0]
# Curriculum: alternating R1/R2 (2:2 ratio for "both" phase)
if phase == "r1":
use_r2 = False
elif phase == "r2":
use_r2 = True
else:
use_r2 = (n % 4 >= 2)
action = _parse_action(completion)
r = 0.5 # true-neutral fallback [FIX-T8]
try:
if not use_r2:
# [FIX-T1] Reset, then step
task = R1_TASKS[n % len(R1_TASKS)]
_post(f"{env_base_url}/reset",
{"task_name": task, "seed": n % 100})
result = _post(f"{env_base_url}/step", {"action": action})
step_r = float(result.get("reward", 0.0))
# Normalise R1 reward: [-3,+2] β†’ [0,1]
r = max(0.0, min(1.0, (step_r + 3.0) / 5.0))
else:
# [FIX-T1] Reset project, then step
task = R2_TASKS[n % len(R2_TASKS)]
_post(f"{env_base_url}/project/reset",
{"task_name": task, "seed": n % 100})
result = _post(f"{env_base_url}/project/step",
{"action": action})
step_r = float(result.get("reward", 0.0))
obs2 = result.get("observation", {})
inst_score = float(obs2.get("instruction_following_score", 0.5))
step_norm = max(0.0, min(1.0, (step_r + 3.0) / 5.0))
# Combined: step quality (60%) + instruction compliance (40%)
# Anti-hacking: inst_score is server-side computed running avg;
# can't be faked by the model outputting anything in particular.
r = step_norm * 0.6 + inst_score * 0.4
except Exception as e:
print(f"[WARN] reward_fn env call failed: {e}", flush=True)
r = 0.5 # [FIX-T8] true neutral
rewards.append(float(r))
return rewards
return reward_fn
# ── Dataset builder ───────────────────────────────────────────────────────────
#
# [FIX-T4] Samples from MIDDLE of episodes (skip first N steps).
# The first 1-2 steps are trivially easy (full backlog, no instructions yet).
# Sampling from steps 3+ gives the model harder, more diverse training states.
# [FIX-T9] Each episode wrapped in try/except β€” HF Space timeouts don't kill collection.
def build_grpo_dataset(n_examples: int = 200, phase: str = "both"):
"""
Build a HuggingFace Dataset of (prompt) examples.
Each prompt is a serialised chat message list.
The LLM is NOT called β€” only rule-based policy advances the game.
"""
try:
from datasets import Dataset
except ImportError:
print("[ERROR] datasets not installed. Run: pip install datasets", flush=True)
sys.exit(1)
import requests
def post(url: str, payload: dict) -> dict:
r = requests.post(url, json=payload, timeout=60)
r.raise_for_status()
return r.json()
examples = []
tasks_r1 = R1_TASKS if phase in ("r1", "both") else []
tasks_r2 = R2_TASKS if phase in ("r2", "both") else []
per_task = max(1, n_examples // max(1, len(tasks_r1) + len(tasks_r2)))
SKIP_STEPS_R1 = 1 # skip first step (trivial full-backlog state)
SKIP_STEPS_R2 = 2 # skip first 2 steps (instructions not yet released)
SAMPLE_PER_EP = 6 # states to sample per episode
# ── Collect R1 snapshots ──────────────────────────────────────────────────
for task_name in tasks_r1:
print(f" [DATASET] R1 {task_name} Γ— {per_task} episodes...", flush=True)
for ep in range(per_task):
try:
obs = post(f"{ENV_BASE_URL}/reset", {"task_name": task_name, "seed": ep})
# [FIX-T4] Advance past trivial early steps
for _ in range(SKIP_STEPS_R1):
if obs.get("done", False):
break
action = smart_fallback_r1(obs)
result = post(f"{ENV_BASE_URL}/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
for step in range(SAMPLE_PER_EP):
if obs.get("done", False):
break
prompt = _build_r1_prompt(obs)
examples.append({
"prompt": [
{"role": "system", "content": R1_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
})
action = smart_fallback_r1(obs)
result = post(f"{ENV_BASE_URL}/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
except Exception as e:
print(f" [WARN] R1 ep{ep} failed: {e}", flush=True) # [FIX-T9]
# ── Collect R2 snapshots ──────────────────────────────────────────────────
for task_name in tasks_r2:
print(f" [DATASET] R2 {task_name} Γ— {per_task} episodes...", flush=True)
for ep in range(per_task):
try:
obs = post(f"{ENV_BASE_URL}/project/reset",
{"task_name": task_name, "seed": ep})
assigned_set: set[str] = set()
# [FIX-T4] Advance past trivial initial state
for _ in range(SKIP_STEPS_R2):
if obs.get("done", False):
break
action = smart_fallback_r2(obs, assigned_set)
if action["action_type"] == "assign" and action.get("task_id"):
assigned_set.add(action["task_id"])
result = post(f"{ENV_BASE_URL}/project/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
for step in range(SAMPLE_PER_EP):
if obs.get("done", False):
break
prompt = _build_r2_prompt(obs)
examples.append({
"prompt": [
{"role": "system", "content": R2_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
})
action = smart_fallback_r2(obs, assigned_set)
if action["action_type"] == "assign" and action.get("task_id"):
assigned_set.add(action["task_id"])
result = post(f"{ENV_BASE_URL}/project/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
except Exception as e:
print(f" [WARN] R2 ep{ep} failed: {e}", flush=True) # [FIX-T9]
print(f" [DATASET] Total examples: {len(examples)}", flush=True)
if not examples:
print("[ERROR] Dataset is empty β€” check server connectivity", flush=True)
sys.exit(1)
return Dataset.from_list(examples)
# ── SFT dataset builder ────────────────────────────────────────────────────────
# [FIX-T3] Used for SFT warm-up phase. Adds 'completion' field (the rule-based action).
def build_sft_dataset(n_examples: int = 100, phase: str = "both"):
"""
Build a supervised fine-tuning dataset with (prompt, completion) pairs.
The completion is the rule-based action β€” good enough to teach JSON format.
"""
try:
from datasets import Dataset
except ImportError:
sys.exit(1)
import requests
def post(url: str, payload: dict) -> dict:
r = requests.post(url, json=payload, timeout=60)
r.raise_for_status()
return r.json()
examples = []
tasks_r1 = R1_TASKS if phase in ("r1", "both") else []
tasks_r2 = R2_TASKS if phase in ("r2", "both") else []
per_task = max(1, n_examples // max(1, len(tasks_r1) + len(tasks_r2)))
SAMPLE_PER = 4
for task_name in tasks_r1:
for ep in range(per_task):
try:
obs = post(f"{ENV_BASE_URL}/reset", {"task_name": task_name, "seed": ep + 1000})
for _ in range(SAMPLE_PER):
if obs.get("done", False):
break
action = smart_fallback_r1(obs)
prompt = _build_r1_prompt(obs)
completion = json.dumps(action)
examples.append({
"prompt": [
{"role": "system", "content": R1_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"completion": completion,
})
result = post(f"{ENV_BASE_URL}/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
except Exception as e:
print(f" [WARN] SFT R1 ep{ep} failed: {e}", flush=True)
for task_name in tasks_r2:
for ep in range(per_task):
try:
obs = post(f"{ENV_BASE_URL}/project/reset",
{"task_name": task_name, "seed": ep + 1000})
assigned: set[str] = set()
for _ in range(SAMPLE_PER):
if obs.get("done", False):
break
action = smart_fallback_r2(obs, assigned)
if action["action_type"] == "assign" and action.get("task_id"):
assigned.add(action["task_id"])
prompt = _build_r2_prompt(obs)
completion = json.dumps(action)
examples.append({
"prompt": [
{"role": "system", "content": R2_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"completion": completion,
})
result = post(f"{ENV_BASE_URL}/project/step", {"action": action})
obs = result.get("observation", obs)
if result.get("done", False):
break
except Exception as e:
print(f" [WARN] SFT R2 ep{ep} failed: {e}", flush=True)
print(f" [SFT DATASET] Total examples: {len(examples)}", flush=True)
return Dataset.from_list(examples)
# ── Model loader ───────────────────────────────────────────────────────────────
def load_model_and_tokenizer(model_name: str):
"""
Load model with Unsloth 4-bit QLoRA. Falls back to HF+PEFT if unavailable.
[FIX-T5] Sets pad_token = eos_token for Llama/Qwen (they have no pad token by default).
"""
try:
from unsloth import FastLanguageModel
print(f"[INFO] Loading {model_name} with Unsloth 4-bit QLoRA...", flush=True)
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=model_name,
max_seq_length=2048,
dtype=None,
load_in_4bit=True,
token=HF_TOKEN or None,
)
model = FastLanguageModel.get_peft_model(
model,
r=16,
target_modules=["q_proj", "v_proj", "k_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_alpha=32,
lora_dropout=0.05,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=42,
)
# [FIX-T5]
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"[INFO] Unsloth loaded. Trainable params: {n_trainable:,}", flush=True)
return model, tokenizer, "unsloth"
except ImportError:
print("[WARN] Unsloth not available. Falling back to HF + PEFT.", flush=True)
return _load_hf_model(model_name)
def _load_hf_model(model_name: str):
try:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import get_peft_model, LoraConfig, TaskType
import torch
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_name, token=HF_TOKEN or None)
# [FIX-T5]
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name, quantization_config=bnb, device_map="auto",
token=HF_TOKEN or None,
)
lora_cfg = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "v_proj", "k_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_cfg)
print("[INFO] HF+PEFT model loaded.", flush=True)
return model, tokenizer, "hf"
except Exception as e:
print(f"[ERROR] Cannot load model: {e}", flush=True)
sys.exit(1)
# ── SFT trainer ────────────────────────────────────────────────────────────────
def run_sft(model, tokenizer, phase: str, n_examples: int, output_dir: str):
"""
[FIX-T3] SFT warm-up: teach the model JSON format before GRPO exploration.
Uses TRL SFTTrainer with the rule-based (obs, action) pairs.
"""
print(f"\n[SFT] Warm-up phase ({n_examples} examples)...", flush=True)
try:
from trl import SFTTrainer, SFTConfig
except ImportError:
print("[WARN] SFTTrainer not in this trl version β€” skipping SFT warm-up", flush=True)
return model
sft_data = build_sft_dataset(n_examples=n_examples, phase=phase)
def format_fn(example):
"""Convert chat messages + completion to a single formatted string."""
parts = []
for msg in example["prompt"]:
parts.append(f"<|{msg['role']}|>\n{msg['content']}")
parts.append(f"<|assistant|>\n{example['completion']}")
return {"text": "\n".join(parts)}
sft_data = sft_data.map(format_fn)
# FIX: Unsloth SFTTrainer (2026.x) detects "prompt"+"completion" columns
# and routes to _tokenize_pc which does list+str -> TypeError.
# Drop them so SFTTrainer only sees "text" and uses dataset_text_field path.
cols_to_drop = [c for c in ["prompt", "completion"] if c in sft_data.column_names]
if cols_to_drop:
sft_data = sft_data.remove_columns(cols_to_drop)
sft_dir = str(Path(output_dir) / "sft_warmup")
sft_conf = SFTConfig(
output_dir=sft_dir,
dataset_text_field="text",
max_seq_length=1024,
report_to="none",
**SFT_CONFIG,
)
trainer = SFTTrainer(
model=model,
processing_class=tokenizer,
train_dataset=sft_data,
args=sft_conf,
)
trainer.train()
print("[SFT] Warm-up complete.", flush=True)
return trainer.model
# ── GRPO trainer ───────────────────────────────────────────────────────────────
def train(
phase: str = "both",
n_dataset_examples: int = 200,
output_dir: str = "results/trained_model",
push_to_hub: bool = False,
sft_epochs: int = 0, # 0 = skip SFT warm-up
gpu_tier: str = "t4", # "t4", "a10g", or "a100"
):
print(f"\n{'='*60}", flush=True)
print(f" GRPO TRAINING β€” Phase: {phase.upper()} | GPU: {gpu_tier.upper()}", flush=True)
print(f" Model: {MODEL_NAME}", flush=True)
print(f" Server: {ENV_BASE_URL}", flush=True)
print(f" SFT warm-up epochs: {sft_epochs}", flush=True)
print(f"{'='*60}\n", flush=True)
# Adjust config for GPU tier
cfg = dict(GRPO_CONFIG)
if gpu_tier == "a10g":
cfg["per_device_train_batch_size"] = 2
cfg["num_generations"] = 4
elif gpu_tier == "a100":
cfg["per_device_train_batch_size"] = 4
cfg["num_generations"] = 4
cfg["gradient_accumulation_steps"] = 4
# 1. Load model
model, tokenizer, backend = load_model_and_tokenizer(MODEL_NAME)
# 2. SFT warm-up (optional)
if sft_epochs > 0:
sft_n = max(50, n_dataset_examples // 3)
SFT_CONFIG["num_train_epochs"] = sft_epochs
model = run_sft(model, tokenizer, phase, sft_n, output_dir)
# 3. Build GRPO dataset
print("[INFO] Building GRPO training dataset...", flush=True)
dataset = build_grpo_dataset(n_examples=n_dataset_examples, phase=phase)
# 4. Build reward function
reward_fn = make_reward_fn(ENV_BASE_URL, phase)
# 5. Configure GRPOTrainer
try:
from trl import GRPOConfig, GRPOTrainer
except ImportError:
print("[ERROR] trl not installed. Run: pip install trl>=0.9.0", flush=True)
sys.exit(1)
# [FIX-T6] graceful version check for older trl
import trl as _trl
_trl_version = tuple(int(x) for x in _trl.__version__.split(".")[:2])
if _trl_version < (0, 9):
print(f"[WARN] trl {_trl.__version__} detected β€” recommend trl>=0.9.0", flush=True)
# Remove keys that don't exist in older versions
for key in ("warmup_ratio",):
cfg.pop(key, None)
grpo_config = GRPOConfig(
output_dir=output_dir,
report_to="none",
**cfg,
)
trainer = GRPOTrainer(
model=model,
processing_class=tokenizer,
reward_funcs=reward_fn,
args=grpo_config,
train_dataset=dataset,
)
# 6. Train
print("[INFO] Starting GRPO training...", flush=True)
t0 = time.time()
trainer.train()
elapsed = time.time() - t0
print(f"\n[INFO] Training complete in {elapsed/60:.1f} min", flush=True)
# 7. Save
Path(output_dir).mkdir(parents=True, exist_ok=True)
trainer.save_model(output_dir)
tokenizer.save_pretrained(output_dir)
print(f"[INFO] Model saved to {output_dir}", flush=True)
# 8. Push to Hub
if push_to_hub and HF_REPO_ID:
print(f"[INFO] Pushing to HF Hub: {HF_REPO_ID}", flush=True)
# [FIX-T7] Merge LoRA weights before push so hub model is self-contained
if backend == "unsloth":
try:
from unsloth import FastLanguageModel
merged = model.merge_and_unload()
merged.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
tokenizer.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
print(f"[INFO] Merged model pushed to https://huggingface.co/{HF_REPO_ID}", flush=True)
except Exception as e:
print(f"[WARN] Merge failed ({e}), pushing LoRA adapter only", flush=True)
model.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
tokenizer.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
else:
model.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
tokenizer.push_to_hub(HF_REPO_ID, token=HF_TOKEN)
return output_dir
# ── Smoke test ─────────────────────────────────────────────────────────────────
def smoke_test():
"""
Smoke test β€” no GPU, no model loading.
Verifies: server reachability, R1/R2 env steps, SFT+GRPO dataset pipeline.
"""
print("\n=== SMOKE TEST (rule-based, no GPU) ===\n", flush=True)
import requests as _req
# Health checks
try:
r1h = _req.get(f"{ENV_BASE_URL}/health", timeout=10).json()
r2h = _req.get(f"{ENV_BASE_URL}/project/health", timeout=10).json()
print(f"[OK] R1 health: {r1h}", flush=True)
print(f"[OK] R2 health: {r2h}", flush=True)
except Exception as e:
print(f"[ERROR] Server not reachable: {e}", flush=True)
sys.exit(1)
results = {}
# ── R1 test ──────────────────────────────────────────────────────────────
task = "easy_sprint"
print(f"\n[R1] {task} (10 steps)...", flush=True)
try:
obs = _req.post(f"{ENV_BASE_URL}/reset",
json={"task_name": task, "seed": 42}, timeout=30).json()
total_r = 0.0
for i in range(10):
if obs.get("done", False):
break
action = smart_fallback_r1(obs)
result = _req.post(f"{ENV_BASE_URL}/step",
json={"action": action}, timeout=30).json()
obs = result.get("observation", obs)
total_r += result.get("reward", 0.0)
print(f" step {i+1}: {action['action_type']} "
f"day={obs.get('current_day','?')} "
f"done={obs.get('tasks_completed',0)} "
f"reward={result.get('reward',0):.3f}", flush=True)
if result.get("done", False):
break
results["r1/easy_sprint"] = round(total_r, 3)
print(f"[OK] R1 cumulative reward: {total_r:.3f}", flush=True)
except Exception as e:
print(f" [ERROR] {e}", flush=True)
results["r1/easy_sprint"] = None
# ── R2 test ──────────────────────────────────────────────────────────────
task = "project_easy"
print(f"\n[R2] {task} (8 steps)...", flush=True)
try:
obs = _req.post(f"{ENV_BASE_URL}/project/reset",
json={"task_name": task, "seed": 42}, timeout=30).json()
assigned: set[str] = set()
for i in range(8):
if obs.get("done", False):
break
action = smart_fallback_r2(obs, assigned)
if action["action_type"] == "assign" and action.get("task_id"):
assigned.add(action["task_id"])
result = _req.post(f"{ENV_BASE_URL}/project/step",
json={"action": action}, timeout=30).json()
obs = result.get("observation", obs)
print(f" step {i+1}: {action['action_type']} "
f"task={action.get('task_id')} "
f"day={obs.get('current_day','?')} "
f"sprint={obs.get('current_sprint','?')} "
f"reward={result.get('reward',0):.3f} "
f"inst={obs.get('instruction_following_score',0):.2f}", flush=True)
if result.get("done", False):
break
results["r2/project_easy"] = round(obs.get("cumulative_reward", 0), 3)
print(f"[OK] R2 cumulative reward: {obs.get('cumulative_reward',0):.3f}", flush=True)
except Exception as e:
print(f" [ERROR] {e}", flush=True)
results["r2/project_easy"] = None
# ── Dataset pipeline test ─────────────────────────────────────────────────
print(f"\n[DATASET] Testing GRPO dataset (12 examples)...", flush=True)
try:
ds = build_grpo_dataset(n_examples=12, phase="both")
print(f" [OK] GRPO dataset size: {len(ds)}", flush=True)
print(f" [OK] Keys: {list(ds[0].keys())}", flush=True)
except Exception as e:
print(f" [WARN] GRPO dataset failed: {e}", flush=True)
print(f"\n[DATASET] Testing SFT dataset (12 examples)...", flush=True)
try:
sft_ds = build_sft_dataset(n_examples=12, phase="both")
print(f" [OK] SFT dataset size: {len(sft_ds)}", flush=True)
print(f" [OK] Keys: {list(sft_ds[0].keys())}", flush=True)
except Exception as e:
print(f" [WARN] SFT dataset failed: {e}", flush=True)
print(f"\n=== SMOKE TEST RESULTS ===", flush=True)
for k, v in results.items():
status = "[OK]" if v is not None else "[FAIL]"
print(f" {status} {k}: {v}", flush=True)
print(f"\nβœ… Smoke test complete. Server is ready for GPU training.", flush=True)
print(f"\n Recommended training command (A100):", flush=True)
print(f" python train_llm.py --phase both --episodes 300 "
f"--sft-epochs 2 --gpu-tier a100 --output results/trained_model --push", flush=True)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="SFT+GRPO training for AI Sprint Manager")
parser.add_argument("--smoke-test", action="store_true",
help="Run smoke test (no GPU). Do this locally first.")
parser.add_argument("--phase", choices=["r1", "r2", "both", "sft"], default="both",
help="Training phase. 'sft' runs SFT only.")
parser.add_argument("--episodes", type=int, default=200,
help="GRPO dataset examples to collect (default: 200)")
parser.add_argument("--sft-epochs", type=int, default=0,
help="SFT warm-up epochs before GRPO (default: 0 = skip)")
parser.add_argument("--gpu-tier", choices=["t4", "a10g", "a100"], default="t4",
help="GPU tier for batch size scaling (default: t4)")
parser.add_argument("--output", type=str, default="results/trained_model")
parser.add_argument("--push", action="store_true",
help="Push trained model to HF Hub (requires HF_REPO_ID)")
args = parser.parse_args()
if args.smoke_test:
smoke_test()
return
if args.phase == "sft":
# SFT only β€” useful to check format learning before GRPO
model, tokenizer, _ = load_model_and_tokenizer(MODEL_NAME)
run_sft(model, tokenizer, "both", 200, args.output)
tokenizer.save_pretrained(args.output)
return
train(
phase=args.phase,
n_dataset_examples=args.episodes,
output_dir=args.output,
push_to_hub=args.push,
sft_epochs=args.sft_epochs,
gpu_tier=args.gpu_tier,
)
if __name__ == "__main__":
main()