junaid0600 commited on
Commit
e299a66
Β·
verified Β·
1 Parent(s): 8ff689d

Update training/train_agent.py

Browse files
Files changed (1) hide show
  1. training/train_agent.py +285 -324
training/train_agent.py CHANGED
@@ -1,101 +1,100 @@
1
  """
2
  training/train_agent.py β€” SQL Database Engineer Agent
3
- Unsloth + GRPO training script.
4
- Run on venue GPU (April 25-26) with compute credits.
5
-
6
- FIXES applied:
7
- 1. Robust JSON extraction via regex (kills PARSE FALLBACK)
8
- 2. task_id from kwargs directly β€” not from kwargs["batch"] (kills only-easy_s001)
9
- 3. Reward calls /grader (stateless) instead of /reset+/step (kills race condition + flat 0.500)
10
- 4. Format bonus so valid JSON gets non-zero reward even before agent learns DBA actions
11
  """
12
 
13
- import os
14
- import re
15
- import json
16
- import warnings
17
- import requests
18
- from datasets import Dataset
19
 
20
- # ── Try importing Unsloth (GPU only) ─────────────────────────
 
 
 
 
 
21
  try:
 
 
 
 
22
  from unsloth import FastLanguageModel
23
  from trl import GRPOTrainer, GRPOConfig
 
24
  UNSLOTH_AVAILABLE = True
25
- except ImportError:
26
- UNSLOTH_AVAILABLE = False
27
- print("⚠️ Unsloth not available. Run: pip install unsloth trl")
28
-
29
- # Keep demo/training logs readable by silencing known upstream deprecation noise.
30
- warnings.filterwarnings(
31
- "ignore",
32
- message=r".*Both `max_new_tokens`.*and `max_length`.*",
33
- )
34
- warnings.filterwarnings(
35
- "ignore",
36
- message=r".*AttentionMaskConverter.*deprecated.*",
37
- category=FutureWarning,
38
- )
39
- warnings.filterwarnings(
40
- "ignore",
41
- message=r".*use_return_dict.*deprecated.*",
42
- )
43
-
44
- # ─────────────────────────────────────────────
45
- # CONFIG
46
- # ─────────────────────────────────────────────
47
-
48
  ENV_URL = os.getenv("ENV_URL", "https://junaid0600-sql-db-engineer-agent.hf.space")
49
  HF_TOKEN = os.getenv("HF_TOKEN", "")
50
- MODEL_NAME = os.getenv("MODEL_NAME", "unsloth/Qwen2.5-7B-Instruct")
51
  OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./sdea-trained")
 
52
 
53
- # Valid Round 2 action types β€” model must use one of these
54
- VALID_ACTION_TYPES = {
 
 
 
55
  "inspect_query", "analyze_indexes", "create_index",
56
  "rewrite_query", "add_column", "drop_index",
57
  "partition_table", "analyze_statistics",
58
  "request_hint", "submit_report",
59
  }
60
 
61
- SYSTEM_PROMPT = """You are a senior database engineer.
62
- Given a database scenario with slow queries, choose the BEST single action to improve performance.
63
 
64
- Investigation pattern (follow this order):
65
- 1. Use inspect_query to understand WHY a query is slow (scan type, rows examined)
66
- 2. Use analyze_indexes to see what indexes exist and what is missing
67
- 3. Use create_index to add the missing index on WHERE/JOIN columns
68
- 4. Use rewrite_query if the SQL itself is inefficient
69
- 5. Use partition_table for tables with 1M+ rows and range queries
70
- 6. Use submit_report when performance target is reached
71
 
72
- RESPOND WITH VALID JSON ONLY. No explanation. No markdown. No preamble.
73
  Examples:
74
  {"action_type": "inspect_query", "payload": {"query_id": "q1"}}
75
- {"action_type": "analyze_indexes", "payload": {"table": "users"}}
76
  {"action_type": "create_index", "payload": {"table": "users", "columns": ["email"]}}
77
- {"action_type": "create_index", "payload": {"table": "orders", "columns": ["user_id", "status"]}}
78
- {"action_type": "submit_report", "payload": {"summary": "Added composite index on orders(user_id, status). Performance improved from 5.0 to 85.0."}}"""
79
 
80
 
81
- # ─────────────────────────────────────────────
82
- # JSON EXTRACTION (FIX 1 β€” kills PARSE FALLBACK)
83
- # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- def _extract_json(text: str) -> dict | None:
86
- """
87
- Robustly extract a JSON object from model output.
88
- Handles: pure JSON, markdown blocks, JSON buried in text, partial JSON.
89
- Returns parsed dict or None if nothing parseable found.
90
- """
91
  if not text:
