jacobeen06 commited on
Commit
504e7f9
·
verified ·
1 Parent(s): 3265eba

Genesis-2.0 RLHF Phase 0 artifacts

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ dpo_pairs_all.jsonl filter=lfs diff=lfs merge=lfs -text
37
+ dpo_pairs_perturbed.jsonl filter=lfs diff=lfs merge=lfs -text
38
+ dpo_pairs_scored.jsonl filter=lfs diff=lfs merge=lfs -text
build_pairs.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Genesis-2.0 RLHF — Preference Pair Builder
3
+
4
+ Generates DPO training pairs (chosen/rejected) from existing SFT data.
5
+
6
+ Strategy:
7
+ 1. Use SFT prompts as seeds
8
+ 2. Generate multiple responses from Genesis-1.0 (via MLX on Mac or via API)
9
+ 3. Score with rule-based reward functions
10
+ 4. Best = chosen, worst = rejected
11
+
12
+ For Phase 0 on MacBook, we use an offline approach:
13
+ - Source A: Direct from SFT data (the existing trajectory IS the chosen)
14
+ Generate a perturbed version as rejected
15
+ - Source B: Score-based (take existing trajectories, rank by reward score,
16
+ pair high/low within each prompt group)
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import random
22
+ import sys
23
+ from typing import Optional
24
+
25
+ # Add project to path
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ from rewards import combined_reward, reward_debug, extract_tool_calls
28
+
29
+
30
+ DATA_DIR = "/Volumes/this_and_that/hermes-admin/improvements/hermes-agentic-dataset/data/train"
31
+ OUTPUT_DIR = "/Users/jacobeen/model-forge-workspace/genesis-rlhf"
32
+
33
+
34
+ def load_sft_data(sources: Optional[list[str]] = None) -> list[dict]:
35
+ """
36
+ Load SFT data from JSONL files.
37
+ Each entry: {"text": "...", "metadata": {...}}
38
+ """
39
+ if sources is None:
40
+ sources = [
41
+ "train_sessions_00001.jsonl",
42
+ "train_augmented_00001.jsonl",
43
+ ]
44
+
45
+ data = []
46
+ for src in sources:
47
+ path = os.path.join(DATA_DIR, src)
48
+ if not os.path.exists(path):
49
+ print(f" WARNING: {path} not found, skipping")
50
+ continue
51
+ with open(path) as f:
52
+ for line in f:
53
+ line = line.strip()
54
+ if line:
55
+ data.append(json.loads(line))
56
+ return data
57
+
58
+
59
+ def extract_prompt(text: str) -> str:
60
+ """Extract the user prompt from a full conversation text.
61
+ Returns everything up to but NOT including the first <|im_start|>assistant."""
62
+ idx = text.find("<|im_start|>assistant")
63
+ if idx >= 0:
64
+ return text[:idx].strip()
65
+ return text
66
+
67
+
68
+ def extract_completion(text: str) -> str:
69
+ """Extract the assistant's completion, including the assistant header."""
70
+ idx = text.find("<|im_start|>assistant")
71
+ if idx >= 0:
72
+ return text[idx:].strip()
73
+ return text
74
+
75
+
76
+ def perturb_trajectory(text: str) -> str:
77
+ """
78
+ Create a deliberately worse version of a trajectory for DPO rejected pairs.
79
+ Uses aggressive perturbations that significantly degrade quality.
80
+ """
81
+ import re
82
+
83
+ # Use multiple perturbations together for stronger signal
84
+ strategies = []
85
+
86
+ # Always remove some thinking
87
+ text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
88
+
89
+ tool_blocks = list(re.finditer(r'<tool_call>.*?</tool_call>', text, re.DOTALL))
90
+
91
+ if tool_blocks:
92
+ # 50% chance: remove the last tool call entirely (drops answer)
93
+ if random.random() < 0.5 and len(tool_blocks) >= 1:
94
+ idx = len(tool_blocks) - 1
95
+ start, end = tool_blocks[idx].start(), tool_blocks[idx].end()
96
+ text = text[:start] + text[end:]
97
+
98
+ # 40% chance: replace a tool name with a plausible wrong one
99
+ if random.random() < 0.4:
100
+ tool_names = ["web_search", "web_extract", "code_interpreter", "file_read",
101
+ "file_write", "database_query", "send_email", "calculator",
102
+ "search_web", "fetch_url", "run_code", "read_document"]
103
+ def _replace_name(m):
104
+ try:
105
+ tc = json.loads(m.group(1))
106
+ others = [n for n in tool_names if n != tc.get("name", "")]
107
+ if others:
108
+ tc["name"] = random.choice(others)
109
+ return f'<tool_call>\n{json.dumps(tc, indent=2)}\n</tool_call>'
110
+ except:
111
+ return m.group(0)
112
+ text = re.sub(r'<tool_call>\s*(\{.*?\})\s*</tool_call>', _replace_name, text, count=1, flags=re.DOTALL)
113
+
114
+ # 40% chance: make arguments invalid
115
+ if random.random() < 0.4:
116
+ def _corrupt_args(m):
117
+ try:
118
+ tc = json.loads(m.group(1))
119
+ if "arguments" in tc and isinstance(tc["arguments"], dict):
120
+ # Remove a required-looking argument
121
+ for key in list(tc["arguments"].keys())[:1]:
122
+ del tc["arguments"][key]
123
+ break
124
+ return f'<tool_call>\n{json.dumps(tc, indent=2)}\n</tool_call>'
125
+ except:
126
+ return m.group(0)
127
+ text = re.sub(r'<tool_call>\s*(\{.*?\})\s*</tool_call>', _corrupt_args, text, count=1, flags=re.DOTALL)
128
+
129
+ # Remove answer text after the last tool call
130
+ if random.random() < 0.5:
131
+ parts = text.rsplit("<|im_start|>assistant\n", 1)
132
+ if len(parts) > 1:
133
+ last_asst = parts[1]
134
+ tc_end = last_asst.rfind("</tool_call>")
135
+ if tc_end >= 0:
136
+ last_asst = last_asst[:tc_end + len("</tool_call>")]
137
+ text = parts[0] + "<|im_start|>assistant\n" + last_asst
138
+ else:
139
+ # No tool call at all — just drop the answer
140
+ text = parts[0].strip()
141
+
142
+ text = re.sub(r'\n{3,}', '\n\n', text)
143
+ return text
144
+
145
+
146
+ def build_pairs_scored(
147
+ data: list[dict],
148
+ output_path: str,
149
+ score_threshold: float = 0.3,
150
+ max_pairs: int = 2000,
151
+ ) -> list[dict]:
152
+ """
153
+ Build DPO pairs by scoring existing trajectories and pairing
154
+ high-scoring vs low-scoring examples.
155
+
156
+ Each pair: {"prompt": ..., "chosen": ..., "rejected": ...}
157
+ """
158
+ scored = []
159
+ for item in data:
160
+ score = combined_reward(item["text"])
161
+ prompt = extract_prompt(item["text"])
162
+ completion = extract_completion(item["text"])
163
+ scored.append((score, prompt, completion, item["metadata"]))
164
+
165
+ # Sort by score
166
+ scored.sort(key=lambda x: x[0], reverse=True)
167
+
168
+ pairs = []
169
+ # Pair high with low
170
+ high_idx = 0
171
+ low_idx = len(scored) - 1
172
+
173
+ while high_idx < low_idx and len(pairs) < max_pairs:
174
+ high_score, high_prompt, high_comp, high_meta = scored[high_idx]
175
+ low_score, low_prompt, low_comp, low_meta = scored[low_idx]
176
+
177
+ score_gap = high_score - low_score
178
+ if score_gap >= score_threshold and high_score > 0.5 and low_score < 0.8:
179
+ pair = {
180
+ "prompt": high_prompt,
181
+ "chosen": high_comp,
182
+ "rejected": low_comp,
183
+ "score_chosen": high_score,
184
+ "score_rejected": low_score,
185
+ "metadata": {
186
+ "source_high": high_meta.get("source", ""),
187
+ "source_low": low_meta.get("source", ""),
188
+ }
189
+ }
190
+ pairs.append(pair)
191
+
192
+ high_idx += 1
193
+ low_idx -= 1
194
+
195
+ # Save
196
+ with open(output_path, "w") as f:
197
+ for p in pairs:
198
+ f.write(json.dumps(p) + "\n")
199
+
200
+ print(f"Built {len(pairs)} scored pairs → {output_path}")
201
+ print(f" Score range: {scored[0][0]:.3f} (high) to {scored[-1][0]:.3f} (low)")
202
+ return pairs
203
+
204
+
205
+ def build_pairs_perturbed(
206
+ data: list[dict],
207
+ output_path: str,
208
+ max_pairs: int = 2000,
209
+ ) -> list[dict]:
210
+ """
211
+ Build DPO pairs by taking existing trajectories and creating
212
+ perturbed (deliberately worse) versions as the rejected sample.
213
+ The original trajectory is the chosen sample.
214
+ """
215
+ pairs = []
216
+ for item in data:
217
+ if len(pairs) >= max_pairs:
218
+ break
219
+
220
+ prompt = extract_prompt(item["text"])
221
+ chosen = extract_completion(item["text"])
222
+
223
+ # Verify chosen scores well (skip bad data)
224
+ chosen_score = combined_reward(chosen)
225
+ if chosen_score < 0.5:
226
+ continue
227
+
228
+ # Create perturbed version
229
+ rejected = perturb_trajectory(chosen)
230
+ rejected_score = combined_reward(rejected)
231
+
232
+ # Only accept if the perturbation actually made it worse
233
+ if rejected_score < chosen_score - 0.05:
234
+ pair = {
235
+ "prompt": prompt,
236
+ "chosen": chosen,
237
+ "rejected": rejected,
238
+ "score_chosen": chosen_score,
239
+ "score_rejected": rejected_score,
240
+ "metadata": {
241
+ "source": item["metadata"].get("source", "perturbed"),
242
+ }
243
+ }
244
+ pairs.append(pair)
245
+
246
+ with open(output_path, "w") as f:
247
+ for p in pairs:
248
+ f.write(json.dumps(p) + "\n")
249
+
250
+ print(f"Built {len(pairs)} perturbed pairs → {output_path}")
251
+ return pairs
252
+
253
+
254
+ if __name__ == "__main__":
255
+ print("=== Genesis-2.0: DPO Preference Pair Builder ===\n")
256
+
257
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
258
+
259
+ # Load data
260
+ print("Loading SFT data...")
261
+ data = load_sft_data()
262
+
263
+ # Sample sharegpt-fc (too large to load all — sample 500)
264
+ sharegpt_path = os.path.join(DATA_DIR, "train_hf_sharegpt-fc.jsonl")
265
+ if os.path.exists(sharegpt_path):
266
+ with open(sharegpt_path) as f:
267
+ for i, line in enumerate(f):
268
+ if i >= 500:
269
+ break
270
+ data.append(json.loads(line))
271
+ print(f" + 500 from sharegpt-fc")
272
+
273
+ print(f" Loaded {len(data)} total examples\n")
274
+
275
+ # Method 1: Score-based pairing
276
+ print("Building scored pairs...")
277
+ build_pairs_scored(
278
+ data,
279
+ os.path.join(OUTPUT_DIR, "dpo_pairs_scored.jsonl"),
280
+ max_pairs=1000,
281
+ )
282
+
283
+ # Method 2: Perturbation-based pairing
284
+ print("\nBuilding perturbed pairs...")
285
+ build_pairs_perturbed(
286
+ data,
287
+ os.path.join(OUTPUT_DIR, "dpo_pairs_perturbed.jsonl"),
288
+ max_pairs=1000,
289
+ )
290
+
291
+ # Combine
292
+ print("\nCombining...")
293
+ combined = []
294
+ for method in ["scored", "perturbed"]:
295
+ path = os.path.join(OUTPUT_DIR, f"dpo_pairs_{method}.jsonl")
296
+ if os.path.exists(path):
297
+ with open(path) as f:
298
+ for line in f:
299
+ if line.strip():
300
+ combined.append(json.loads(line))
301
+
302
+ with open(os.path.join(OUTPUT_DIR, "dpo_pairs_all.jsonl"), "w") as f:
303
+ for p in combined:
304
+ f.write(json.dumps(p) + "\n")
305
+
306
+ print(f"\n=== DONE: {len(combined)} total DPO pairs ===")
307
+ print(f" File: {OUTPUT_DIR}/dpo_pairs_all.jsonl")
dpo_pairs_all.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6cf41af65be6b23b77a533dc70a367ce24a2197b7bf0e12d9a8f68638db912a4
3
+ size 43781476
dpo_pairs_perturbed.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:832766423dc534ebbee155d47370c1af57c73a773cc2f422be9b3a67d1b6d934
3
+ size 26361675
dpo_pairs_scored.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5850f0aa43e90895f1f2354348514c9a1a6279704eb9e56e8070e07b7e8abd4
3
+ size 17419801
eval_benchmark.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Genesis-2.0 RLHF — Evaluation Benchmark
3
+
4
+ Evaluates a model's tool-use capabilities on a hold-out set.
5
+ Measures: tool accuracy, completion rate, efficiency, format compliance.
6
+
7
+ Usage:
8
+ python3 eval_benchmark.py --model <path> [--data <jsonl>] [--output <json>]
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ import re
15
+ from typing import Optional
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+ from rewards import (
19
+ extract_tool_calls,
20
+ extract_assistant_messages,
21
+ combined_reward,
22
+ reward_debug,
23
+ )
24
+
25
+
26
+ def evaluate_trajectory(trajectory_text: str) -> dict:
27
+ """
28
+ Evaluate a single model trajectory on all metrics.
29
+ Returns a dict of scores and metadata.
30
+ """
31
+ tool_calls = extract_tool_calls(trajectory_text)
32
+ assistant_msgs = extract_assistant_messages(trajectory_text)
33
+ reward_scores = reward_debug(trajectory_text)
34
+
35
+ # Tool accuracy: how many tool calls have valid names + args
36
+ valid_calls = 0
37
+ for tc in tool_calls:
38
+ name_ok = isinstance(tc.get("name"), str) and bool(tc["name"].strip())
39
+ args_ok = "arguments" in tc and isinstance(tc["arguments"], dict)
40
+ if name_ok and args_ok:
41
+ valid_calls += 1
42
+
43
+ tool_accuracy = valid_calls / max(len(tool_calls), 1)
44
+
45
+ # Completion: is there a substantive answer?
46
+ if assistant_msgs:
47
+ last_msg = assistant_msgs[-1]
48
+ has_text_answer = len(re.sub(r'<[^>]+>', '', last_msg).strip()) > 30
49
+ else:
50
+ has_text_answer = False
51
+
52
+ return {
53
+ "tool_count": len(tool_calls),
54
+ "valid_tool_count": valid_calls,
55
+ "tool_accuracy": tool_accuracy,
56
+ "has_answer": 1.0 if has_text_answer else 0.0,
57
+ "reward_completion": reward_scores["completion"],
58
+ "reward_validity": reward_scores["validity"],
59
+ "reward_efficiency": reward_scores["efficiency"],
60
+ "reward_format": reward_scores["format"],
61
+ "reward_combined": reward_scores["combined"],
62
+ "assistant_turns": len(assistant_msgs),
63
+ }
64
+
65
+
66
+ def evaluate_dataset(
67
+ data_path: str,
68
+ model_generate_fn=None,
69
+ output_path: Optional[str] = None,
70
+ max_samples: int = 200,
71
+ ) -> dict:
72
+ """
73
+ Evaluate a dataset of prompts against a model.
74
+
75
+ If model_generate_fn is None, evaluates the existing trajectories
76
+ (for offline eval of SFT data).
77
+
78
+ If model_generate_fn is provided, it's called as:
79
+ response = model_generate_fn(prompt)
80
+ for each prompt.
81
+
82
+ Returns aggregated metrics.
83
+ """
84
+ # Load data
85
+ data = []
86
+ with open(data_path) as f:
87
+ for i, line in enumerate(f):
88
+ if i >= max_samples:
89
+ break
90
+ line = line.strip()
91
+ if line:
92
+ data.append(json.loads(line))
93
+
94
+ results = []
95
+ for item in data:
96
+ if model_generate_fn:
97
+ prompt = item.get("prompt", item.get("text", ""))
98
+ response = model_generate_fn(prompt)
99
+ full_text = prompt + "\n" + response
100
+ else:
101
+ full_text = item.get("text", "")
102
+
103
+ metrics = evaluate_trajectory(full_text)
104
+ results.append(metrics)
105
+
106
+ # Aggregate
107
+ agg = {
108
+ "num_samples": len(results),
109
+ "avg_tool_accuracy": sum(r["tool_accuracy"] for r in results) / max(len(results), 1),
110
+ "avg_has_answer": sum(r["has_answer"] for r in results) / max(len(results), 1),
111
+ "avg_reward_combined": sum(r["reward_combined"] for r in results) / max(len(results), 1),
112
+ "avg_reward_completion": sum(r["reward_completion"] for r in results) / max(len(results), 1),
113
+ "avg_reward_validity": sum(r["reward_validity"] for r in results) / max(len(results), 1),
114
+ "avg_reward_efficiency": sum(r["reward_efficiency"] for r in results) / max(len(results), 1),
115
+ "avg_reward_format": sum(r["reward_format"] for r in results) / max(len(results), 1),
116
+ "avg_tool_count": sum(r["tool_count"] for r in results) / max(len(results), 1),
117
+ "total_tool_calls": sum(r["tool_count"] for r in results),
118
+ }
119
+
120
+ if output_path:
121
+ with open(output_path, "w") as f:
122
+ json.dump({"aggregate": agg, "per_sample": results}, f, indent=2)
123
+ print(f"Saved evaluation to {output_path}")
124
+
125
+ return agg
126
+
127
+
128
+ def build_holdout_set(
129
+ input_dir: str,
130
+ output_path: str,
131
+ num_prompts: int = 150,
132
+ sources: Optional[list[str]] = None,
133
+ ) -> None:
134
+ """
135
+ Build a hold-out evaluation set from SFT data.
136
+ Takes prompts only (no completions) for online LLM evaluation.
137
+ """
138
+ if sources is None:
139
+ sources = ["train_sessions_00001.jsonl", "train_augmented_00001.jsonl"]
140
+
141
+ prompts = []
142
+ for src in sources:
143
+ path = os.path.join(input_dir, src)
144
+ if not os.path.exists(path):
145
+ print(f" WARNING: {path} not found, skipping")
146
+ continue
147
+ with open(path) as f:
148
+ for line in f:
149
+ line = line.strip()
150
+ if line:
151
+ item = json.loads(line)
152
+ # Extract just the prompt (user + system, no assistant)
153
+ text = item["text"]
154
+ idx = text.find("<|im_start|>assistant")
155
+ if idx >= 0:
156
+ prompt = text[:idx].strip()
157
+ else:
158
+ prompt = text
159
+ prompts.append({
160
+ "prompt": prompt,
161
+ "source": item["metadata"].get("source", "unknown"),
162
+ })
163
+
164
+ # Take evenly spaced samples
165
+ step = max(1, len(prompts) // num_prompts)
166
+ holdout = [prompts[i] for i in range(0, len(prompts), step)][:num_prompts]
167
+
168
+ with open(output_path, "w") as f:
169
+ for p in holdout:
170
+ f.write(json.dumps(p) + "\n")
171
+
172
+ print(f"Built hold-out set: {len(holdout)} prompts → {output_path}")
173
+
174
+
175
+ if __name__ == "__main__":
176
+ project_dir = os.path.dirname(os.path.abspath(__file__))
177
+ data_dir = "/Volumes/this_and_that/hermes-admin/improvements/hermes-agentic-dataset/data/train"
178
+
179
+ print("=== Genesis-2.0: Evaluation Benchmark ===\n")
180
+
181
+ # Build hold-out set
182
+ holdout_path = os.path.join(project_dir, "eval_holdout.jsonl")
183
+ build_holdout_set(data_dir, holdout_path, num_prompts=150)
184
+ print()
185
+
186
+ # Evaluate SFT data offline (baseline)
187
+ for src in ["train_sessions_00001.jsonl", "train_augmented_00001.jsonl"]:
188
+ path = os.path.join(data_dir, src)
189
+ if os.path.exists(path):
190
+ print(f"\nEvaluating {src}...")
191
+ agg = evaluate_dataset(path, max_samples=50)
192
+ print(f" Tool accuracy: {agg['avg_tool_accuracy']:.3f}")
193
+ print(f" Completion rate: {agg['avg_has_answer']:.3f}")
194
+ print(f" Combined reward: {agg['avg_reward_combined']:.3f}")
195
+ print(f" Tool efficiency: {agg['avg_reward_efficiency']:.3f}")
196
+ print(f" Format compliance: {agg['avg_reward_format']:.3f}")
197
+
198
+ print(f"\nHold-out set ready: {holdout_path}")
199
+ print(f"Run evaluation with: python3 eval_benchmark.py --eval {holdout_path}")
eval_holdout.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
rewards.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Genesis-2.0 RLHF — Reward Functions Module
3
+
4
+ Rule-based reward functions for scoring tool-use trajectories.
5
+ Used by both the DPO preference pair generator and the GRPO trainer.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from typing import Any, Optional
11
+
12
+
13
+ def extract_tool_calls(text: str) -> list[dict[str, Any]]:
14
+ """Extract <tool_call> JSON blocks from assistant messages."""
15
+ tool_calls = []
16
+ pattern = re.compile(r'<tool_call>\s*(\{.*?\})\s*</tool_call>', re.DOTALL)
17
+ for match in pattern.finditer(text):
18
+ try:
19
+ tc = json.loads(match.group(1))
20
+ if isinstance(tc, dict) and "name" in tc:
21
+ tool_calls.append(tc)
22
+ except json.JSONDecodeError:
23
+ continue
24
+ return tool_calls
25
+
26
+
27
+ def extract_assistant_messages(text: str) -> list[str]:
28
+ """Split a conversation into individual assistant response blocks."""
29
+ parts = text.split("<|im_start|>assistant\n")
30
+ if len(parts) <= 1:
31
+ return []
32
+ messages = []
33
+ for p in parts[1:]:
34
+ end_idx = p.find("<|im_end|>")
35
+ if end_idx >= 0:
36
+ messages.append(p[:end_idx].strip())
37
+ return messages
38
+
39
+
40
+ def reward_tool_call_validity(trajectory: str) -> float:
41
+ """
42
+ +1.0 if all <tool_call> blocks contain valid JSON with valid tool names.
43
+ 0.0 if ANY tool call has invalid JSON or missing 'name' field.
44
+ """
45
+ tool_calls = extract_tool_calls(trajectory)
46
+ if not tool_calls:
47
+ return 0.0
48
+ for tc in tool_calls:
49
+ if not isinstance(tc.get("name"), str) or not tc["name"].strip():
50
+ return 0.0
51
+ if "arguments" not in tc:
52
+ return 0.5 # partial: has name but no args
53
+ return 1.0
54
+
55
+
56
+ def reward_task_completion(trajectory: str, expected_keywords: Optional[list[str]] = None) -> float:
57
+ """
58
+ +1.0 if the final assistant message provides a substantive answer.
59
+ 0.0 if the trajectory ends without a meaningful response.
60
+ -1.0 if it ends with an error or hallucination.
61
+ """
62
+ msgs = extract_assistant_messages(trajectory)
63
+ if not msgs:
64
+ return -1.0
65
+
66
+ last = msgs[-1]
67
+
68
+ # If the last message has tool calls but no text answer, task isn't complete
69
+ text_after_tools = re.sub(r'<tool_call>.*?</tool_call>', '', last, flags=re.DOTALL).strip()
70
+ text_after_tools = re.sub(r'<tool_result>.*?</tool_result>', '', text_after_tools, flags=re.DOTALL).strip()
71
+
72
+ tool_calls = extract_tool_calls(last)
73
+ if not text_after_tools and tool_calls:
74
+ return 0.0 # only tool calls, no answer
75
+
76
+ if text_after_tools and len(text_after_tools) > 20:
77
+ # Has substantive text — likely gave an answer
78
+ if expected_keywords:
79
+ hits = sum(1 for kw in expected_keywords if kw.lower() in text_after_tools.lower())
80
+ return min(1.0, hits / max(len(expected_keywords), 1) * 1.5)
81
+ return 1.0
82
+
83
+ if text_after_tools and len(text_after_tools) > 5:
84
+ return 0.5 # short answer, partial completion
85
+
86
+ return 0.0
87
+
88
+
89
+ def reward_tool_result_usage(trajectory: str) -> float:
90
+ """
91
+ +1.0 if tool calls are followed by reasoning that references them.
92
+ 0.0 if tool calls are made but no subsequent reasoning.
93
+ """
94
+ segments = trajectory.split("<|im_start|>assistant\n")
95
+ tool_call_segments = 0
96
+ reasoning_after_call = 0
97
+
98
+ for seg in segments[1:]:
99
+ has_tool_call = bool(re.search(r'<tool_call>', seg))
100
+ if has_tool_call:
101
+ tool_call_segments += 1
102
+ # Remove tool_call blocks and check remaining text
103
+ text_after = re.sub(r'<tool_call>.*?</tool_call>', '', seg, flags=re.DOTALL)
104
+ text_after = re.sub(r'<think>.*?</think>', '', text_after, flags=re.DOTALL)
105
+ text_after = text_after.strip()
106
+ if len(text_after) > 50:
107
+ reasoning_after_call += 1
108
+
109
+ if tool_call_segments == 0:
110
+ return 0.5 # no tools used — neutral
111
+ if reasoning_after_call >= tool_call_segments * 0.5:
112
+ return 1.0
113
+ if reasoning_after_call > 0:
114
+ return 0.5
115
+ return 0.0
116
+
117
+
118
+ def reward_trajectory_efficiency(trajectory: str) -> float:
119
+ """
120
+ Score based on how efficiently tools are used.
121
+ Ideal: 1-3 tool calls for a typical query.
122
+ Penalty for excessive tool calls (>5) or zero tool calls when tools would help.
123
+ """
124
+ tool_calls = extract_tool_calls(trajectory)
125
+ n = len(tool_calls)
126
+
127
+ if n == 0:
128
+ return 0.3 # no tools used — might be fine for simple queries
129
+ elif n <= 3:
130
+ return 1.0 # efficient
131
+ elif n <= 5:
132
+ return 0.7 # acceptable
133
+ elif n <= 8:
134
+ return 0.4 # somewhat excessive
135
+ else:
136
+ return 0.0 # too many tool calls
137
+
138
+
139
+ def reward_format_validity(trajectory: str) -> float:
140
+ """
141
+ +1.0 if tool calls use the correct <tool_call> JSON format.
142
+ 0.0 if there are malformed tool calls.
143
+ """
144
+ # Check for malformed tool_call markers
145
+ malformed = re.findall(r'<tool_call>[^<]*?$', trajectory)
146
+ if malformed:
147
+ return 0.0
148
+
149
+ # Check for unclosed tool_call tags
150
+ open_tags = len(re.findall(r'<tool_call>', trajectory))
151
+ close_tags = len(re.findall(r'</tool_call>', trajectory))
152
+ if open_tags != close_tags:
153
+ return 0.0
154
+
155
+ # All parsed tool calls should have valid JSON
156
+ tool_calls = extract_tool_calls(trajectory)
157
+ expected = len(re.findall(r'<tool_call>', trajectory))
158
+ if expected > 0 and len(tool_calls) < expected:
159
+ return 0.5 # some valid, some not
160
+
161
+ return 1.0
162
+
163
+
164
+ def reward_recovery_from_error(trajectory: str) -> float:
165
+ """
166
+ +1.0 if the model retries after a failed tool call (multi-turn recovery).
167
+ Detects consecutive assistant segments making tool calls.
168
+ 0.0 otherwise.
169
+ """
170
+ segments = trajectory.split("<|im_start|>assistant\n")[1:]
171
+ consecutive_tool_calls = 0
172
+ for i, seg in enumerate(segments):
173
+ has_tc = bool(re.search(r'<tool_call>', seg))
174
+ if has_tc and i > 0:
175
+ prev_has_tc = bool(re.search(r'<tool_call>', segments[i-1]))
176
+ if prev_has_tc:
177
+ consecutive_tool_calls += 1
178
+
179
+ if consecutive_tool_calls >= 1:
180
+ return 1.0 # did a multi-step tool sequence
181
+ return 0.0
182
+
183
+
184
+ def combined_reward(trajectory: str, expected_keywords: Optional[list[str]] = None) -> float:
185
+ """
186
+ Weighted combination of all reward signals.
187
+ Used by both DPO pair generation and GRPO training.
188
+ """
189
+ weights = {
190
+ "validity": 0.25,
191
+ "completion": 0.40,
192
+ "usage": 0.15,
193
+ "efficiency": 0.10,
194
+ "format": 0.05,
195
+ "recovery": 0.05,
196
+ }
197
+
198
+ scores = {
199
+ "validity": reward_tool_call_validity(trajectory),
200
+ "completion": reward_task_completion(trajectory, expected_keywords),
201
+ "usage": reward_tool_result_usage(trajectory),
202
+ "efficiency": reward_trajectory_efficiency(trajectory),
203
+ "format": reward_format_validity(trajectory),
204
+ "recovery": reward_recovery_from_error(trajectory),
205
+ }
206
+
207
+ total = sum(scores[k] * weights[k] for k in weights)
208
+ return max(-1.0, min(1.0, total)) # clamp to [-1, 1]
209
+
210
+
211
+ def reward_debug(trajectory: str) -> dict:
212
+ """Return breakdown of all reward components for debugging."""
213
+ return {
214
+ "validity": reward_tool_call_validity(trajectory),
215
+ "completion": reward_task_completion(trajectory),
216
+ "usage": reward_tool_result_usage(trajectory),
217
+ "efficiency": reward_trajectory_efficiency(trajectory),
218
+ "format": reward_format_validity(trajectory),
219
+ "recovery": reward_recovery_from_error(trajectory),
220
+ "combined": combined_reward(trajectory),
221
+ }
train_grpo.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Genesis-2.0 — Phase 2: GRPO with Verifiable Rewards
4
+
5
+ Run on RunPod RTX PRO 6000 Blackwell (96 GB) using TRL + Unsloth + vLLM.
6
+
7
+ Steps:
8
+ 1. Load Phase 1 checkpoint (or Genesis-1.0 base + all-linear LoRA)
9
+ 2. Configure GRPOTrainer with vLLM for rollouts
10
+ 3. Run GRPO with rule-based reward functions
11
+ 4. Save + upload adapter
12
+
13
+ Usage:
14
+ python3 train_grpo.py [--from-dpo /workspace/genesis2-dpo]
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import json
20
+ import torch
21
+ import argparse
22
+ from typing import Optional
23
+
24
+ os.environ["UNSLOTH_VLLM_STANDBY"] = "1"
25
+
26
+
27
+ class Config:
28
+ # Model
29
+ base_model = "Qwen/Qwen3.6-35B-A3B"
30
+ # If --from-dpo is set, load that adapter. Otherwise load Genesis-1.0 SFT adapter
31
+ initial_adapter = "jacobeen06/Genesis-1.0-SFT-adapter"
32
+ dpo_adapter_path = None # Override with --from-dpo
33
+
34
+ # Output
35
+ output_dir = "/workspace/genesis2-grpo"
36
+ hf_repo = "jacobeen06/Genesis-2.0-GRPO-adapter"
37
+
38
+ # QLoRA
39
+ load_in_4bit = True
40
+ bnb_4bit_quant_type = "nf4"
41
+ bnb_4bit_compute_dtype = torch.bfloat16
42
+
43
+ # All-linear LoRA (Config C)
44
+ lora_r = 32
45
+ lora_alpha = 64
46
+ lora_dropout = 0.0
47
+ lora_target_modules = [
48
+ "q_proj", "k_proj", "v_proj", "o_proj",
49
+ "gate_proj", "up_proj", "down_proj",
50
+ "gate",
51
+ ]
52
+ use_rslora = True
53
+
54
+ # GRPO
55
+ num_generations = 4 # G=4 responses per prompt
56
+ max_length = 4096
57
+ max_prompt_length = 3072
58
+ beta = 0.04 # KL penalty (start)
59
+ beta_decay = True # Decay to 0.01 during training
60
+ clip_high = 0.28 # DAPO-style asymmetric
61
+ clip_low = 0.20
62
+
63
+ # Training
64
+ learning_rate = 3e-6
65
+ lr_scheduler_type = "cosine"
66
+ warmup_ratio = 0.05
67
+ per_device_train_batch_size = 1
68
+ gradient_accumulation_steps = 4
69
+ num_train_epochs = 1
70
+ logging_steps = 5
71
+ save_steps = 100
72
+ save_total_limit = 2
73
+
74
+ # vLLM
75
+ vllm_gpu_memory_utilization = 0.90
76
+
77
+ # Data
78
+ prompts_data = "/workspace/training_prompts.jsonl" # Prompts for GRPO rollout
79
+
80
+
81
+ def load_model(config: Config):
82
+ """Load base model + merge Genesis-1.0 SFT → attach all-linear LoRA."""
83
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
84
+ from peft import PeftModel, LoraConfig, get_peft_model
85
+
86
+ bnb_config = BitsAndBytesConfig(
87
+ load_in_4bit=config.load_in_4bit,
88
+ bnb_4bit_quant_type=config.bnb_4bit_quant_type,
89
+ bnb_4bit_compute_dtype=config.bnb_4bit_compute_dtype,
90
+ )
91
+
92
+ model = AutoModelForCausalLM.from_pretrained(
93
+ config.base_model,
94
+ quantization_config=bnb_config,
95
+ device_map="auto",
96
+ trust_remote_code=True,
97
+ torch_dtype=torch.bfloat16,
98
+ )
99
+
100
+ tokenizer = AutoTokenizer.from_pretrained(config.base_model, trust_remote_code=True)
101
+ if tokenizer.pad_token is None:
102
+ tokenizer.pad_token = tokenizer.eos_token
103
+
104
+ # Load adapter
105
+ if config.dpo_adapter_path and os.path.exists(config.dpo_adapter_path):
106
+ print(f"Loading DPO adapter from {config.dpo_adapter_path}")
107
+ model = PeftModel.from_pretrained(model, config.dpo_adapter_path)
108
+ else:
109
+ print(f"Loading Genesis-1.0 SFT adapter from {config.initial_adapter}")
110
+ model = PeftModel.from_pretrained(model, config.initial_adapter)
111
+
112
+ model = model.merge_and_unload()
113
+
114
+ # Attach new LoRA for GRPO
115
+ lora_config = LoraConfig(
116
+ r=config.lora_r,
117
+ lora_alpha=config.lora_alpha,
118
+ target_modules=config.lora_target_modules,
119
+ lora_dropout=config.lora_dropout,
120
+ bias="none",
121
+ task_type="CAUSAL_LM",
122
+ use_rslora=config.use_rslora,
123
+ )
124
+ model = get_peft_model(model, lora_config)
125
+ model.print_trainable_parameters()
126
+
127
+ return model, tokenizer
128
+
129
+
130
+ def reward_func(completions, **kwargs):
131
+ """
132
+ Rule-based reward function for GRPO.
133
+ Runs the rewards module on each generated completion.
134
+ """
135
+ # Import our reward functions
136
+ sys.path.insert(0, "/workspace/genesis-rlhf")
137
+ from rewards import combined_reward
138
+
139
+ rewards = []
140
+ for completion in completions:
141
+ score = combined_reward(completion)
142
+ rewards.append(score)
143
+ return rewards
144
+
145
+
146
+ def load_prompts(config: Config):
147
+ """Load training prompts for GRPO rollout."""
148
+ prompts = []
149
+ with open(config.prompts_data) as f:
150
+ for line in f:
151
+ line = line.strip()
152
+ if line:
153
+ item = json.loads(line)
154
+ prompts.append(item.get("prompt", item.get("text", "")))
155
+ print(f"Loaded {len(prompts)} prompts for GRPO")
156
+ return prompts
157
+
158
+
159
+ def train_grpo(config: Config):
160
+ from trl import GRPOTrainer
161
+ from transformers import TrainingArguments
162
+ from datasets import Dataset
163
+
164
+ model, tokenizer = load_model(config)
165
+ prompts = load_prompts(config)
166
+
167
+ # Create dataset with just prompts
168
+ dataset = Dataset.from_list([{"prompt": p} for p in prompts])
169
+
170
+ # Training args
171
+ training_args = TrainingArguments(
172
+ output_dir=config.output_dir,
173
+ per_device_train_batch_size=config.per_device_train_batch_size,
174
+ gradient_accumulation_steps=config.gradient_accumulation_steps,
175
+ learning_rate=config.learning_rate,
176
+ lr_scheduler_type=config.lr_scheduler_type,
177
+ warmup_ratio=config.warmup_ratio,
178
+ num_train_epochs=config.num_train_epochs,
179
+ logging_steps=config.logging_steps,
180
+ save_steps=config.save_steps,
181
+ save_total_limit=config.save_total_limit,
182
+ bf16=True,
183
+ tf32=True,
184
+ gradient_checkpointing=True,
185
+ gradient_checkpointing_kwargs={"use_reentrant": False},
186
+ report_to="wandb" if os.environ.get("WANDB_API_KEY") else "none",
187
+ run_name="genesis2-grpo",
188
+ dataloader_num_workers=2,
189
+ )
190
+
191
+ # GRPO Trainer
192
+ trainer = GRPOTrainer(
193
+ model=model,
194
+ reward_funcs=[reward_func],
195
+ args=training_args,
196
+ train_dataset=dataset,
197
+ tokenizer=tokenizer,
198
+ num_generations=config.num_generations,
199
+ max_length=config.max_length,
200
+ max_prompt_length=config.max_prompt_length,
201
+ beta=config.beta,
202
+ clip_high=config.clip_high,
203
+ clip_low=config.clip_low,
204
+ )
205
+
206
+ # Train
207
+ print("Starting GRPO training...")
208
+ trainer.train()
209
+
210
+ # Save
211
+ trainer.save_model(config.output_dir)
212
+ tokenizer.save_pretrained(config.output_dir)
213
+
214
+ # Upload
215
+ try:
216
+ from huggingface_hub import HfApi
217
+ api = HfApi()
218
+ api.create_repo(config.hf_repo, exist_ok=True)
219
+ api.upload_folder(
220
+ folder_path=config.output_dir,
221
+ repo_id=config.hf_repo,
222
+ commit_message="Genesis-2.0 GRPO adapter",
223
+ )
224
+ print(f"Uploaded to {config.hf_repo}")
225
+ except Exception as e:
226
+ print(f"Upload failed (non-fatal): {e}")
227
+
228
+ print(f"DONE! Adapter saved to {config.output_dir}")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ parser = argparse.ArgumentParser()
233
+ parser.add_argument("--from-dpo", type=str, default=None,
234
+ help="Path to DPO-trained adapter to start from")
235
+ args = parser.parse_args()
236
+
237
+ config = Config()
238
+ config.dpo_adapter_path = args.from_dpo
239
+
240
+ print("=" * 60)
241
+ print("Genesis-2.0 — Phase 2: GRPO Training")
242
+ print("=" * 60)
243
+ print(f"Base model: {config.base_model}")
244
+ print(f"Start from DPO: {config.dpo_adapter_path or 'No (from SFT)'}")
245
+ print(f"Generations per prompt: {config.num_generations}")
246
+ print(f"Output: {config.output_dir}")
247
+ print()
248
+
249
+ train_grpo(config)