92
  return None
93
-
94
- # Strip common markdown wrappers
95
  text = text.strip()
96
  text = re.sub(r"```(?:json)?", "", text).replace("```", "").strip()
97
 
98
- # Try 1: entire text is valid JSON
99
  try:
100
  obj = json.loads(text)
101
  if isinstance(obj, dict) and "action_type" in obj:
@@ -103,334 +102,296 @@ def _extract_json(text: str) -> dict | None:
103
  except json.JSONDecodeError:
104
  pass
105
 
106
- # Try 2: balanced-brace scan from every opening '{'
107
- # This handles nested payload objects/arrays more reliably than regex.
108
- starts = [idx for idx, ch in enumerate(text) if ch == "{"]
109
- for start in starts:
110
- depth = 0
111
- in_str = False
112
- escape = False
113
  for i in range(start, len(text)):
114
  ch = text[i]
115
  if in_str:
116
- if escape:
117
- escape = False
118
- elif ch == "\\":
119
- escape = True
120
- elif ch == '"':
121
- in_str = False
122
  continue
123
- if ch == '"':
124
- in_str = True
125
- continue
126
- if ch == "{":
127
- depth += 1
128
  elif ch == "}":
129
  depth -= 1
130
  if depth == 0:
131
- candidate = text[start:i + 1]
132
  try:
133
- obj = json.loads(candidate)
134
  if isinstance(obj, dict) and "action_type" in obj:
135
  return obj
136
- except json.JSONDecodeError:
137
- pass
138
  break
139
-
140
  return None
141
 
142
 
143
- def _is_valid_action(action: dict) -> bool:
144
- """Check action has correct structure before sending to /grader."""
145
- if not isinstance(action, dict):
146
- return False
147
- if "action_type" not in action:
148
- return False
149
- if action["action_type"] not in VALID_ACTION_TYPES:
150
- return False
151
- if "payload" not in action or not isinstance(action.get("payload"), dict):
152
- return False
153
- return True
154
-
155
-
156
- def _completion_to_text(completion) -> str:
157
  """
158
- Normalize TRL completion formats into raw text.
159
- Handles dict/list/string and content blocks emitted by some chat templates.
160
  """
161
- if isinstance(completion, str):
162
- return completion
163
-
164
- if isinstance(completion, dict):
165
- content = completion.get("content", "")
166
- if isinstance(content, str):
167
- return content
168
- if isinstance(content, list):
169
- chunks = []
170
- for block in content:
171
- if isinstance(block, dict):
172
- chunks.append(str(block.get("text", "")))
173
- else:
174
- chunks.append(str(block))
175
- return "".join(chunks)
176
- return str(content)
177
-
178
- if isinstance(completion, list):
179
- # Common shape: [{"role":"assistant","content":"..."}]
180
- if not completion:
181
- return ""
182
- return "".join(_completion_to_text(item) for item in completion)
183
-
184
- return str(completion)
185
-
186
-
187
- # ─────────────────────────────────────────────
188
- # REWARD FUNCTION (FIX 2 + FIX 3)
189
- # ─────────────────────────────────────────────
190
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def reward_fn(prompts, completions, **kwargs):
192
  """
193
- GRPO reward function β€” calls /grader (STATELESS).
194
-
195
- FIX 2: task_ids from kwargs["task_id"] directly (TRL passes dataset
196
- columns as direct kwargs, NOT inside a "batch" key).
197
-
198
- FIX 3: calls /grader instead of /reset + /step.
199
- /grader is stateless β€” no race condition, no global env mutation,
200
- no flat reward from concurrent resets overwriting each other.
201
  """
202
- rewards = []
203
-
204
- # ── FIX 2: correct task_id extraction ────────────────────────
205
- # TRL GRPO passes dataset columns directly as kwargs.
206
- # With num_generations=4, each task_id is repeated 4x in the list.
207
- raw_task_ids = kwargs.get("task_id", [])
208
- if isinstance(raw_task_ids, str):
209
- raw_task_ids = [raw_task_ids]
210
 
211
  for i, (prompt, completion) in enumerate(zip(prompts, completions)):
212
- task_id = (
213
- raw_task_ids[i]
214
- if i < len(raw_task_ids)
215
- else "easy_s001"
216
- )
217
-
218
- # ── Extract text from completion ──────────────────────────
219
- text = _completion_to_text(completion)
 
 
 
 
 
 
 
 
 
 
220
 
221
- # ── FIX 1: robust JSON parse ──────────────────────────────
222
- action = _extract_json(text)
 
 
223
 
224
- if action is None:
225
- # Complete parse failure β€” 0.001 (not 0.0, avoids GRPO div-by-zero)
226
- print(f" [REWARD] scenario={task_id} | INVALID JSON | score=0.001")
227
- rewards.append(0.001)
228
- continue
229
-
230
- # Format bonus: valid JSON with correct structure = small positive signal
231
- # This gives the model SOMETHING to learn from even before it learns
232
- # the right actions, avoiding the all-zero gradient problem.
233
- if not _is_valid_action(action):
234
- # JSON parsed but action_type is wrong/missing
235
- print(f" [REWARD] scenario={task_id} | INVALID ACTION | score=0.05")
236
- rewards.append(0.05)
237
- continue
238
-
239
- # ── FIX 3: stateless /grader call ────────────────────────
240
- try:
241
- resp = requests.post(
242
- f"{ENV_URL}/grader",
243
- json={"task_id": task_id, "action": action},
244
- timeout=20,
245
- )
246
- resp.raise_for_status()
247
- score = float(resp.json().get("score", 0.001))
248
- score = max(0.001, min(0.999, score))
249
- print(f" [REWARD] scenario={task_id} | action={action['action_type']} | score={score:.3f}")
250
  rewards.append(score)
251
 
252
- except requests.exceptions.Timeout:
253
- print(f" [REWARD] scenario={task_id} | GRADER TIMEOUT | score=0.05")
254
- rewards.append(0.05) # grader timed out β€” give format credit
 
255
  except Exception as e:
256
- print(f"[reward_fn] grader call failed for {task_id}: {e}")
257
  rewards.append(0.001)
258
 
259
  return rewards
260
 
261
 
262
- # ─────────────────────────────────────────────
263
- # BUILD TRAINING DATASET
264
- # ─────────────────────────────────────────────
265
-
266
- def build_dataset():
267
- """
268
- Build training examples from all Round 2 scenario JSON files.
269
- Each example: {"prompt": "...", "task_id": "easy_s001"}.
270
- task_id is passed through to reward_fn via kwargs (TRL behaviour).
271
- """
272
- scenarios = []
273
-
274
- for fname in [
275
- "dataset/easy_scenarios.json",
276
- "dataset/medium_scenarios.json",
277
- "dataset/hard_scenarios.json",
278
- ]:
279
- try:
280
- with open(fname) as f:
281
- loaded = json.load(f)
282
- scenarios.extend(loaded)
283
- print(f" Loaded {len(loaded)} scenarios from {fname}")
284
- except FileNotFoundError:
285
- print(f" {fname} not found, skipping")
286
-
287
- if not scenarios:
288
- print(" Falling back to /tasks endpoint...")
289
- try:
290
- resp = requests.get(f"{ENV_URL}/tasks", timeout=15)
291
- tasks = resp.json().get("tasks", [])
292
- scenarios = [{"id": t["id"], "description": t.get("description", "")}
293
- for t in tasks if "_s" in t["id"]]
294
- except Exception as e:
295
- print(f" /tasks fallback failed: {e}")
296
- # Minimal fallback so training doesn't crash
297
- scenarios = [{"id": "easy_s001",
298
- "description": "User lookup query taking 2s. Add index.",
299
- "tables": [{"name": "users", "rows": 10000, "indexes": ["PRIMARY"]}],
300
- "slow_queries": [{"id": "q1", "sql": "SELECT * FROM users WHERE email=?", "avg_ms": 2000}],
301
- "performance_score_baseline": 8.0,
302
- "target_score": 80.0}]
303
-
304
  examples = []
305
- for s in scenarios:
306
- tables_txt = json.dumps(s.get("tables", []), separators=(",", ":"))
307
- queries_txt = json.dumps(s.get("slow_queries", []), separators=(",", ":"))
308
- baseline = s.get("performance_score_baseline", s.get("performance_score", 0))
309
- target = s.get("target_score", 85)
310
- max_steps = s.get("max_steps", 50)
311
-
312
  prompt = (
313
- f"{SYSTEM_PROMPT}\n\n"
314
- f"Scenario: {s.get('id')} β€” {s.get('description','')}\n"
315
- f"Slow queries: {', '.join(q['id'] for q in s.get('slow_queries',[]))}\n"
316
- f"Tables: {', '.join(t['name'] for t in s.get('tables',[]))}\n"
317
- f"Missing indexes: {json.dumps(s.get('missing_index_hints',[]))}\n"
318
- f"Performance: {baseline}/100 β†’ target {target}/100\n\n"
319
- f"Respond with JSON only:"
320
- )
321
-
322
-
323
- examples.append({
324
- "prompt": prompt,
325
- "task_id": s.get("id", "easy_s001"),
326
- })
327
 
328
- print(f"Built {len(examples)} training examples from {len(scenarios)} scenarios")
329
  return Dataset.from_list(examples)
330
 
331
 
332
- # ─────────────────────────────────────────────
333
- # REWARD WRAPPER (FIX 2 continued)
334
- # ─────────────────────────────────────────────
335
-
336
- def reward_wrapper(prompts, completions, **kwargs):
337
- """
338
- Thin wrapper β€” passes kwargs straight through.
339
- TRL GRPO sends dataset columns (including task_id) as direct kwargs.
340
- DO NOT use kwargs.get("batch") β€” that key does not exist in TRL GRPO.
341
- """
342
- return reward_fn(prompts, completions, **kwargs)
343
 
344
-
345
- # ─────────────────────────────────────────────
346
- # MAIN TRAINING
347
- # ─────────────────────────────────────────────
348
-
349
- def train():
350
- if not UNSLOTH_AVAILABLE:
351
- print("Cannot train β€” Unsloth not installed")
352
- print("Run: pip install 'unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git' trl transformers datasets accelerate")
353
  return
354
 
355
- print(f"πŸš€ Loading model: {MODEL_NAME}")
356
- print(f"🌐 Environment: {ENV_URL}")
357
-
358
- # Sanity check β€” make sure environment is reachable
359
- try:
360
- r = requests.get(f"{ENV_URL}/health", timeout=10)
361
- print(f"βœ… Environment health: {r.json()}")
362
- except Exception as e:
363
- print(f"⚠️ Cannot reach environment at {ENV_URL}: {e}")
364
- print(" Training will likely fail β€” check ENV_URL")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- # Load model with Unsloth 4-bit quantization
367
  model, tokenizer = FastLanguageModel.from_pretrained(
368
  model_name = MODEL_NAME,
369
- max_seq_length = 4096,
370
  load_in_4bit = True,
371
  token = HF_TOKEN or None,
372
  )
373
-
374
- # Add LoRA adapters
375
  model = FastLanguageModel.get_peft_model(
376
  model,
377
- r = 16,
378
- lora_alpha = 16,
379
- target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
380
- "gate_proj", "up_proj", "down_proj"],
381
- lora_dropout = 0,
382
- bias = "none",
383
- use_gradient_checkpointing = "unsloth",
384
  )
 
385
 
386
- # Build dataset
387
  dataset = build_dataset()
388
 
389
- # GRPO config
390
- # Avoid generation warnings from simultaneously using max_length and max_new_tokens.
391
- if hasattr(model, "generation_config"):
392
- model.generation_config.max_length = None
393
- if hasattr(model, "config"):
394
- # Some stacks still read generation defaults from model.config.
395
- model.config.max_length = None
396
-
397
  config = GRPOConfig(
398
  output_dir = OUTPUT_DIR,
399
- num_train_epochs = 100, # was 3 β†’ now 100 β†’ gives ~120 steps
400
- per_device_train_batch_size = 1, # was 2 β†’ frees memory for 1.5B
401
- gradient_accumulation_steps = 2, # was 8 β†’ was hiding steps, now shows them
402
- learning_rate = 2e-5, # was 5e-5 β†’ lower = more stable for small model
403
- max_completion_length = 120,
404
  num_generations = 4,
405
- logging_steps = 1, # was 10 β†’ see every step
406
- save_steps = 20,
407
- warmup_steps = 10, # was warmup_ratio (deprecated)
 
 
408
  report_to = "none",
 
409
  )
410
 
411
  trainer = GRPOTrainer(
412
  model = model,
413
  tokenizer = tokenizer,
414
- reward_funcs = reward_wrapper,
415
  args = config,
416
  train_dataset = dataset,
417
  )
418
 
419
- print("πŸ‹οΈ Starting GRPO training...")
420
- print(" Expected reward progression:")
421
- print(" Steps 10: ~0.05-0.15 (model still outputting free text)")
422
- print(" Steps 50: ~0.20-0.35 (learning JSON format)")
423
- print(" Steps 100: ~0.35-0.50 (learning correct action types)")
424
- print(" Steps 200: ~0.55-0.70 (learning DBA investigation pattern)")
425
- print(" Steps 300: ~0.70-0.82 (strategic multi-action planning)")
426
 
427
  trainer.train()
 
428
 
429
- # Save
430
  model.save_pretrained(f"{OUTPUT_DIR}/final")
431
  tokenizer.save_pretrained(f"{OUTPUT_DIR}/final")
432
- print(f"βœ… Training complete. Model saved to {OUTPUT_DIR}/final")
 
 
 
 
 
 
 
 
 
 
433
 
434
 
435
  if __name__ == "__main__":
436
- train()
 
1
  """
2
  training/train_agent.py β€” SQL Database Engineer Agent
3
+ Unsloth + GRPO β€” Fixed Version
4
+ - 100 steps (not 700)
5
+ - Local DatabaseSimulator for variance (not just /grader)
6
+ - Real delta rewards, milestones shown in logs
7
+ - Generates loss_curve.png automatically
 
 
 
8
  """
9
 
10
+ import os, re, json, sys, warnings
11
+ from pathlib import Path
 
 
 
 
12
 
13
+ # ── Suppress known warnings ───────────────────────────────────
14
+ warnings.filterwarnings("ignore", message=r".*max_new_tokens.*max_length.*")
15
+ warnings.filterwarnings("ignore", message=r".*AttentionMaskConverter.*", category=FutureWarning)
16
+
17
+ # ── GPU check + Unsloth ───────────────────────────────────────
18
+ UNSLOTH_AVAILABLE = False
19
  try:
20
+ import torch
21
+ if not torch.cuda.is_available():
22
+ print("❌ No GPU. Unsloth requires CUDA.")
23
+ sys.exit(1)
24
  from unsloth import FastLanguageModel
25
  from trl import GRPOTrainer, GRPOConfig
26
+ from datasets import Dataset
27
  UNSLOTH_AVAILABLE = True
28
+ print(f"βœ… GPU: {torch.cuda.get_device_name(0)}")
29
+ print(f"βœ… VRAM: {torch.cuda.get_device_properties(0).total_memory/1e9:.1f}GB")
30
+ except ImportError as e:
31
+ print(f"❌ {e}")
32
+ sys.exit(1)
33
+
34
+ # ── Add project root to path ──────────────────────────────────
35
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36
+ sys.path.insert(0, ROOT)
37
+ from env.db_simulator import DatabaseSimulator
38
+
39
+ # ── Config ────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
40
  ENV_URL = os.getenv("ENV_URL", "https://junaid0600-sql-db-engineer-agent.hf.space")
41
  HF_TOKEN = os.getenv("HF_TOKEN", "")
42
+ MODEL_NAME = os.getenv("MODEL_NAME", "unsloth/Qwen2.5-1.5B-Instruct")
43
  OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./sdea-trained")
44
+ MAX_STEPS = int(os.getenv("MAX_STEPS", "100"))
45
 
46
+ print(f"\n[CONFIG] Model: {MODEL_NAME}")
47
+ print(f"[CONFIG] Max steps: {MAX_STEPS}")
48
+ print(f"[CONFIG] Output: {OUTPUT_DIR}\n")
49
+
50
+ VALID_ACTIONS = {
51
  "inspect_query", "analyze_indexes", "create_index",
52
  "rewrite_query", "add_column", "drop_index",
53
  "partition_table", "analyze_statistics",
54
  "request_hint", "submit_report",
55
  }
56
 
57
+ SYSTEM_PROMPT = """You are a senior database engineer fixing slow database queries.
 
58
 
59
+ Investigation pattern:
60
+ 1. inspect_query β†’ understand WHY query is slow
61
+ 2. analyze_indexes β†’ see what indexes are missing
62
+ 3. create_index β†’ add composite index on WHERE/JOIN columns
63
+ 4. submit_report β†’ when performance target is reached
 
 
64
 
65
+ RESPOND WITH VALID JSON ONLY. No markdown. No explanation.
66
  Examples:
67
  {"action_type": "inspect_query", "payload": {"query_id": "q1"}}
 
68
  {"action_type": "create_index", "payload": {"table": "users", "columns": ["email"]}}
69
+ {"action_type": "create_index", "payload": {"table": "orders", "columns": ["user_id", "status"]}}"""
 
70
 
71
 
72
+ # ── Load all 15 scenarios ─────────────────────────────────────
73
+ def load_scenarios() -> list:
74
+ scenarios = []
75
+ for fname in ["easy_scenarios.json", "medium_scenarios.json", "hard_scenarios.json"]:
76
+ path = os.path.join(ROOT, "dataset", fname)
77
+ try:
78
+ with open(path) as f:
79
+ data = json.load(f)
80
+ scenarios.extend(data)
81
+ print(f" βœ… {len(data)} from {fname}")
82
+ except FileNotFoundError:
83
+ print(f" ⚠️ {fname} not found")
84
+ print(f" Total: {len(scenarios)} scenarios\n")
85
+ return scenarios
86
 
87
+ ALL_SCENARIOS = load_scenarios()
88
+
89
+
90
+ # ── Parse LLM output β†’ action dict ───────────────────────────
91
+ def parse_action(text: str) -> dict | None:
 
92
  if not text:
93
  return None
 
 
94
  text = text.strip()
95
  text = re.sub(r"```(?:json)?", "", text).replace("```", "").strip()
96
 
97
+ # Try full parse
98
  try:
99
  obj = json.loads(text)
100
  if isinstance(obj, dict) and "action_type" in obj:
 
102
  except json.JSONDecodeError:
103
  pass
104
 
105
+ # Try extract from partial text
106
+ for start in [i for i,c in enumerate(text) if c == "{"]:
107
+ depth, in_str, escape = 0, False, False
 
 
 
 
108
  for i in range(start, len(text)):
109
  ch = text[i]
110
  if in_str:
111
+ if escape: escape = False
112
+ elif ch == "\\": escape = True
113
+ elif ch == '"': in_str = False
 
 
 
114
  continue
115
+ if ch == '"': in_str = True; continue
116
+ if ch == "{": depth += 1
 
 
 
117
  elif ch == "}":
118
  depth -= 1
119
  if depth == 0:
 
120
  try:
121
+ obj = json.loads(text[start:i+1])
122
  if isinstance(obj, dict) and "action_type" in obj:
123
  return obj
124
+ except: pass
 
125
  break
 
126
  return None
127
 
128
 
129
+ # ── Local reward using DatabaseSimulator ──────────────────────
130
+ def compute_reward(action: dict, scenario: dict) -> tuple:
 
 
 
 
 
 
 
 
 
 
 
 
131
  """
132
+ Compute reward LOCALLY β€” no HTTP, no shared state, deterministic.
133
+ Returns (score, improvement_pts, milestone_bonus, description)
134
  """
135
+ sim = DatabaseSimulator(scenario)
136
+ baseline = sim.get_performance_score()
137
+ action_type = action.get("action_type", "")
138
+ payload = action.get("payload", {})
139
+
140
+ # Apply action
141
+ delta = 0.0
142
+ if action_type == "create_index":
143
+ result = sim.apply_action("create_index", payload)
144
+ delta = result.get("delta", 0.0)
145
+ elif action_type == "rewrite_query":
146
+ result = sim.apply_action("rewrite_query", payload)
147
+ delta = result.get("delta", 0.0)
148
+ elif action_type == "partition_table":
149
+ result = sim.apply_action("partition_table", payload)
150
+ delta = result.get("delta", 0.0)
151
+ elif action_type == "analyze_statistics":
152
+ result = sim.apply_action("analyze_statistics", payload)
153
+ delta = result.get("delta", 0.0)
154
+ elif action_type in ("inspect_query", "analyze_indexes"):
155
+ delta = 0.0 # investigation β€” no DB change
156
+ elif action_type == "submit_report":
157
+ delta = max(0, sim.get_performance_score() - baseline)
158
+
159
+ final = sim.get_performance_score()
160
+ improvement = max(0.0, final - baseline)
161
+ max_possible = max(1.0, 100.0 - baseline)
162
+ ratio = improvement / max_possible
163
+
164
+ # Step reward
165
+ step_r = {
166
+ "inspect_query": 0.10,
167
+ "analyze_indexes": 0.10,
168
+ "create_index": 0.15,
169
+ "rewrite_query": 0.20,
170
+ "analyze_statistics":0.08,
171
+ "partition_table": 0.15,
172
+ "submit_report": 0.05,
173
+ }.get(action_type, 0.001)
174
+
175
+ # Delta reward β€” key signal
176
+ delta_r = min(0.65, ratio * 0.65)
177
+
178
+ # Milestone bonus
179
+ milestone = 0.0
180
+ milestone_str = ""
181
+ if ratio >= 0.75:
182
+ milestone = 0.40
183
+ milestone_str = "🎯 75% milestone!"
184
+ elif ratio >= 0.50:
185
+ milestone = 0.25
186
+ milestone_str = "🎯 50% milestone!"
187
+ elif ratio >= 0.25:
188
+ milestone = 0.15
189
+ milestone_str = "🎯 25% milestone!"
190
+
191
+ # Wrong index penalty
192
+ wrong_pen = -0.15 if (action_type == "create_index" and delta <= 0.0) else 0.0
193
+
194
+ total = max(0.001, min(0.999, step_r + delta_r + milestone + wrong_pen))
195
+ desc = f"+{improvement:.1f}pts delta={delta:.1f} {milestone_str}"
196
+
197
+ return total, improvement, milestone, desc
198
+
199
+
200
+ # ── GRPO reward function ──────────────────────────────────────
201
  def reward_fn(prompts, completions, **kwargs):
202
  """
203
+ LOCAL reward β€” DatabaseSimulator directly.
204
+ Rewards vary 0.001 to 0.999 giving GRPO real gradient signal.
 
 
 
 
 
 
205
  """
206
+ rewards = []
 
 
 
 
 
 
 
207
 
208
  for i, (prompt, completion) in enumerate(zip(prompts, completions)):
209
+ try:
210
+ # Get text
211
+ if isinstance(completion, list):
212
+ text = completion[0].get("content","") if completion else ""
213
+ else:
214
+ text = str(completion)
215
+
216
+ # Pick scenario
217
+ scenario = ALL_SCENARIOS[i % len(ALL_SCENARIOS)]
218
+ sid = scenario["id"]
219
+
220
+ # Parse
221
+ action = parse_action(text)
222
+
223
+ if action is None:
224
+ print(f" [REWARD] {sid} | INVALID JSON | score=0.001")
225
+ rewards.append(0.001)
226
+ continue
227
 
228
+ if action.get("action_type") not in VALID_ACTIONS:
229
+ print(f" [REWARD] {sid} | UNKNOWN ACTION | score=0.05")
230
+ rewards.append(0.05)
231
+ continue
232
 
233
+ # Compute locally
234
+ score, improvement, milestone, desc = compute_reward(action, scenario)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  rewards.append(score)
236
 
237
+ print(f" [REWARD] {sid} | "
238
+ f"action={action['action_type']} | "
239
+ f"{desc} | score={score:.3f}")
240
+
241
  except Exception as e:
242
+ print(f" [REWARD] Error: {e}")
243
  rewards.append(0.001)
244
 
245
  return rewards
246
 
247
 
248
+ # ── Build dataset ─────────────────────────────────────────────
249
+ def build_dataset() -> Dataset:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  examples = []
251
+ for s in ALL_SCENARIOS:
252
+ tables_str = json.dumps(s.get("tables", []))
253
+ queries_str = json.dumps(s.get("slow_queries", []))
254
+ hints_str = json.dumps(s.get("missing_index_hints", []))
 
 
 
255
  prompt = (
256
+ f"{SYSTEM_PROMPT}\n\n"
257
+ f"=== DATABASE STATE ===\n"
258
+ f"Scenario: {s['id']} β€” {s.get('description','')}\n"
259
+ f"Tables: {tables_str}\n"
260
+ f"Slow Queries: {queries_str}\n"
261
+ f"Missing Index Hints: {hints_str}\n"
262
+ f"Performance: {s.get('performance_score_baseline',0)}/100 "
263
+ f"β†’ Target: {s.get('target_score',85)}/100\n\n"
264
+ f"What is your next action? JSON only:"
265
+ )
266
+ examples.append({"prompt": prompt, "scenario_id": s["id"]})
 
 
 
267
 
268
+ print(f" βœ… Dataset: {len(examples)} examples")
269
  return Dataset.from_list(examples)
270
 
271
 
272
+ # ── Generate loss + reward plots ──────────────────────────────
273
+ def generate_plots(trainer):
274
+ import matplotlib
275
+ matplotlib.use("Agg")
276
+ import matplotlib.pyplot as plt
 
 
 
 
 
 
277
 
278
+ logs = [l for l in trainer.state.log_history if "loss" in l]
279
+ if not logs:
280
+ print("⚠️ No logs to plot")
 
 
 
 
 
 
281
  return
282
 
283
+ steps = [l.get("step", i) for i,l in enumerate(logs)]
284
+ losses = [l.get("loss", 0) for l in logs]
285
+ rewards = [l.get("reward", 0) for l in logs]
286
+
287
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
288
+ fig.suptitle("GRPO Training β€” SQL Database Engineer Agent",
289
+ fontsize=13, fontweight="bold")
290
+
291
+ ax1.plot(steps, losses, "b-o", lw=2, ms=4, label="Loss")
292
+ ax1.set_xlabel("Training Step")
293
+ ax1.set_ylabel("Loss")
294
+ ax1.set_title("Training Loss (↓ = model learning)")
295
+ ax1.grid(True, alpha=0.3)
296
+ if losses:
297
+ ax1.annotate(f"Start: {losses[0]:.4f}", xy=(steps[0], losses[0]),
298
+ xytext=(steps[0]+1, losses[0]*1.1), fontsize=8, color="red")
299
+ ax1.annotate(f"End: {losses[-1]:.4f}", xy=(steps[-1], losses[-1]),
300
+ xytext=(steps[-1]-8, losses[-1]*1.15), fontsize=8, color="green")
301
+
302
+ ax2.plot(steps, rewards, "g-o", lw=2, ms=4, label="Avg Reward")
303
+ ax2.set_xlabel("Training Step")
304
+ ax2.set_ylabel("Reward")
305
+ ax2.set_title("Reward During Training (↑ = improving)")
306
+ ax2.grid(True, alpha=0.3)
307
+
308
+ plt.tight_layout()
309
+ plt.savefig("loss_curve.png", dpi=150, bbox_inches="tight")
310
+ print("βœ… loss_curve.png saved")
311
+ if losses:
312
+ print(f" Loss: {losses[0]:.4f} β†’ {losses[-1]:.4f}")
313
+ if rewards:
314
+ valid = [r for r in rewards if r > 0]
315
+ if valid:
316
+ print(f" Reward: {valid[0]:.4f} β†’ {valid[-1]:.4f}")
317
+
318
+
319
+ # ── Main ──────────────────────────────────────────────────────
320
+ def train():
321
+ if not ALL_SCENARIOS:
322
+ print("❌ No scenarios loaded. Check dataset/ folder.")
323
+ sys.exit(1)
324
 
325
+ print(f"⏳ Loading {MODEL_NAME}...")
326
  model, tokenizer = FastLanguageModel.from_pretrained(
327
  model_name = MODEL_NAME,
328
+ max_seq_length = 2048,
329
  load_in_4bit = True,
330
  token = HF_TOKEN or None,
331
  )
 
 
332
  model = FastLanguageModel.get_peft_model(
333
  model,
334
+ r=16, lora_alpha=16,
335
+ target_modules=["q_proj","k_proj","v_proj","o_proj",
336
+ "gate_proj","up_proj","down_proj"],
337
+ lora_dropout=0, bias="none",
338
+ use_gradient_checkpointing="unsloth",
339
+ random_state=42,
 
340
  )
341
+ print("βœ… Model + LoRA ready\n")
342
 
 
343
  dataset = build_dataset()
344
 
 
 
 
 
 
 
 
 
345
  config = GRPOConfig(
346
  output_dir = OUTPUT_DIR,
347
+ max_steps = MAX_STEPS,
348
+ per_device_train_batch_size = 1,
349
+ gradient_accumulation_steps = 2,
350
+ learning_rate = 2e-5,
351
+ max_completion_length = 150,
352
  num_generations = 4,
353
+ temperature = 1.0,
354
+ logging_steps = 1,
355
+ save_steps = 25,
356
+ save_total_limit = 3,
357
+ warmup_steps = 10,
358
  report_to = "none",
359
+ remove_unused_columns = False,
360
  )
361
 
362
  trainer = GRPOTrainer(
363
  model = model,
364
  tokenizer = tokenizer,
365
+ reward_funcs = reward_fn,
366
  args = config,
367
  train_dataset = dataset,
368
  )
369
 
370
+ print(f"πŸ‹οΈ GRPO training β€” {MAX_STEPS} steps")
371
+ print("Expected rewards:")
372
+ print(" inspect_query (always): ~0.10")
373
+ print(" create_index (wrong columns): ~0.001")
374
+ print(" create_index (right columns): ~0.75-0.99")
375
+ print(" GRPO learns: right create_index >> everything else\n")
 
376
 
377
  trainer.train()
378
+ print("\nβœ… Training complete!")
379
 
380
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
381
  model.save_pretrained(f"{OUTPUT_DIR}/final")
382
  tokenizer.save_pretrained(f"{OUTPUT_DIR}/final")
383
+ print(f"βœ… Saved to {OUTPUT_DIR}/final")
384
+
385
+ generate_plots(trainer)
386
+
387
+ print("\n" + "="*50)
388
+ print("NEXT:")
389
+ print(" python training/evaluate_agent.py")
390
+ print(" git add loss_curve.png reward_curve.png")
391
+ print(" git commit -m 'Real GRPO training evidence'")
392
+ print(" git push origin main")
393
+ print("="*50)
394
 
395
 
396
  if __name__ == "__main__":
397
+ train()