ParetoOptimal Claude Opus 4.6 commited on
Commit
80d9920
·
1 Parent(s): 01d7974

Add demo script, API endpoints, and training notebook baselines

Browse files

- Add demo.py: standalone heuristic agent for full 6-quarter episodes
(CLI + JSON API, no LLM key required)
- Add /demo, /hyperparameters, /scenarios API endpoints to HF Space
- Update Dockerfile to include demo.py and agent/ directory
- Enhance training notebook with baseline comparisons and reward curves:
- Random baseline (5 seeds, ~0.15-0.25 floor)
- Heuristic baseline via /demo API (~0.50-0.65 target)
- Reward + loss curve plotting after GRPO training
- Before/after evaluation on same seeds
- Results summary with bar charts and JSON export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. Dockerfile +2 -1
  2. demo.py +826 -0
  3. hr_env/server/app.py +218 -0
  4. notebooks/hcm21_trl_training.ipynb +693 -438
Dockerfile CHANGED
@@ -5,7 +5,8 @@ WORKDIR /app
5
  COPY pyproject.toml README.md openenv.yaml ./
6
  COPY hr_env/ hr_env/
7
  COPY server/ server/
8
- COPY __init__.py client.py models.py ./
 
9
 
10
  RUN pip install --no-cache-dir -e ".[server]"
11
 
 
5
  COPY pyproject.toml README.md openenv.yaml ./
6
  COPY hr_env/ hr_env/
7
  COPY server/ server/
8
+ COPY agent/ agent/
9
+ COPY demo.py __init__.py client.py models.py ./
10
 
11
  RUN pip install --no-cache-dir -e ".[server]"
12
 
demo.py ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone demo: run a full 6-quarter HR simulation with a heuristic strategy.
2
+
3
+ No server, no LLM API key required. Seeds the environment with sample data and
4
+ executes data-driven HR decisions through all HCM:21 phases each quarter.
5
+
6
+ Usage:
7
+ python demo.py # Default: seed=42, size=300
8
+ python demo.py --seed 123 --size 250 --scenario budget_cuts
9
+ python demo.py --scenario high_eng_turnover
10
+ python demo.py --all-scenarios # Run all 4 task scenarios
11
+
12
+ API usage (from server):
13
+ from demo import run_demo_json
14
+ result = run_demo_json(seed=42, size=300, scenario="high_eng_turnover")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from hr_env.models import HRAction
24
+ from hr_env.server.environment import HRProductivityEnvironment
25
+
26
+ DEPARTMENTS = ["Engineering", "Sales", "Operations", "HR", "Finance"]
27
+
28
+ SCENARIOS = [
29
+ {"name": "high_eng_turnover", "seed": 42, "size": 300},
30
+ {"name": "budget_cuts", "seed": 123, "size": 250},
31
+ {"name": "rapid_growth", "seed": 456, "size": 350},
32
+ {"name": "balanced_optimization", "seed": 789, "size": 300},
33
+ ]
34
+
35
+
36
+ # ── Formatting Helpers ──────────────────────────────────────────────
37
+
38
+
39
+ def fmt_dollar(v: float) -> str:
40
+ if abs(v) >= 1_000_000:
41
+ return f"${v / 1_000_000:,.1f}M"
42
+ if abs(v) >= 1_000:
43
+ return f"${v:,.0f}"
44
+ return f"${v:.2f}"
45
+
46
+
47
+ def fmt_pct(curr: float, prev: float) -> str:
48
+ if prev == 0:
49
+ return " N/A"
50
+ change = (curr - prev) / abs(prev) * 100
51
+ return f"{change:+.1f}%"
52
+
53
+
54
+ def print_header(seed: int, size: int, scenario: Optional[str]) -> None:
55
+ print()
56
+ print("=" * 64)
57
+ print(" HCM:21 HR Productivity Environment - Demo Playthrough")
58
+ print("=" * 64)
59
+ scn = scenario or "default (no scenario)"
60
+ print(f" Seed: {seed} | Company Size: {size} | Scenario: {scn}")
61
+ print()
62
+
63
+
64
+ def print_baseline(obs_data: dict) -> None:
65
+ summary = obs_data.get("company_summary", {})
66
+ metrics = obs_data.get("baseline_metrics", {})
67
+ print("--- COMPANY BASELINE ---")
68
+ print(f" Employees: {summary.get('total_headcount', '?')} across 5 departments")
69
+ print(f" Revenue: {fmt_dollar(summary.get('revenue', 0))} | Profit: {fmt_dollar(summary.get('profit', 0))}")
70
+ hcva = metrics.get("hcva", 0)
71
+ hcroi = metrics.get("hcroi", 0)
72
+ qips_c = metrics.get("qips", {}).get("composite", 0)
73
+ print(f" HCVA: {fmt_dollar(hcva)}/FTE | HCROI: {hcroi:.2f}x | QIPS: {qips_c:.4f}")
74
+ # Department breakdown
75
+ depts = summary.get("departments", {})
76
+ for name in DEPARTMENTS:
77
+ d = depts.get(name, {})
78
+ print(
79
+ f" {name:12s}: {d.get('headcount', '?'):>3} employees, "
80
+ f"perf {d.get('avg_performance', 0):.1f}, "
81
+ f"engage {d.get('avg_engagement', 0):.0f}, "
82
+ f"risk {d.get('avg_flight_risk', 0):.2f}"
83
+ )
84
+ print()
85
+
86
+
87
+ def print_quarter_header(q: int) -> None:
88
+ print(f"{'=' * 64}")
89
+ print(f" QUARTER {q}")
90
+ print(f"{'=' * 64}")
91
+
92
+
93
+ def print_phase(phase: str) -> None:
94
+ print(f"\n --- {phase.upper()} ---")
95
+
96
+
97
+ def print_metrics_table(
98
+ metrics: dict, prev_metrics: Optional[dict], reward: Optional[float]
99
+ ) -> None:
100
+ prev = prev_metrics or {}
101
+ hcva = metrics.get("hcva", 0)
102
+ hcroi = metrics.get("hcroi", 0)
103
+ qips = metrics.get("qips", {}).get("composite", 0)
104
+ ev = metrics.get("employee_value", 0)
105
+ snap = metrics.get("snapshot", {})
106
+ headcount = snap.get("headcount", 0)
107
+ engagement = snap.get("avg_engagement", 0)
108
+
109
+ p_hcva = prev.get("hcva", 0)
110
+ p_hcroi = prev.get("hcroi", 0)
111
+ p_qips = prev.get("qips", {}).get("composite", 0)
112
+ p_snap = prev.get("snapshot", {})
113
+ p_hc = p_snap.get("headcount", 0)
114
+ p_eng = p_snap.get("avg_engagement", 0)
115
+
116
+ print()
117
+ print(" Metrics:")
118
+ print(f" {'Metric':<14s} {'Value':>12s} {'Change':>8s}")
119
+ print(f" {'-'*14} {'-'*12} {'-'*8}")
120
+ print(f" {'HCVA':<14s} {fmt_dollar(hcva):>12s} {fmt_pct(hcva, p_hcva):>8s}")
121
+ print(f" {'HCROI':<14s} {hcroi:>12.2f}x {fmt_pct(hcroi, p_hcroi):>8s}")
122
+ print(f" {'QIPS':<14s} {qips:>12.4f} {fmt_pct(qips, p_qips):>8s}")
123
+ print(f" {'Empl. Value':<14s} {ev:>12.4f}")
124
+ print(f" {'Headcount':<14s} {headcount:>12d} {fmt_pct(headcount, p_hc):>8s}")
125
+ print(f" {'Engagement':<14s} {engagement:>12.1f} {fmt_pct(engagement, p_eng):>8s}")
126
+
127
+ if reward is not None:
128
+ print(f"\n Quarterly Reward: {reward:+.4f}")
129
+
130
+
131
+ def print_final_summary(
132
+ total_steps: int,
133
+ final_score: float,
134
+ metric_history: List[dict],
135
+ quarterly_rewards: List[float],
136
+ all_events: List[str],
137
+ ) -> None:
138
+ print()
139
+ print("=" * 64)
140
+ print(" FINAL EPISODE SUMMARY")
141
+ print("=" * 64)
142
+ print(f" Total Steps: {total_steps}")
143
+ print(f" Final Score: {final_score:.4f}")
144
+
145
+ if len(metric_history) >= 2:
146
+ first = metric_history[0]
147
+ last = metric_history[-1]
148
+ print(f"\n Metric Trajectory (Baseline -> Q6):")
149
+ h0, h1 = first.get("hcva", 0), last.get("hcva", 0)
150
+ r0, r1 = first.get("hcroi", 0), last.get("hcroi", 0)
151
+ q0, q1 = first.get("qips", {}).get("composite", 0), last.get("qips", {}).get("composite", 0)
152
+ print(f" HCVA: {fmt_dollar(h0):>10s} -> {fmt_dollar(h1):>10s} ({fmt_pct(h1, h0)})")
153
+ print(f" HCROI: {r0:>10.2f}x -> {r1:>10.2f}x ({fmt_pct(r1, r0)})")
154
+ print(f" QIPS: {q0:>10.4f} -> {q1:>10.4f} ({fmt_pct(q1, q0)})")
155
+
156
+ if quarterly_rewards:
157
+ formatted = ", ".join(f"{r:+.4f}" for r in quarterly_rewards)
158
+ print(f"\n Quarterly Rewards: [{formatted}]")
159
+
160
+ if all_events:
161
+ print(f"\n Events Log ({len(all_events)} total):")
162
+ for ev in all_events:
163
+ print(f" - {ev}")
164
+
165
+ print("=" * 64)
166
+ print()
167
+
168
+
169
+ # ── Phase Execution ─────────────────────────────────────────────────
170
+
171
+
172
+ def run_scanning(env: HRProductivityEnvironment) -> dict:
173
+ """Execute scanning phase: query all departments, employees, metrics, financials."""
174
+ print_phase("scanning")
175
+ dept_data = {}
176
+
177
+ # Query each department
178
+ for dept_name in DEPARTMENTS:
179
+ obs = env.step(HRAction(action_type="query_department", department=dept_name))
180
+ d = obs.data or {}
181
+ dept_data[dept_name] = d
182
+ print(
183
+ f" [SCAN] {dept_name:12s}: {d.get('headcount', '?'):>3} employees, "
184
+ f"perf {d.get('avg_performance', 0):.1f}, "
185
+ f"engage {d.get('avg_engagement', 0):.0f}, "
186
+ f"risk {d.get('avg_flight_risk', 0):.2f}"
187
+ )
188
+
189
+ # Query high performers for promotion candidates
190
+ obs = env.step(HRAction(
191
+ action_type="query_employees", parameters={"min_performance": 4.0}
192
+ ))
193
+ high_performers = (obs.data or {}).get("employees", [])
194
+ promote_ids = [
195
+ e["id"] for e in high_performers if e.get("level", 5) < 5
196
+ ][:3]
197
+
198
+ # Query low performers for termination candidates
199
+ obs = env.step(HRAction(
200
+ action_type="query_employees", parameters={"min_flight_risk": 0.0}
201
+ ))
202
+ all_emps = (obs.data or {}).get("employees", [])
203
+ terminate_ids = [
204
+ e["id"] for e in all_emps if e.get("performance_score", 5.0) < 1.8
205
+ ][:2]
206
+
207
+ # Calculate all metrics
208
+ obs = env.step(HRAction(action_type="calculate_metric", metric_name="all"))
209
+ all_metrics = obs.data or {}
210
+ hcva = all_metrics.get("hcva", 0)
211
+ hcroi = all_metrics.get("hcroi", 0)
212
+ qips_c = all_metrics.get("qips", {}).get("composite", 0)
213
+ print(f" [METRIC] HCVA: {fmt_dollar(hcva)} | HCROI: {hcroi:.2f}x | QIPS: {qips_c:.4f}")
214
+
215
+ # Review financials
216
+ obs = env.step(HRAction(action_type="review_financials"))
217
+ fin = obs.data or {}
218
+ hr_budget = fin.get("hr_budget_remaining", 0)
219
+ print(f" [FINANCE] Revenue: {fmt_dollar(fin.get('revenue', 0))} | "
220
+ f"Profit: {fmt_dollar(fin.get('profit', 0))} | "
221
+ f"HR Budget: {fmt_dollar(hr_budget)}")
222
+
223
+ # Advance to planning
224
+ env.step(HRAction(action_type="advance_phase"))
225
+
226
+ return {
227
+ "depts": dept_data,
228
+ "metrics": all_metrics,
229
+ "financials": fin,
230
+ "hr_budget": hr_budget,
231
+ "promote_ids": promote_ids,
232
+ "terminate_ids": terminate_ids,
233
+ }
234
+
235
+
236
+ def plan_quarter(
237
+ env: HRProductivityEnvironment,
238
+ scan_data: dict,
239
+ quarter: int,
240
+ prev_events: List[str],
241
+ ) -> dict:
242
+ """Execute planning phase with data-driven decisions."""
243
+ print_phase("planning")
244
+ depts = scan_data["depts"]
245
+ hr_budget = scan_data["hr_budget"]
246
+ plan = {}
247
+
248
+ # Rank departments by flight risk (descending) and performance (ascending)
249
+ dept_list = [
250
+ {
251
+ "name": name,
252
+ "headcount": d.get("headcount", 0),
253
+ "avg_performance": d.get("avg_performance", 3.0),
254
+ "avg_engagement": d.get("avg_engagement", 70),
255
+ "avg_flight_risk": d.get("avg_flight_risk", 0.1),
256
+ }
257
+ for name, d in depts.items()
258
+ ]
259
+
260
+ by_risk = sorted(dept_list, key=lambda d: d["avg_flight_risk"], reverse=True)
261
+ by_perf = sorted(dept_list, key=lambda d: d["avg_performance"])
262
+ by_headcount = sorted(
263
+ [d for d in dept_list if d["name"] != "HR"],
264
+ key=lambda d: d["headcount"],
265
+ )
266
+
267
+ # 1. Hiring: spread across the 2 smallest non-HR depts to offset turnover
268
+ hiring_dept = by_headcount[0]["name"]
269
+ # React to competitor poaching events
270
+ for ev in prev_events:
271
+ if "poaching" in ev.lower() or "competitor" in ev.lower():
272
+ for d in dept_list:
273
+ if d["name"].lower() in ev.lower():
274
+ hiring_dept = d["name"]
275
+ break
276
+
277
+ # Hire aggressively to offset ~20% quarterly turnover
278
+ total_hc = sum(d["headcount"] for d in dept_list)
279
+ hire_count = max(3, min(15, int(total_hc * 0.12)))
280
+ obs = env.step(HRAction(
281
+ action_type="set_hiring_target", department=hiring_dept, count=hire_count
282
+ ))
283
+ plan["hiring"] = {"dept": hiring_dept, "count": hire_count}
284
+ print(f" [PLAN] Hiring target: {hiring_dept} +{hire_count}")
285
+
286
+ # Also set hiring target for second-smallest dept
287
+ hiring_dept2 = by_headcount[1]["name"] if len(by_headcount) > 1 else hiring_dept
288
+ hire_count2 = max(2, min(10, int(total_hc * 0.08)))
289
+ if hiring_dept2 != hiring_dept:
290
+ obs = env.step(HRAction(
291
+ action_type="set_hiring_target", department=hiring_dept2, count=hire_count2
292
+ ))
293
+ plan["hiring2"] = {"dept": hiring_dept2, "count": hire_count2}
294
+ print(f" [PLAN] Hiring target: {hiring_dept2} +{hire_count2}")
295
+
296
+ # 2. Training: lowest-performing department
297
+ training_dept = by_perf[0]["name"]
298
+ training_amount = min(hr_budget * 0.30, 100_000)
299
+ if training_amount > 0:
300
+ obs = env.step(HRAction(
301
+ action_type="set_training_budget", department=training_dept, amount=training_amount
302
+ ))
303
+ # Update budget tracking from observation
304
+ if obs.data and "hr_budget_remaining" in obs.data:
305
+ hr_budget = obs.data["hr_budget_remaining"]
306
+ else:
307
+ hr_budget -= training_amount
308
+ plan["training"] = {"dept": training_dept, "amount": training_amount}
309
+ print(f" [PLAN] Training budget: {fmt_dollar(training_amount)} -> {training_dept}")
310
+
311
+ # 3. Compensation: highest flight-risk department, +3%
312
+ comp_dept = by_risk[0]["name"]
313
+ comp_pct = 3.0
314
+ obs = env.step(HRAction(
315
+ action_type="set_compensation_policy", department=comp_dept, amount=comp_pct
316
+ ))
317
+ plan["compensation"] = {"dept": comp_dept, "pct": comp_pct}
318
+ print(f" [PLAN] Compensation: +{comp_pct:.0f}% for {comp_dept}")
319
+
320
+ # 4. Retention: highest flight-risk dept (if budget allows)
321
+ retention_dept = by_risk[0]["name"]
322
+ retention_amount = min(hr_budget * 0.20, 50_000)
323
+ if retention_amount > 1000:
324
+ obs = env.step(HRAction(
325
+ action_type="set_retention_program", department=retention_dept, amount=retention_amount
326
+ ))
327
+ plan["retention"] = {"dept": retention_dept, "amount": retention_amount}
328
+ print(f" [PLAN] Retention program: {retention_dept} ({fmt_dollar(retention_amount)})")
329
+
330
+ # Advance to producing
331
+ env.step(HRAction(action_type="advance_phase"))
332
+ return plan
333
+
334
+
335
+ def execute_quarter(
336
+ env: HRProductivityEnvironment,
337
+ plan: dict,
338
+ scan_data: dict,
339
+ ) -> None:
340
+ """Execute producing phase: hire, train, promote, optionally terminate."""
341
+ print_phase("producing")
342
+
343
+ # 1. Execute hiring (primary dept)
344
+ hiring = plan.get("hiring", {})
345
+ if hiring:
346
+ obs = env.step(HRAction(
347
+ action_type="execute_hiring",
348
+ department=hiring["dept"],
349
+ count=hiring["count"],
350
+ ))
351
+ cost = (obs.data or {}).get("cost", 0)
352
+ print(f" [EXEC] Hired {hiring['count']} in {hiring['dept']} (cost: {fmt_dollar(cost)})")
353
+
354
+ # 1b. Execute hiring (secondary dept)
355
+ hiring2 = plan.get("hiring2", {})
356
+ if hiring2:
357
+ obs = env.step(HRAction(
358
+ action_type="execute_hiring",
359
+ department=hiring2["dept"],
360
+ count=hiring2["count"],
361
+ ))
362
+ cost = (obs.data or {}).get("cost", 0)
363
+ print(f" [EXEC] Hired {hiring2['count']} in {hiring2['dept']} (cost: {fmt_dollar(cost)})")
364
+
365
+ # 2. Execute training
366
+ training = plan.get("training", {})
367
+ if training:
368
+ obs = env.step(HRAction(
369
+ action_type="execute_training",
370
+ department=training["dept"],
371
+ amount=20, # 20 hours per employee
372
+ ))
373
+ cost = (obs.data or {}).get("cost", 0)
374
+ hc = (obs.data or {}).get("headcount", "?")
375
+ print(f" [EXEC] Training: {training['dept']}, 20 hrs x {hc} employees (cost: {fmt_dollar(cost)})")
376
+
377
+ # 3. Promote top performers (identified during scanning)
378
+ promote_ids = scan_data.get("promote_ids", [])
379
+ if promote_ids:
380
+ obs = env.step(HRAction(action_type="execute_promotion", employee_ids=promote_ids))
381
+ promoted = sum(
382
+ 1 for p in (obs.data or {}).get("promotions", []) if p.get("success")
383
+ )
384
+ print(f" [EXEC] Promoted {promoted} high-performing employees")
385
+ else:
386
+ print(" [EXEC] No promotion candidates found")
387
+
388
+ # 4. Terminate underperformers (identified during scanning)
389
+ terminate_ids = scan_data.get("terminate_ids", [])
390
+ if terminate_ids:
391
+ obs = env.step(HRAction(action_type="execute_termination", employee_ids=terminate_ids))
392
+ terminated = sum(
393
+ 1 for t in (obs.data or {}).get("terminations", []) if t.get("success")
394
+ )
395
+ print(f" [EXEC] Terminated {terminated} underperformers")
396
+ else:
397
+ print(" [EXEC] No underperformers to terminate")
398
+
399
+ # Advance to controlling
400
+ env.step(HRAction(action_type="advance_phase"))
401
+
402
+
403
+ def run_controlling(env: HRProductivityEnvironment, q: int) -> dict:
404
+ """Execute controlling phase: submit report and advance quarter."""
405
+ print_phase("controlling")
406
+
407
+ # Submit report
408
+ env.step(HRAction(action_type="submit_report"))
409
+ print(f" [REPORT] Q{q} report submitted")
410
+
411
+ # Advance quarter
412
+ obs = env.step(HRAction(action_type="advance_quarter"))
413
+ return {
414
+ "obs": obs,
415
+ "data": obs.data or {},
416
+ "reward": obs.reward,
417
+ "done": obs.done,
418
+ }
419
+
420
+
421
+ # ── Main Demo Runner ────────────────────────────────────────────────
422
+
423
+
424
+ def run_demo(seed: int = 42, size: int = 300, scenario: Optional[str] = None) -> float:
425
+ """Run a full 6-quarter demo episode with a heuristic strategy."""
426
+ print_header(seed, size, scenario)
427
+
428
+ env = HRProductivityEnvironment()
429
+ kwargs: Dict[str, Any] = {"size": size}
430
+ if scenario:
431
+ kwargs["scenario"] = scenario
432
+ obs = env.reset(seed=seed, **kwargs)
433
+
434
+ print_baseline(obs.data or {})
435
+
436
+ # Track state across quarters
437
+ baseline_metrics = (obs.data or {}).get("baseline_metrics", {})
438
+ metric_history = [baseline_metrics]
439
+ quarterly_rewards: List[float] = []
440
+ all_events: List[str] = []
441
+ prev_events: List[str] = []
442
+ total_steps = 0
443
+
444
+ for q in range(1, 7):
445
+ print_quarter_header(q)
446
+
447
+ # Scanning
448
+ scan_data = run_scanning(env)
449
+
450
+ # Planning
451
+ plan = plan_quarter(env, scan_data, q, prev_events)
452
+
453
+ # Producing
454
+ execute_quarter(env, plan, scan_data)
455
+
456
+ # Controlling
457
+ result = run_controlling(env, q)
458
+ data = result["data"]
459
+
460
+ # Collect results
461
+ reward = result["reward"]
462
+ events = data.get("events", [])
463
+ turnover = data.get("turnover", {})
464
+ departed = turnover.get("departed_count", 0)
465
+ metrics = data.get("metrics") or data.get("last_quarter_metrics", {})
466
+
467
+ all_events.extend(events)
468
+ prev_events = events
469
+
470
+ if reward is not None and not result["done"]:
471
+ quarterly_rewards.append(reward)
472
+
473
+ if metrics:
474
+ metric_history.append(metrics)
475
+
476
+ # Print quarter results
477
+ if events:
478
+ print(f"\n Events:")
479
+ for ev in events:
480
+ print(f" - {ev}")
481
+ if departed:
482
+ print(f" Turnover: {departed} employees departed")
483
+
484
+ prev_m = metric_history[-2] if len(metric_history) >= 2 else None
485
+ if metrics:
486
+ print_metrics_table(metrics, prev_m, reward)
487
+
488
+ total_steps = data.get("total_steps", total_steps)
489
+
490
+ if result["done"]:
491
+ final_score = data.get("final_score", reward or 0.0)
492
+ total_steps = data.get("total_steps", total_steps)
493
+ # Collect all quarterly rewards from final data
494
+ qr = data.get("quarterly_rewards", quarterly_rewards)
495
+ if qr:
496
+ quarterly_rewards = qr
497
+ print_final_summary(
498
+ total_steps, final_score, metric_history, quarterly_rewards, all_events
499
+ )
500
+ return final_score
501
+
502
+ # Should not reach here, but just in case
503
+ return 0.0
504
+
505
+
506
+ def _pct_change(curr: float, prev: float) -> Optional[float]:
507
+ if prev == 0:
508
+ return None
509
+ return round((curr - prev) / abs(prev) * 100, 2)
510
+
511
+
512
+ def run_demo_json(
513
+ seed: int = 42, size: int = 300, scenario: Optional[str] = None
514
+ ) -> Dict[str, Any]:
515
+ """Run a full 6-quarter demo and return structured JSON results.
516
+
517
+ This is the API-friendly version of run_demo() — no print output,
518
+ returns all data as a dict suitable for JSON serialization.
519
+ """
520
+ env = HRProductivityEnvironment()
521
+ kwargs: Dict[str, Any] = {"size": size}
522
+ if scenario:
523
+ kwargs["scenario"] = scenario
524
+ obs = env.reset(seed=seed, **kwargs)
525
+
526
+ obs_data = obs.data or {}
527
+ baseline_metrics = obs_data.get("baseline_metrics", {})
528
+ baseline_summary = obs_data.get("company_summary", {})
529
+ metric_history = [baseline_metrics]
530
+ quarterly_rewards: List[float] = []
531
+ all_events: List[str] = []
532
+ prev_events: List[str] = []
533
+ quarters: List[Dict[str, Any]] = []
534
+
535
+ for q in range(1, 7):
536
+ scan_data = run_scanning.__wrapped__(env) if hasattr(run_scanning, '__wrapped__') else _scan_quiet(env)
537
+ plan = _plan_quiet(env, scan_data, q, prev_events)
538
+ _exec_quiet(env, plan, scan_data)
539
+ result = _control_quiet(env)
540
+ data = result["data"]
541
+
542
+ reward = result["reward"]
543
+ events = data.get("events", [])
544
+ turnover = data.get("turnover", {})
545
+ departed = turnover.get("departed_count", 0)
546
+ metrics = data.get("metrics") or data.get("last_quarter_metrics", {})
547
+
548
+ all_events.extend(events)
549
+ prev_events = events
550
+
551
+ prev_m = metric_history[-1] if metric_history else {}
552
+ quarter_result: Dict[str, Any] = {
553
+ "quarter": q,
554
+ "actions": {
555
+ "hiring": plan.get("hiring"),
556
+ "hiring2": plan.get("hiring2"),
557
+ "training": plan.get("training"),
558
+ "compensation": plan.get("compensation"),
559
+ "retention": plan.get("retention"),
560
+ },
561
+ "events": events,
562
+ "turnover_count": departed,
563
+ "quarterly_reward": reward,
564
+ }
565
+
566
+ if metrics:
567
+ metric_history.append(metrics)
568
+ quarter_result["metrics"] = {
569
+ "hcva": metrics.get("hcva", 0),
570
+ "hcroi": metrics.get("hcroi", 0),
571
+ "qips": metrics.get("qips", {}).get("composite", 0),
572
+ "employee_value": metrics.get("employee_value", 0),
573
+ "headcount": metrics.get("snapshot", {}).get("headcount", 0),
574
+ "avg_engagement": round(metrics.get("snapshot", {}).get("avg_engagement", 0), 1),
575
+ }
576
+ quarter_result["changes"] = {
577
+ "hcva_pct": _pct_change(metrics.get("hcva", 0), prev_m.get("hcva", 0)),
578
+ "hcroi_pct": _pct_change(metrics.get("hcroi", 0), prev_m.get("hcroi", 0)),
579
+ "qips_pct": _pct_change(
580
+ metrics.get("qips", {}).get("composite", 0),
581
+ prev_m.get("qips", {}).get("composite", 0),
582
+ ),
583
+ }
584
+
585
+ if reward is not None and not result["done"]:
586
+ quarterly_rewards.append(reward)
587
+
588
+ quarters.append(quarter_result)
589
+
590
+ if result["done"]:
591
+ final_score = data.get("final_score", reward or 0.0)
592
+ qr = data.get("quarterly_rewards", quarterly_rewards)
593
+ if qr:
594
+ quarterly_rewards = qr
595
+ break
596
+
597
+ first_m = metric_history[0] if metric_history else {}
598
+ last_m = metric_history[-1] if metric_history else {}
599
+
600
+ return {
601
+ "seed": seed,
602
+ "size": size,
603
+ "scenario": scenario,
604
+ "baseline": {
605
+ "headcount": baseline_summary.get("total_headcount", 0),
606
+ "revenue": baseline_summary.get("revenue", 0),
607
+ "profit": baseline_summary.get("profit", 0),
608
+ "hcva": first_m.get("hcva", 0),
609
+ "hcroi": first_m.get("hcroi", 0),
610
+ "qips": first_m.get("qips", {}).get("composite", 0),
611
+ },
612
+ "final": {
613
+ "score": final_score,
614
+ "total_steps": data.get("total_steps", 0),
615
+ "hcva": last_m.get("hcva", 0),
616
+ "hcroi": last_m.get("hcroi", 0),
617
+ "qips": last_m.get("qips", {}).get("composite", 0),
618
+ "hcva_change_pct": _pct_change(last_m.get("hcva", 0), first_m.get("hcva", 0)),
619
+ "hcroi_change_pct": _pct_change(last_m.get("hcroi", 0), first_m.get("hcroi", 0)),
620
+ "qips_change_pct": _pct_change(
621
+ last_m.get("qips", {}).get("composite", 0),
622
+ first_m.get("qips", {}).get("composite", 0),
623
+ ),
624
+ },
625
+ "quarterly_rewards": quarterly_rewards,
626
+ "events": all_events,
627
+ "quarters": quarters,
628
+ }
629
+
630
+
631
+ def _scan_quiet(env: HRProductivityEnvironment) -> dict:
632
+ """Scanning phase — no print output."""
633
+ dept_data = {}
634
+ for dept_name in DEPARTMENTS:
635
+ obs = env.step(HRAction(action_type="query_department", department=dept_name))
636
+ dept_data[dept_name] = obs.data or {}
637
+
638
+ obs = env.step(HRAction(
639
+ action_type="query_employees", parameters={"min_performance": 4.0}
640
+ ))
641
+ high_performers = (obs.data or {}).get("employees", [])
642
+ promote_ids = [e["id"] for e in high_performers if e.get("level", 5) < 5][:3]
643
+
644
+ obs = env.step(HRAction(
645
+ action_type="query_employees", parameters={"min_flight_risk": 0.0}
646
+ ))
647
+ all_emps = (obs.data or {}).get("employees", [])
648
+ terminate_ids = [
649
+ e["id"] for e in all_emps if e.get("performance_score", 5.0) < 1.8
650
+ ][:2]
651
+
652
+ obs = env.step(HRAction(action_type="calculate_metric", metric_name="all"))
653
+ all_metrics = obs.data or {}
654
+
655
+ obs = env.step(HRAction(action_type="review_financials"))
656
+ fin = obs.data or {}
657
+
658
+ env.step(HRAction(action_type="advance_phase"))
659
+
660
+ return {
661
+ "depts": dept_data,
662
+ "metrics": all_metrics,
663
+ "financials": fin,
664
+ "hr_budget": fin.get("hr_budget_remaining", 0),
665
+ "promote_ids": promote_ids,
666
+ "terminate_ids": terminate_ids,
667
+ }
668
+
669
+
670
+ def _plan_quiet(
671
+ env: HRProductivityEnvironment, scan_data: dict, quarter: int, prev_events: List[str]
672
+ ) -> dict:
673
+ """Planning phase — no print output."""
674
+ depts = scan_data["depts"]
675
+ hr_budget = scan_data["hr_budget"]
676
+ plan: Dict[str, Any] = {}
677
+
678
+ dept_list = [
679
+ {
680
+ "name": name,
681
+ "headcount": d.get("headcount", 0),
682
+ "avg_performance": d.get("avg_performance", 3.0),
683
+ "avg_engagement": d.get("avg_engagement", 70),
684
+ "avg_flight_risk": d.get("avg_flight_risk", 0.1),
685
+ }
686
+ for name, d in depts.items()
687
+ ]
688
+
689
+ by_risk = sorted(dept_list, key=lambda d: d["avg_flight_risk"], reverse=True)
690
+ by_perf = sorted(dept_list, key=lambda d: d["avg_performance"])
691
+ by_headcount = sorted(
692
+ [d for d in dept_list if d["name"] != "HR"], key=lambda d: d["headcount"]
693
+ )
694
+
695
+ hiring_dept = by_headcount[0]["name"]
696
+ for ev in prev_events:
697
+ if "poaching" in ev.lower() or "competitor" in ev.lower():
698
+ for d in dept_list:
699
+ if d["name"].lower() in ev.lower():
700
+ hiring_dept = d["name"]
701
+ break
702
+
703
+ total_hc = sum(d["headcount"] for d in dept_list)
704
+ hire_count = max(3, min(15, int(total_hc * 0.12)))
705
+ env.step(HRAction(action_type="set_hiring_target", department=hiring_dept, count=hire_count))
706
+ plan["hiring"] = {"dept": hiring_dept, "count": hire_count}
707
+
708
+ hiring_dept2 = by_headcount[1]["name"] if len(by_headcount) > 1 else hiring_dept
709
+ hire_count2 = max(2, min(10, int(total_hc * 0.08)))
710
+ if hiring_dept2 != hiring_dept:
711
+ env.step(HRAction(action_type="set_hiring_target", department=hiring_dept2, count=hire_count2))
712
+ plan["hiring2"] = {"dept": hiring_dept2, "count": hire_count2}
713
+
714
+ training_dept = by_perf[0]["name"]
715
+ training_amount = min(hr_budget * 0.30, 100_000)
716
+ if training_amount > 0:
717
+ obs = env.step(HRAction(
718
+ action_type="set_training_budget", department=training_dept, amount=training_amount
719
+ ))
720
+ if obs.data and "hr_budget_remaining" in obs.data:
721
+ hr_budget = obs.data["hr_budget_remaining"]
722
+ else:
723
+ hr_budget -= training_amount
724
+ plan["training"] = {"dept": training_dept, "amount": training_amount}
725
+
726
+ comp_dept = by_risk[0]["name"]
727
+ env.step(HRAction(action_type="set_compensation_policy", department=comp_dept, amount=3.0))
728
+ plan["compensation"] = {"dept": comp_dept, "pct": 3.0}
729
+
730
+ retention_dept = by_risk[0]["name"]
731
+ retention_amount = min(hr_budget * 0.20, 50_000)
732
+ if retention_amount > 1000:
733
+ env.step(HRAction(
734
+ action_type="set_retention_program", department=retention_dept, amount=retention_amount
735
+ ))
736
+ plan["retention"] = {"dept": retention_dept, "amount": retention_amount}
737
+
738
+ env.step(HRAction(action_type="advance_phase"))
739
+ return plan
740
+
741
+
742
+ def _exec_quiet(
743
+ env: HRProductivityEnvironment, plan: dict, scan_data: dict
744
+ ) -> None:
745
+ """Producing phase — no print output."""
746
+ hiring = plan.get("hiring", {})
747
+ if hiring:
748
+ env.step(HRAction(action_type="execute_hiring", department=hiring["dept"], count=hiring["count"]))
749
+
750
+ hiring2 = plan.get("hiring2", {})
751
+ if hiring2:
752
+ env.step(HRAction(action_type="execute_hiring", department=hiring2["dept"], count=hiring2["count"]))
753
+
754
+ training = plan.get("training", {})
755
+ if training:
756
+ env.step(HRAction(action_type="execute_training", department=training["dept"], amount=20))
757
+
758
+ promote_ids = scan_data.get("promote_ids", [])
759
+ if promote_ids:
760
+ env.step(HRAction(action_type="execute_promotion", employee_ids=promote_ids))
761
+
762
+ terminate_ids = scan_data.get("terminate_ids", [])
763
+ if terminate_ids:
764
+ env.step(HRAction(action_type="execute_termination", employee_ids=terminate_ids))
765
+
766
+ env.step(HRAction(action_type="advance_phase"))
767
+
768
+
769
+ def _control_quiet(env: HRProductivityEnvironment) -> dict:
770
+ """Controlling phase — no print output."""
771
+ env.step(HRAction(action_type="submit_report"))
772
+ obs = env.step(HRAction(action_type="advance_quarter"))
773
+ return {
774
+ "obs": obs,
775
+ "data": obs.data or {},
776
+ "reward": obs.reward,
777
+ "done": obs.done,
778
+ }
779
+
780
+
781
+ def main() -> None:
782
+ parser = argparse.ArgumentParser(
783
+ description="Run a full 6-quarter HR simulation demo with heuristic strategy"
784
+ )
785
+ parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
786
+ parser.add_argument("--size", type=int, default=300, help="Company size (default: 300)")
787
+ parser.add_argument(
788
+ "--scenario",
789
+ choices=["high_eng_turnover", "budget_cuts", "rapid_growth", "balanced_optimization"],
790
+ help="Scenario variant to run",
791
+ )
792
+ parser.add_argument(
793
+ "--all-scenarios", action="store_true",
794
+ help="Run all 4 task scenarios and compare results",
795
+ )
796
+ args = parser.parse_args()
797
+
798
+ if args.all_scenarios:
799
+ print()
800
+ print("=" * 64)
801
+ print(" Running all 4 scenarios...")
802
+ print("=" * 64)
803
+ results = []
804
+ for scn in SCENARIOS:
805
+ score = run_demo(seed=scn["seed"], size=scn["size"], scenario=scn["name"])
806
+ results.append((scn["name"], score))
807
+
808
+ print()
809
+ print("=" * 64)
810
+ print(" SCENARIO COMPARISON")
811
+ print("=" * 64)
812
+ print(f" {'Scenario':<28s} {'Score':>8s}")
813
+ print(f" {'-'*28} {'-'*8}")
814
+ for name, score in results:
815
+ print(f" {name:<28s} {score:>8.4f}")
816
+ avg = sum(s for _, s in results) / len(results)
817
+ print(f" {'-'*28} {'-'*8}")
818
+ print(f" {'Average':<28s} {avg:>8.4f}")
819
+ print("=" * 64)
820
+ else:
821
+ score = run_demo(seed=args.seed, size=args.size, scenario=args.scenario)
822
+ print(f" Exit score: {score:.4f}")
823
+
824
+
825
+ if __name__ == "__main__":
826
+ main()
hr_env/server/app.py CHANGED
@@ -1,5 +1,11 @@
1
  """FastAPI application entry point for the HR Productivity Environment."""
2
 
 
 
 
 
 
 
3
  from openenv.core.env_server.http_server import create_app
4
 
5
  from hr_env.models import HRAction, HRObservation
@@ -12,3 +18,215 @@ app = create_app(
12
  env_name="hr-productivity-env",
13
  max_concurrent_envs=1,
14
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """FastAPI application entry point for the HR Productivity Environment."""
2
 
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from fastapi import Query
8
+ from fastapi.responses import RedirectResponse
9
  from openenv.core.env_server.http_server import create_app
10
 
11
  from hr_env.models import HRAction, HRObservation
 
18
  env_name="hr-productivity-env",
19
  max_concurrent_envs=1,
20
  )
21
+
22
+
23
+ @app.get("/")
24
+ async def root():
25
+ return RedirectResponse(url="/web")
26
+
27
+
28
+ # ── Hyperparameters endpoint ────────────────────────────────────────
29
+
30
+
31
+ @app.get("/hyperparameters")
32
+ async def hyperparameters():
33
+ """Return all environment hyperparameters and configuration constants."""
34
+ from hr_env.server.company import (
35
+ BENEFITS_MULTIPLIER,
36
+ DEPT_CONFIGS,
37
+ RECRUITING_COST_PER_HIRE,
38
+ )
39
+ from hr_env.server.data_gen import (
40
+ DEPT_SIZE_FRACTIONS,
41
+ DEPT_SKILLS,
42
+ ROLES,
43
+ SALARY_BASE,
44
+ SALARY_SIGMA,
45
+ )
46
+ from hr_env.server.events import ALL_EVENTS
47
+ from hr_env.server.phases import PHASE_ACTIONS, PHASE_MIN_ACTIONS, PHASES
48
+ from hr_env.server.scoring import compute_final_score # noqa: F401
49
+
50
+ return {
51
+ "environment": {
52
+ "max_quarters": 6,
53
+ "max_steps": 300,
54
+ "max_concurrent_envs": 1,
55
+ "default_company_size": 300,
56
+ "recommended_size_range": [200, 500],
57
+ "departments": ["Engineering", "Sales", "Operations", "HR", "Finance"],
58
+ },
59
+ "company_financials": {
60
+ "base_revenue_formula": "size * 150,000",
61
+ "hr_budget_formula": "size * 6,000",
62
+ "quarterly_hr_budget": "hr_budget / 4",
63
+ "non_employment_cost_ratio": 0.55,
64
+ "benefits_multiplier": BENEFITS_MULTIPLIER,
65
+ "recruiting_cost_per_hire": RECRUITING_COST_PER_HIRE,
66
+ "training_cost_per_hour_per_employee": 50,
67
+ },
68
+ "cobb_douglas_production": {
69
+ dept: {
70
+ "A": cfg["A"],
71
+ "alpha": cfg["alpha"],
72
+ "beta": cfg["beta"],
73
+ "base_revenue_share": cfg["base_revenue_share"],
74
+ }
75
+ for dept, cfg in DEPT_CONFIGS.items()
76
+ },
77
+ "employee_generation": {
78
+ "salary_base_by_level": SALARY_BASE,
79
+ "salary_lognormal_sigma": SALARY_SIGMA,
80
+ "performance_distribution": {"type": "normal", "mean": 3.2, "std": 0.8, "clip": [1.0, 5.0]},
81
+ "tenure_distribution": {"type": "exponential", "lambda_months": 24},
82
+ "engagement_distribution": {"type": "beta", "alpha": 7, "beta": 3, "scale": 100},
83
+ "level_distribution": {
84
+ "type": "categorical",
85
+ "probabilities": {"L1": 0.35, "L2": 0.30, "L3": 0.20, "L4": 0.10, "L5": 0.05},
86
+ },
87
+ "dept_size_fractions": DEPT_SIZE_FRACTIONS,
88
+ "skills_per_employee": {"min": 2, "max": 4},
89
+ },
90
+ "employee_lifecycle": {
91
+ "promotion_salary_increase": 0.15,
92
+ "promotion_engagement_boost": 10,
93
+ "promotion_flight_risk_reduction": 0.15,
94
+ "quarterly_engagement_decay": -1.5,
95
+ "long_tenure_no_promotion_penalty": -3.0,
96
+ "long_tenure_threshold_quarters": 4,
97
+ "transfer_engagement_penalty": -5,
98
+ "termination_colleague_engagement_penalty": -3,
99
+ "new_hire_engagement_distribution": {"type": "beta", "alpha": 8, "beta": 2, "scale": 100},
100
+ },
101
+ "flight_risk": {
102
+ "model": "logistic",
103
+ "formula": "sigmoid(-1 + 2*pay_factor + 1.5*engagement_factor + 1*tenure_factor + 0.8*market_demand)",
104
+ "turnover_probability": "flight_risk * 0.4 per quarter",
105
+ "bounds": [0.02, 0.95],
106
+ },
107
+ "phase_system": {
108
+ "phases": PHASES,
109
+ "actions_per_phase": PHASE_ACTIONS,
110
+ "minimum_actions_per_phase": PHASE_MIN_ACTIONS,
111
+ },
112
+ "stochastic_events": [
113
+ {
114
+ "name": ev.name,
115
+ "description": ev.description,
116
+ "probability": ev.probability,
117
+ }
118
+ for ev in ALL_EVENTS
119
+ ],
120
+ "scoring": {
121
+ "quarterly_reward_weights": {
122
+ "hcva_improvement": 0.40,
123
+ "hcroi_improvement": 0.20,
124
+ "qips_improvement": 0.20,
125
+ "five_indexes": 0.20,
126
+ },
127
+ "final_score_weights": {
128
+ "hcva_trajectory": 0.25,
129
+ "hcroi_final_vs_baseline": 0.20,
130
+ "employee_value": 0.20,
131
+ "qips_consistency": 0.15,
132
+ "five_indexes_cumulative": 0.10,
133
+ "financial_health": 0.10,
134
+ },
135
+ "calibration_targets": {
136
+ "random_agent": "0.15-0.25",
137
+ "reasonable_heuristic": "~0.50",
138
+ "strong_agent": "0.70+",
139
+ },
140
+ },
141
+ "heuristic_strategy": {
142
+ "description": "Data-driven heuristic used in the demo endpoint",
143
+ "hiring_rate": "12% of current headcount for primary dept, 8% for secondary",
144
+ "training_budget_fraction": 0.30,
145
+ "training_hours_per_session": 20,
146
+ "compensation_adjustment_pct": 3.0,
147
+ "retention_budget_fraction": 0.20,
148
+ "promotion_criteria": {"min_performance": 4.0, "max_level": 4, "max_per_quarter": 3},
149
+ "termination_criteria": {"max_performance": 1.8, "max_per_quarter": 2},
150
+ },
151
+ "roles_by_department": {
152
+ dept: [{"role": role, "level": level} for role, level in roles]
153
+ for dept, roles in ROLES.items()
154
+ },
155
+ "skills_by_department": DEPT_SKILLS,
156
+ }
157
+
158
+
159
+ # ── Scenarios endpoint ──────────────────────────────────────────────
160
+
161
+
162
+ @app.get("/scenarios")
163
+ async def scenarios():
164
+ """Return all available task scenarios with their configurations."""
165
+ tasks_dir = Path(__file__).resolve().parent.parent / "tasks"
166
+ task_list = []
167
+
168
+ if tasks_dir.exists():
169
+ for f in sorted(tasks_dir.glob("*.json")):
170
+ try:
171
+ task_list.append(json.loads(f.read_text()))
172
+ except (json.JSONDecodeError, OSError):
173
+ continue
174
+
175
+ # Also include scenario effect descriptions
176
+ scenario_effects = {
177
+ "high_eng_turnover": {
178
+ "modifications": "Engineering flight_risk += 0.20, engagement -= 10",
179
+ "challenge": "Stabilize retention in a bleeding engineering department",
180
+ },
181
+ "budget_cuts": {
182
+ "modifications": "HR budget halved (50% reduction)",
183
+ "challenge": "Optimize workforce with severely constrained resources",
184
+ },
185
+ "rapid_growth": {
186
+ "modifications": "Revenue +30%, 15% employees deactivated (understaffing)",
187
+ "challenge": "Scale the workforce while maintaining quality and culture",
188
+ },
189
+ "balanced_optimization": {
190
+ "modifications": "All employees: performance -0.3, engagement -8",
191
+ "challenge": "Improve below-average metrics across the board",
192
+ },
193
+ }
194
+
195
+ return {
196
+ "scenarios": [
197
+ {
198
+ **task,
199
+ "effects": scenario_effects.get(task.get("params", {}).get("scenario"), {}),
200
+ }
201
+ for task in task_list
202
+ ],
203
+ "default": {
204
+ "seed": 42,
205
+ "size": 300,
206
+ "scenario": None,
207
+ "description": "Standard company with no scenario modifications",
208
+ },
209
+ }
210
+
211
+
212
+ # ── Demo endpoint ───────────────────────────────────────────────────
213
+
214
+
215
+ @app.get("/demo")
216
+ async def demo(
217
+ seed: int = Query(42, description="Random seed for reproducibility"),
218
+ size: int = Query(300, ge=50, le=1000, description="Company size (employees)"),
219
+ scenario: Optional[str] = Query(
220
+ None,
221
+ description="Scenario variant",
222
+ enum=["high_eng_turnover", "budget_cuts", "rapid_growth", "balanced_optimization"],
223
+ ),
224
+ ):
225
+ """Run a full 6-quarter demo with a heuristic strategy and return results.
226
+
227
+ Executes data-driven HR decisions through all HCM:21 phases for 6 quarters.
228
+ No LLM API key required — uses a built-in heuristic agent.
229
+ """
230
+ from demo import run_demo_json
231
+
232
+ return run_demo_json(seed=seed, size=size, scenario=scenario)
notebooks/hcm21_trl_training.ipynb CHANGED
@@ -1,440 +1,695 @@
1
  {
2
- "nbformat": 4,
3
- "nbformat_minor": 0,
4
- "metadata": {
5
- "colab": {
6
- "provenance": [],
7
- "gpuType": "T4"
8
- },
9
- "kernelspec": {
10
- "name": "python3",
11
- "display_name": "Python 3"
12
- },
13
- "language_info": {
14
- "name": "python"
15
- },
16
- "accelerator": "GPU"
17
  },
18
- "cells": [
19
- {
20
- "cell_type": "markdown",
21
- "metadata": {},
22
- "source": [
23
- "# HCM:21 Training with TRL + Unsloth\n",
24
- "\n",
25
- "This notebook trains a small LLM to play as Chief Human Capital Officer in the\n",
26
- "[HCM:21 environment](https://huggingface.co/spaces/ParetoOptimal/hcm21) using\n",
27
- "**GRPO** (Group Relative Policy Optimization) from HuggingFace TRL, with\n",
28
- "**Unsloth** for efficient fine-tuning on a free Colab T4 GPU.\n",
29
- "\n",
30
- "The agent interacts with the HCM:21 OpenEnv environment deployed on HF Spaces\n",
31
- "via WebSocket, making HR decisions (hiring, training, compensation, retention)\n",
32
- "across 6 simulated quarters and receiving sparse rewards based on Fitz-enz\n",
33
- "HR analytics metrics.\n",
34
- "\n",
35
- "**Environment:** https://huggingface.co/spaces/ParetoOptimal/hcm21 \n",
36
- "**GitHub:** https://github.com/n8mauer/HCM-21"
37
- ]
38
- },
39
- {
40
- "cell_type": "markdown",
41
- "metadata": {},
42
- "source": [
43
- "## 1. Install Dependencies"
44
- ]
45
- },
46
- {
47
- "cell_type": "code",
48
- "execution_count": null,
49
- "metadata": {},
50
- "outputs": [],
51
- "source": [
52
- "%%capture\n",
53
- "!pip install unsloth trl openenv-core pydantic datasets\n",
54
- "# Unsloth automatically patches transformers for 2x faster training on T4"
55
- ]
56
- },
57
- {
58
- "cell_type": "markdown",
59
- "metadata": {},
60
- "source": [
61
- "## 2. Load Model with Unsloth"
62
- ]
63
- },
64
- {
65
- "cell_type": "code",
66
- "execution_count": null,
67
- "metadata": {},
68
- "outputs": [],
69
- "source": [
70
- "from unsloth import FastLanguageModel\n",
71
- "\n",
72
- "model, tokenizer = FastLanguageModel.from_pretrained(\n",
73
- " model_name=\"unsloth/Qwen3-0.6B\",\n",
74
- " max_seq_length=4096,\n",
75
- " load_in_4bit=True,\n",
76
- ")\n",
77
- "\n",
78
- "model = FastLanguageModel.get_peft_model(\n",
79
- " model,\n",
80
- " r=16,\n",
81
- " target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n",
82
- " \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
83
- " lora_alpha=16,\n",
84
- " lora_dropout=0,\n",
85
- " use_gradient_checkpointing=\"unsloth\",\n",
86
- ")\n",
87
- "print(f\"Model loaded. Trainable params: {model.print_trainable_parameters()}\")"
88
- ]
89
- },
90
- {
91
- "cell_type": "markdown",
92
- "metadata": {},
93
- "source": [
94
- "## 3. Define the HCM:21 Environment Wrapper\n",
95
- "\n",
96
- "We wrap the OpenEnv WebSocket client as a TRL `environment_factory` that\n",
97
- "exposes `reset()`, `step()`, and `get_reward()` methods. The agent calls\n",
98
- "`step()` as a tool, and the reward comes from quarterly HR metric improvements.\n",
99
- "\n",
100
- "The OpenEnv WebSocket protocol maintains stateful sessions — each connection\n",
101
- "gets its own Environment instance on the server."
102
- ]
103
- },
104
- {
105
- "cell_type": "code",
106
- "execution_count": null,
107
- "metadata": {},
108
- "outputs": [],
109
- "source": [
110
- "import json\n",
111
- "from openenv.core import GenericEnvClient\n",
112
- "\n",
113
- "HCM21_URL = \"https://paretooptimal-hcm21.hf.space\"\n",
114
- "\n",
115
- "\n",
116
- "class HCM21Env:\n",
117
- " \"\"\"TRL-compatible wrapper for the HCM:21 OpenEnv environment.\n",
118
- "\n",
119
- " Uses the OpenEnv GenericEnvClient (WebSocket) for stateful sessions.\n",
120
- " Each instance gets its own environment on the server.\n",
121
- " \"\"\"\n",
122
- "\n",
123
- " def __init__(self):\n",
124
- " self._client = GenericEnvClient(base_url=HCM21_URL)\n",
125
- " self._client.connect()\n",
126
- " self._reward = 0.0\n",
127
- " self._done = False\n",
128
- "\n",
129
- " def reset(self, seed: int = 42) -> str:\n",
130
- " \"\"\"Reset the environment for a new episode.\"\"\"\n",
131
- " result = self._client.reset(seed=seed)\n",
132
- " obs = result.observation\n",
133
- " self._reward = result.reward or 0.0\n",
134
- " self._done = result.done\n",
135
- " return self._format_obs(obs)\n",
136
- "\n",
137
- " def step(self, action_json: str) -> str:\n",
138
- " \"\"\"Take an action in the environment.\n",
139
- "\n",
140
- " Args:\n",
141
- " action_json: JSON string with action_type and parameters.\n",
142
- " Example: {\\\"action_type\\\": \\\"query_department\\\", \\\"department\\\": \\\"Engineering\\\"}\n",
143
- "\n",
144
- " Returns:\n",
145
- " Observation string describing the result.\n",
146
- " \"\"\"\n",
147
- " try:\n",
148
- " action = json.loads(action_json)\n",
149
- " except json.JSONDecodeError:\n",
150
- " return \"Error: Invalid JSON. Provide a valid action JSON object.\"\n",
151
- "\n",
152
- " from openenv.core import GenericAction\n",
153
- " generic_action = GenericAction(**action)\n",
154
- " result = self._client.step(generic_action)\n",
155
- " obs = result.observation\n",
156
- " if result.reward is not None:\n",
157
- " self._reward = float(result.reward)\n",
158
- " self._done = result.done\n",
159
- " return self._format_obs(obs)\n",
160
- "\n",
161
- " def get_reward(self) -> float:\n",
162
- " \"\"\"Return the reward from the last step.\"\"\"\n",
163
- " return self._reward\n",
164
- "\n",
165
- " def close(self):\n",
166
- " \"\"\"Disconnect from the environment.\"\"\"\n",
167
- " try:\n",
168
- " self._client.disconnect()\n",
169
- " except Exception:\n",
170
- " pass\n",
171
- "\n",
172
- " def _format_obs(self, obs) -> str:\n",
173
- " \"\"\"Format observation as a concise string for the LLM.\"\"\"\n",
174
- " if isinstance(obs, dict):\n",
175
- " data = obs\n",
176
- " else:\n",
177
- " data = obs if isinstance(obs, dict) else vars(obs) if hasattr(obs, '__dict__') else {\"raw\": str(obs)}\n",
178
- " parts = []\n",
179
- " parts.append(f\"Q{data.get('current_quarter', '?')} | {data.get('current_phase', '?')} phase\")\n",
180
- " parts.append(str(data.get('message', '')))\n",
181
- " warnings = data.get('warnings', [])\n",
182
- " if warnings:\n",
183
- " parts.append(f\"Warnings: {', '.join(str(w) for w in warnings)}\")\n",
184
- " actions = data.get('available_actions', [])\n",
185
- " if actions:\n",
186
- " parts.append(f\"Available: {', '.join(str(a) for a in actions)}\")\n",
187
- " obs_data = data.get('data')\n",
188
- " if obs_data:\n",
189
- " data_str = json.dumps(obs_data, indent=2, default=str)\n",
190
- " if len(data_str) > 1500:\n",
191
- " data_str = data_str[:1500] + \"...\"\n",
192
- " parts.append(f\"Data: {data_str}\")\n",
193
- " return \"\\n\".join(parts)\n",
194
- "\n",
195
- "\n",
196
- "# Quick connectivity test\n",
197
- "import requests\n",
198
- "try:\n",
199
- " health = requests.get(f\"{HCM21_URL}/health\", timeout=10)\n",
200
- " print(f\"Environment health: {health.json()}\")\n",
201
- " env_test = HCM21Env()\n",
202
- " obs = env_test.reset(seed=42)\n",
203
- " print(f\"\\nReset OK. Observation preview:\")\n",
204
- " print(obs[:300])\n",
205
- " env_test.close()\n",
206
- "except Exception as e:\n",
207
- " print(f\"Environment not reachable: {e}\")\n",
208
- " print(\"If the Space is still building, wait a few minutes and re-run.\")"
209
- ]
210
- },
211
- {
212
- "cell_type": "markdown",
213
- "metadata": {},
214
- "source": [
215
- "## 4. Build Training Dataset\n",
216
- "\n",
217
- "For GRPO, we need a dataset of prompts that start each episode.\n",
218
- "Each prompt describes the CHCO role and the current scenario."
219
- ]
220
- },
221
- {
222
- "cell_type": "code",
223
- "execution_count": null,
224
- "metadata": {},
225
- "outputs": [],
226
- "source": [
227
- "from datasets import Dataset\n",
228
- "\n",
229
- "SYSTEM_PROMPT = \"\"\"You are the Chief Human Capital Officer (CHCO) of a simulated company.\n",
230
- "You manage 300 employees across 5 departments over 6 quarters.\n",
231
- "Each quarter has 4 phases: Scanning, Planning, Producing, Controlling.\n",
232
- "Maximize HCVA, HCROI, and QIPS metrics by making strategic HR decisions.\n",
233
- "\n",
234
- "When calling the step tool, provide a JSON action object like:\n",
235
- "{\"action_type\": \"query_department\", \"department\": \"Engineering\"}\n",
236
- "\n",
237
- "Available action types vary by phase:\n",
238
- "- Scanning: query_department, query_employees, calculate_metric, review_financials\n",
239
- "- Planning: set_hiring_target, set_training_budget, set_compensation_policy, set_retention_program\n",
240
- "- Producing: execute_hiring, execute_promotion, execute_transfer, execute_training, execute_termination\n",
241
- "- Controlling: submit_report, calculate_metric, advance_quarter\n",
242
- "- advance_phase: move to next phase (after minimum actions taken)\n",
243
- "\n",
244
- "Think step by step. Start by scanning each department.\"\"\"\n",
245
- "\n",
246
- "# Create prompts with different seeds for diversity\n",
247
- "prompts = []\n",
248
- "for seed in range(20):\n",
249
- " prompts.append({\n",
250
- " \"prompt\": [\n",
251
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
252
- " {\"role\": \"user\", \"content\": f\"Begin managing the company. Episode seed: {seed}. Start by scanning your organization to understand its current state.\"},\n",
253
- " ]\n",
254
- " })\n",
255
- "\n",
256
- "dataset = Dataset.from_list(prompts)\n",
257
- "print(f\"Training dataset: {len(dataset)} episodes\")"
258
- ]
259
- },
260
- {
261
- "cell_type": "markdown",
262
- "metadata": {},
263
- "source": [
264
- "## 5. Define Reward Function\n",
265
- "\n",
266
- "The reward function uses the environment's built-in scoring.\n",
267
- "Quarterly rewards are based on HCVA/HCROI/QIPS improvement.\n",
268
- "The final episode score (0-1) is the terminal reward."
269
- ]
270
- },
271
- {
272
- "cell_type": "code",
273
- "execution_count": null,
274
- "metadata": {},
275
- "outputs": [],
276
- "source": [
277
- "def reward_func(completions, environments, **kwargs):\n",
278
- " \"\"\"Extract rewards from the HCM:21 environment after each rollout.\"\"\"\n",
279
- " rewards = []\n",
280
- " for env in environments:\n",
281
- " r = env.get_reward()\n",
282
- " rewards.append(r)\n",
283
- " return rewards\n",
284
- "\n",
285
- "print(\"Reward function defined.\")"
286
- ]
287
- },
288
- {
289
- "cell_type": "markdown",
290
- "metadata": {},
291
- "source": [
292
- "## 6. Configure and Run GRPO Training\n",
293
- "\n",
294
- "We use TRL's `GRPOTrainer` with `environment_factory=HCM21Env`.\n",
295
- "The model generates tool calls to interact with the environment,\n",
296
- "and GRPO optimizes the policy based on the environment rewards.\n",
297
- "\n",
298
- "On a free T4 GPU, we use:\n",
299
- "- Small batch size (2) with gradient accumulation\n",
300
- "- 4-bit quantization via Unsloth\n",
301
- "- Short max_completion_length to fit in memory"
302
- ]
303
- },
304
- {
305
- "cell_type": "code",
306
- "execution_count": null,
307
- "metadata": {},
308
- "outputs": [],
309
- "source": [
310
- "from trl import GRPOTrainer, GRPOConfig\n",
311
- "\n",
312
- "training_args = GRPOConfig(\n",
313
- " output_dir=\"./hcm21-grpo-output\",\n",
314
- " num_train_epochs=1,\n",
315
- " per_device_train_batch_size=2,\n",
316
- " gradient_accumulation_steps=4,\n",
317
- " num_generations=4,\n",
318
- " max_completion_length=2048,\n",
319
- " learning_rate=5e-6,\n",
320
- " logging_steps=1,\n",
321
- " save_steps=10,\n",
322
- " bf16=True,\n",
323
- " chat_template_kwargs={\"enable_thinking\": False},\n",
324
- ")\n",
325
- "\n",
326
- "trainer = GRPOTrainer(\n",
327
- " model=model,\n",
328
- " processing_class=tokenizer,\n",
329
- " train_dataset=dataset,\n",
330
- " reward_funcs=reward_func,\n",
331
- " environment_factory=HCM21Env,\n",
332
- " args=training_args,\n",
333
- ")\n",
334
- "\n",
335
- "print(\"Trainer initialized. Starting training...\")\n",
336
- "trainer.train()\n",
337
- "print(\"Training complete!\")"
338
- ]
339
- },
340
- {
341
- "cell_type": "markdown",
342
- "metadata": {},
343
- "source": [
344
- "## 7. Save & Push Model"
345
- ]
346
- },
347
- {
348
- "cell_type": "code",
349
- "execution_count": null,
350
- "metadata": {},
351
- "outputs": [],
352
- "source": [
353
- "# Save locally\n",
354
- "model.save_pretrained(\"./hcm21-agent-lora\")\n",
355
- "tokenizer.save_pretrained(\"./hcm21-agent-lora\")\n",
356
- "\n",
357
- "# Optional: push to HF Hub\n",
358
- "# model.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
359
- "# tokenizer.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
360
- "\n",
361
- "print(\"Model saved to ./hcm21-agent-lora\")"
362
- ]
363
- },
364
- {
365
- "cell_type": "markdown",
366
- "metadata": {},
367
- "source": [
368
- "## 8. Evaluate the Trained Agent\n",
369
- "\n",
370
- "Run a full 6-quarter episode with the trained model to see its score."
371
- ]
372
- },
373
- {
374
- "cell_type": "code",
375
- "execution_count": null,
376
- "metadata": {},
377
- "outputs": [],
378
- "source": [
379
- "import json\n",
380
- "\n",
381
- "FastLanguageModel.for_inference(model)\n",
382
- "\n",
383
- "env = HCM21Env()\n",
384
- "obs_text = env.reset(seed=99)\n",
385
- "print(\"Episode started.\")\n",
386
- "\n",
387
- "messages = [\n",
388
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
389
- " {\"role\": \"user\", \"content\": f\"Begin. Here is your initial observation:\\n{obs_text}\"},\n",
390
- "]\n",
391
- "\n",
392
- "step_num = 0\n",
393
- "max_steps = 200\n",
394
- "\n",
395
- "while not env._done and step_num < max_steps:\n",
396
- " # Generate action\n",
397
- " inputs = tokenizer.apply_chat_template(\n",
398
- " messages, return_tensors=\"pt\", add_generation_prompt=True\n",
399
- " ).to(model.device)\n",
400
- "\n",
401
- " outputs = model.generate(\n",
402
- " input_ids=inputs,\n",
403
- " max_new_tokens=256,\n",
404
- " temperature=0.7,\n",
405
- " do_sample=True,\n",
406
- " )\n",
407
- " response = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
408
- "\n",
409
- " # Extract JSON action from response\n",
410
- " try:\n",
411
- " start = response.index(\"{\")\n",
412
- " depth = 0\n",
413
- " for i, c in enumerate(response[start:], start):\n",
414
- " if c == \"{\": depth += 1\n",
415
- " elif c == \"}\": depth -= 1\n",
416
- " if depth == 0:\n",
417
- " action_json = response[start:i+1]\n",
418
- " break\n",
419
- " obs_text = env.step(action_json)\n",
420
- " action = json.loads(action_json)\n",
421
- " print(f\"Step {step_num} | {action.get('action_type', '?')} | Reward: {env._reward:.4f}\")\n",
422
- " except (ValueError, json.JSONDecodeError):\n",
423
- " obs_text = env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
424
- " print(f\"Step {step_num} | fallback advance_phase\")\n",
425
- "\n",
426
- " # Rolling context window\n",
427
- " messages.append({\"role\": \"assistant\", \"content\": response})\n",
428
- " messages.append({\"role\": \"user\", \"content\": obs_text})\n",
429
- " if len(messages) > 12:\n",
430
- " messages = messages[:2] + messages[-10:]\n",
431
- "\n",
432
- " step_num += 1\n",
433
- "\n",
434
- "env.close()\n",
435
- "print(f\"\\nEpisode complete after {step_num} steps.\")\n",
436
- "print(f\"Final score: {env._reward:.4f}\")"
437
- ]
438
- }
439
- ]
440
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 0,
4
+ "metadata": {
5
+ "colab": {
6
+ "provenance": [],
7
+ "gpuType": "T4"
 
 
 
 
 
 
 
 
 
8
  },
9
+ "kernelspec": {
10
+ "name": "python3",
11
+ "display_name": "Python 3"
12
+ },
13
+ "language_info": {
14
+ "name": "python"
15
+ },
16
+ "accelerator": "GPU"
17
+ },
18
+ "cells": [
19
+ {
20
+ "cell_type": "markdown",
21
+ "metadata": {},
22
+ "source": [
23
+ "# HCM:21 Training with TRL + Unsloth\n",
24
+ "\n",
25
+ "This notebook trains a small LLM to play as Chief Human Capital Officer in the\n",
26
+ "[HCM:21 environment](https://huggingface.co/spaces/ParetoOptimal/hcm21) using\n",
27
+ "**GRPO** (Group Relative Policy Optimization) from HuggingFace TRL, with\n",
28
+ "**Unsloth** for efficient fine-tuning on a free Colab T4 GPU.\n",
29
+ "\n",
30
+ "The agent interacts with the HCM:21 OpenEnv environment deployed on HF Spaces\n",
31
+ "via WebSocket, making HR decisions (hiring, training, compensation, retention)\n",
32
+ "across 6 simulated quarters and receiving sparse rewards based on Fitz-enz\n",
33
+ "HR analytics metrics.\n",
34
+ "\n",
35
+ "**Environment:** https://huggingface.co/spaces/ParetoOptimal/hcm21 \n",
36
+ "**GitHub:** https://github.com/n8mauer/HCM-21"
37
+ ]
38
+ },
39
+ {
40
+ "cell_type": "markdown",
41
+ "metadata": {},
42
+ "source": [
43
+ "## 1. Install Dependencies"
44
+ ]
45
+ },
46
+ {
47
+ "cell_type": "code",
48
+ "execution_count": null,
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": [
52
+ "%%capture\n",
53
+ "!pip install unsloth trl openenv-core pydantic datasets\n",
54
+ "# Unsloth automatically patches transformers for 2x faster training on T4"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "markdown",
59
+ "metadata": {},
60
+ "source": [
61
+ "## 2. Load Model with Unsloth"
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "code",
66
+ "execution_count": null,
67
+ "metadata": {},
68
+ "outputs": [],
69
+ "source": [
70
+ "from unsloth import FastLanguageModel\n",
71
+ "\n",
72
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
73
+ " model_name=\"unsloth/Qwen3-0.6B\",\n",
74
+ " max_seq_length=4096,\n",
75
+ " load_in_4bit=True,\n",
76
+ ")\n",
77
+ "\n",
78
+ "model = FastLanguageModel.get_peft_model(\n",
79
+ " model,\n",
80
+ " r=16,\n",
81
+ " target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n",
82
+ " \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
83
+ " lora_alpha=16,\n",
84
+ " lora_dropout=0,\n",
85
+ " use_gradient_checkpointing=\"unsloth\",\n",
86
+ ")\n",
87
+ "print(f\"Model loaded. Trainable params: {model.print_trainable_parameters()}\")"
88
+ ]
89
+ },
90
+ {
91
+ "cell_type": "markdown",
92
+ "metadata": {},
93
+ "source": [
94
+ "## 3. Define the HCM:21 Environment Wrapper\n",
95
+ "\n",
96
+ "We wrap the OpenEnv WebSocket client as a TRL `environment_factory` that\n",
97
+ "exposes `reset()`, `step()`, and `get_reward()` methods. The agent calls\n",
98
+ "`step()` as a tool, and the reward comes from quarterly HR metric improvements.\n",
99
+ "\n",
100
+ "The OpenEnv WebSocket protocol maintains stateful sessions \u00e2\u20ac\u201d each connection\n",
101
+ "gets its own Environment instance on the server."
102
+ ]
103
+ },
104
+ {
105
+ "cell_type": "code",
106
+ "execution_count": null,
107
+ "metadata": {},
108
+ "outputs": [],
109
+ "source": [
110
+ "import json\n",
111
+ "from openenv.core import GenericEnvClient\n",
112
+ "\n",
113
+ "HCM21_URL = \"https://paretooptimal-hcm21.hf.space\"\n",
114
+ "\n",
115
+ "\n",
116
+ "class HCM21Env:\n",
117
+ " \"\"\"TRL-compatible wrapper for the HCM:21 OpenEnv environment.\n",
118
+ "\n",
119
+ " Uses the OpenEnv GenericEnvClient (WebSocket) for stateful sessions.\n",
120
+ " Each instance gets its own environment on the server.\n",
121
+ " \"\"\"\n",
122
+ "\n",
123
+ " def __init__(self):\n",
124
+ " self._client = GenericEnvClient(base_url=HCM21_URL)\n",
125
+ " self._client.connect()\n",
126
+ " self._reward = 0.0\n",
127
+ " self._done = False\n",
128
+ "\n",
129
+ " def reset(self, seed: int = 42) -> str:\n",
130
+ " \"\"\"Reset the environment for a new episode.\"\"\"\n",
131
+ " result = self._client.reset(seed=seed)\n",
132
+ " obs = result.observation\n",
133
+ " self._reward = result.reward or 0.0\n",
134
+ " self._done = result.done\n",
135
+ " return self._format_obs(obs)\n",
136
+ "\n",
137
+ " def step(self, action_json: str) -> str:\n",
138
+ " \"\"\"Take an action in the environment.\n",
139
+ "\n",
140
+ " Args:\n",
141
+ " action_json: JSON string with action_type and parameters.\n",
142
+ " Example: {\\\"action_type\\\": \\\"query_department\\\", \\\"department\\\": \\\"Engineering\\\"}\n",
143
+ "\n",
144
+ " Returns:\n",
145
+ " Observation string describing the result.\n",
146
+ " \"\"\"\n",
147
+ " try:\n",
148
+ " action = json.loads(action_json)\n",
149
+ " except json.JSONDecodeError:\n",
150
+ " return \"Error: Invalid JSON. Provide a valid action JSON object.\"\n",
151
+ "\n",
152
+ " from openenv.core import GenericAction\n",
153
+ " generic_action = GenericAction(**action)\n",
154
+ " result = self._client.step(generic_action)\n",
155
+ " obs = result.observation\n",
156
+ " if result.reward is not None:\n",
157
+ " self._reward = float(result.reward)\n",
158
+ " self._done = result.done\n",
159
+ " return self._format_obs(obs)\n",
160
+ "\n",
161
+ " def get_reward(self) -> float:\n",
162
+ " \"\"\"Return the reward from the last step.\"\"\"\n",
163
+ " return self._reward\n",
164
+ "\n",
165
+ " def close(self):\n",
166
+ " \"\"\"Disconnect from the environment.\"\"\"\n",
167
+ " try:\n",
168
+ " self._client.disconnect()\n",
169
+ " except Exception:\n",
170
+ " pass\n",
171
+ "\n",
172
+ " def _format_obs(self, obs) -> str:\n",
173
+ " \"\"\"Format observation as a concise string for the LLM.\"\"\"\n",
174
+ " if isinstance(obs, dict):\n",
175
+ " data = obs\n",
176
+ " else:\n",
177
+ " data = obs if isinstance(obs, dict) else vars(obs) if hasattr(obs, '__dict__') else {\"raw\": str(obs)}\n",
178
+ " parts = []\n",
179
+ " parts.append(f\"Q{data.get('current_quarter', '?')} | {data.get('current_phase', '?')} phase\")\n",
180
+ " parts.append(str(data.get('message', '')))\n",
181
+ " warnings = data.get('warnings', [])\n",
182
+ " if warnings:\n",
183
+ " parts.append(f\"Warnings: {', '.join(str(w) for w in warnings)}\")\n",
184
+ " actions = data.get('available_actions', [])\n",
185
+ " if actions:\n",
186
+ " parts.append(f\"Available: {', '.join(str(a) for a in actions)}\")\n",
187
+ " obs_data = data.get('data')\n",
188
+ " if obs_data:\n",
189
+ " data_str = json.dumps(obs_data, indent=2, default=str)\n",
190
+ " if len(data_str) > 1500:\n",
191
+ " data_str = data_str[:1500] + \"...\"\n",
192
+ " parts.append(f\"Data: {data_str}\")\n",
193
+ " return \"\\n\".join(parts)\n",
194
+ "\n",
195
+ "\n",
196
+ "# Quick connectivity test\n",
197
+ "import requests\n",
198
+ "try:\n",
199
+ " health = requests.get(f\"{HCM21_URL}/health\", timeout=10)\n",
200
+ " print(f\"Environment health: {health.json()}\")\n",
201
+ " env_test = HCM21Env()\n",
202
+ " obs = env_test.reset(seed=42)\n",
203
+ " print(f\"\\nReset OK. Observation preview:\")\n",
204
+ " print(obs[:300])\n",
205
+ " env_test.close()\n",
206
+ "except Exception as e:\n",
207
+ " print(f\"Environment not reachable: {e}\")\n",
208
+ " print(\"If the Space is still building, wait a few minutes and re-run.\")"
209
+ ]
210
+ },
211
+ {
212
+ "cell_type": "markdown",
213
+ "metadata": {},
214
+ "source": [
215
+ "## 3a. Random Baseline\n",
216
+ "\n",
217
+ "Before training, establish the **floor** by running episodes with random actions.\n",
218
+ "A random agent scores ~0.15-0.25, showing how hard the environment is without strategy."
219
+ ]
220
+ },
221
+ {
222
+ "cell_type": "code",
223
+ "metadata": {},
224
+ "source": [
225
+ "import random\n",
226
+ "import numpy as np\n",
227
+ "\n",
228
+ "BASELINE_SEEDS = [42, 99, 123, 456, 789]\n",
229
+ "\n",
230
+ "# Valid actions by phase for a random agent\n",
231
+ "RANDOM_ACTIONS = {\n",
232
+ " \"scanning\": [\n",
233
+ " {\"action_type\": \"query_department\", \"department\": \"Engineering\"},\n",
234
+ " {\"action_type\": \"query_department\", \"department\": \"Sales\"},\n",
235
+ " {\"action_type\": \"query_department\", \"department\": \"Operations\"},\n",
236
+ " {\"action_type\": \"review_financials\"},\n",
237
+ " {\"action_type\": \"calculate_metric\", \"metric_name\": \"hcva\"},\n",
238
+ " ],\n",
239
+ " \"planning\": [\n",
240
+ " {\"action_type\": \"set_hiring_target\", \"department\": \"Engineering\", \"count\": 5},\n",
241
+ " {\"action_type\": \"set_training_budget\", \"department\": \"Sales\", \"amount\": 10000},\n",
242
+ " {\"action_type\": \"set_compensation_policy\", \"department\": \"Operations\", \"parameters\": {\"adjustment_pct\": 2.0}},\n",
243
+ " ],\n",
244
+ " \"producing\": [\n",
245
+ " {\"action_type\": \"execute_hiring\", \"department\": \"Engineering\", \"count\": 3},\n",
246
+ " {\"action_type\": \"execute_training\", \"department\": \"Sales\", \"parameters\": {\"hours\": 10}},\n",
247
+ " ],\n",
248
+ " \"controlling\": [\n",
249
+ " {\"action_type\": \"calculate_metric\", \"metric_name\": \"hcva\"},\n",
250
+ " {\"action_type\": \"submit_report\", \"rationale\": \"Random agent report\"},\n",
251
+ " ],\n",
252
+ "}\n",
253
+ "\n",
254
+ "def run_random_episode(seed):\n",
255
+ " \"\"\"Run one episode with random actions, return final score.\"\"\"\n",
256
+ " env = HCM21Env()\n",
257
+ " try:\n",
258
+ " env.reset(seed=seed)\n",
259
+ " rng = random.Random(seed)\n",
260
+ " steps = 0\n",
261
+ " while not env._done and steps < 300:\n",
262
+ " if steps % 5 == 4:\n",
263
+ " env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
264
+ " elif steps % 8 == 7:\n",
265
+ " env.step(json.dumps({\"action_type\": \"advance_quarter\"}))\n",
266
+ " else:\n",
267
+ " phase = rng.choice(list(RANDOM_ACTIONS.keys()))\n",
268
+ " action = rng.choice(RANDOM_ACTIONS[phase])\n",
269
+ " env.step(json.dumps(action))\n",
270
+ " steps += 1\n",
271
+ " return env.get_reward()\n",
272
+ " finally:\n",
273
+ " env.close()\n",
274
+ "\n",
275
+ "print(\"Running random baseline episodes...\")\n",
276
+ "random_scores = []\n",
277
+ "for seed in BASELINE_SEEDS:\n",
278
+ " score = run_random_episode(seed)\n",
279
+ " random_scores.append(score)\n",
280
+ " print(f\" Seed {seed:>3d}: {score:.4f}\")\n",
281
+ "\n",
282
+ "print(f\"\\n{'='*40}\")\n",
283
+ "print(f\"Random Baseline: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\n",
284
+ "print(f\"{'='*40}\")"
285
+ ],
286
+ "outputs": [],
287
+ "execution_count": null
288
+ },
289
+ {
290
+ "cell_type": "markdown",
291
+ "metadata": {},
292
+ "source": [
293
+ "## 4. Build Training Dataset\n",
294
+ "\n",
295
+ "For GRPO, we need a dataset of prompts that start each episode.\n",
296
+ "Each prompt describes the CHCO role and the current scenario."
297
+ ]
298
+ },
299
+ {
300
+ "cell_type": "code",
301
+ "execution_count": null,
302
+ "metadata": {},
303
+ "outputs": [],
304
+ "source": [
305
+ "from datasets import Dataset\n",
306
+ "\n",
307
+ "SYSTEM_PROMPT = \"\"\"You are the Chief Human Capital Officer (CHCO) of a simulated company.\n",
308
+ "You manage 300 employees across 5 departments over 6 quarters.\n",
309
+ "Each quarter has 4 phases: Scanning, Planning, Producing, Controlling.\n",
310
+ "Maximize HCVA, HCROI, and QIPS metrics by making strategic HR decisions.\n",
311
+ "\n",
312
+ "When calling the step tool, provide a JSON action object like:\n",
313
+ "{\"action_type\": \"query_department\", \"department\": \"Engineering\"}\n",
314
+ "\n",
315
+ "Available action types vary by phase:\n",
316
+ "- Scanning: query_department, query_employees, calculate_metric, review_financials\n",
317
+ "- Planning: set_hiring_target, set_training_budget, set_compensation_policy, set_retention_program\n",
318
+ "- Producing: execute_hiring, execute_promotion, execute_transfer, execute_training, execute_termination\n",
319
+ "- Controlling: submit_report, calculate_metric, advance_quarter\n",
320
+ "- advance_phase: move to next phase (after minimum actions taken)\n",
321
+ "\n",
322
+ "Think step by step. Start by scanning each department.\"\"\"\n",
323
+ "\n",
324
+ "# Create prompts with different seeds for diversity\n",
325
+ "prompts = []\n",
326
+ "for seed in range(20):\n",
327
+ " prompts.append({\n",
328
+ " \"prompt\": [\n",
329
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
330
+ " {\"role\": \"user\", \"content\": f\"Begin managing the company. Episode seed: {seed}. Start by scanning your organization to understand its current state.\"},\n",
331
+ " ]\n",
332
+ " })\n",
333
+ "\n",
334
+ "dataset = Dataset.from_list(prompts)\n",
335
+ "print(f\"Training dataset: {len(dataset)} episodes\")"
336
+ ]
337
+ },
338
+ {
339
+ "cell_type": "markdown",
340
+ "metadata": {},
341
+ "source": [
342
+ "## 4a. Heuristic Baseline\n",
343
+ "\n",
344
+ "Run the built-in heuristic agent (from `demo.py`) on the same seeds.\n",
345
+ "This data-driven strategy scores ~0.50-0.65, representing what a\n",
346
+ "well-designed non-LLM approach achieves. The trained model should aim\n",
347
+ "to match or exceed this."
348
+ ]
349
+ },
350
+ {
351
+ "cell_type": "code",
352
+ "metadata": {},
353
+ "source": [
354
+ "import requests\n",
355
+ "\n",
356
+ "def run_heuristic_episode(seed, size=300):\n",
357
+ " \"\"\"Run the built-in heuristic agent via the /demo API endpoint.\"\"\"\n",
358
+ " try:\n",
359
+ " resp = requests.get(\n",
360
+ " f\"{HCM21_URL}/demo\",\n",
361
+ " params={\"seed\": seed, \"size\": size},\n",
362
+ " timeout=120,\n",
363
+ " )\n",
364
+ " result = resp.json()\n",
365
+ " return result.get(\"final_score\", 0.0)\n",
366
+ " except Exception as e:\n",
367
+ " print(f\" Error on seed {seed}: {e}\")\n",
368
+ " return 0.0\n",
369
+ "\n",
370
+ "print(\"Running heuristic baseline episodes...\")\n",
371
+ "heuristic_scores = []\n",
372
+ "for seed in BASELINE_SEEDS:\n",
373
+ " score = run_heuristic_episode(seed)\n",
374
+ " heuristic_scores.append(score)\n",
375
+ " print(f\" Seed {seed:>3d}: {score:.4f}\")\n",
376
+ "\n",
377
+ "print(f\"\\n{'='*40}\")\n",
378
+ "print(f\"Heuristic Baseline: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\n",
379
+ "print(f\"{'='*40}\")\n",
380
+ "\n",
381
+ "print(f\"\\nBaseline Summary:\")\n",
382
+ "print(f\" Random: {np.mean(random_scores):.4f} (floor)\")\n",
383
+ "print(f\" Heuristic: {np.mean(heuristic_scores):.4f} (target)\")"
384
+ ],
385
+ "outputs": [],
386
+ "execution_count": null
387
+ },
388
+ {
389
+ "cell_type": "markdown",
390
+ "metadata": {},
391
+ "source": [
392
+ "## 5. Define Reward Function\n",
393
+ "\n",
394
+ "The reward function uses the environment's built-in scoring.\n",
395
+ "Quarterly rewards are based on HCVA/HCROI/QIPS improvement.\n",
396
+ "The final episode score (0-1) is the terminal reward."
397
+ ]
398
+ },
399
+ {
400
+ "cell_type": "code",
401
+ "execution_count": null,
402
+ "metadata": {},
403
+ "outputs": [],
404
+ "source": [
405
+ "def reward_func(completions, environments, **kwargs):\n",
406
+ " \"\"\"Extract rewards from the HCM:21 environment after each rollout.\"\"\"\n",
407
+ " rewards = []\n",
408
+ " for env in environments:\n",
409
+ " r = env.get_reward()\n",
410
+ " rewards.append(r)\n",
411
+ " return rewards\n",
412
+ "\n",
413
+ "print(\"Reward function defined.\")"
414
+ ]
415
+ },
416
+ {
417
+ "cell_type": "markdown",
418
+ "metadata": {},
419
+ "source": [
420
+ "## 6. Configure and Run GRPO Training\n",
421
+ "\n",
422
+ "We use TRL's `GRPOTrainer` with `environment_factory=HCM21Env`.\n",
423
+ "The model generates tool calls to interact with the environment,\n",
424
+ "and GRPO optimizes the policy based on the environment rewards.\n",
425
+ "\n",
426
+ "On a free T4 GPU, we use:\n",
427
+ "- Small batch size (2) with gradient accumulation\n",
428
+ "- 4-bit quantization via Unsloth\n",
429
+ "- Short max_completion_length to fit in memory\n",
430
+ "\n",
431
+ "After training, we plot the reward trajectory to visualize learning progress."
432
+ ]
433
+ },
434
+ {
435
+ "cell_type": "code",
436
+ "metadata": {},
437
+ "source": [
438
+ "from trl import GRPOTrainer, GRPOConfig\n",
439
+ "\n",
440
+ "training_args = GRPOConfig(\n",
441
+ " output_dir=\"./hcm21-grpo-output\",\n",
442
+ " logging_dir=\"./hcm21-grpo-logs\",\n",
443
+ " num_train_epochs=1,\n",
444
+ " per_device_train_batch_size=2,\n",
445
+ " gradient_accumulation_steps=4,\n",
446
+ " num_generations=4,\n",
447
+ " max_completion_length=2048,\n",
448
+ " learning_rate=5e-6,\n",
449
+ " logging_steps=1,\n",
450
+ " save_steps=10,\n",
451
+ " bf16=True,\n",
452
+ " chat_template_kwargs={\"enable_thinking\": False},\n",
453
+ ")\n",
454
+ "\n",
455
+ "trainer = GRPOTrainer(\n",
456
+ " model=model,\n",
457
+ " processing_class=tokenizer,\n",
458
+ " train_dataset=dataset,\n",
459
+ " reward_funcs=reward_func,\n",
460
+ " environment_factory=HCM21Env,\n",
461
+ " args=training_args,\n",
462
+ ")\n",
463
+ "\n",
464
+ "print(\"Trainer initialized. Starting training...\")\n",
465
+ "trainer.train()\n",
466
+ "print(\"Training complete!\")\n",
467
+ "\n",
468
+ "# Extract and plot reward curve from training logs\n",
469
+ "import matplotlib.pyplot as plt\n",
470
+ "\n",
471
+ "log_history = trainer.state.log_history\n",
472
+ "rewards = [entry[\"reward\"] for entry in log_history if \"reward\" in entry]\n",
473
+ "losses = [entry[\"loss\"] for entry in log_history if \"loss\" in entry]\n",
474
+ "\n",
475
+ "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
476
+ "\n",
477
+ "# Reward curve\n",
478
+ "if rewards:\n",
479
+ " axes[0].plot(rewards, alpha=0.4, label=\"Per-step reward\", color=\"steelblue\")\n",
480
+ " if len(rewards) >= 3:\n",
481
+ " window = min(5, len(rewards))\n",
482
+ " rolling = np.convolve(rewards, np.ones(window)/window, mode=\"valid\")\n",
483
+ " axes[0].plot(range(window-1, len(rewards)), rolling, linewidth=2,\n",
484
+ " label=f\"{window}-step rolling avg\", color=\"darkblue\")\n",
485
+ " axes[0].axhline(y=np.mean(random_scores), color=\"red\", linestyle=\"--\",\n",
486
+ " label=f\"Random baseline ({np.mean(random_scores):.3f})\")\n",
487
+ " axes[0].axhline(y=np.mean(heuristic_scores), color=\"green\", linestyle=\"--\",\n",
488
+ " label=f\"Heuristic baseline ({np.mean(heuristic_scores):.3f})\")\n",
489
+ " axes[0].set_xlabel(\"Training Step\")\n",
490
+ " axes[0].set_ylabel(\"Episode Reward\")\n",
491
+ " axes[0].set_title(\"GRPO Training Reward Curve\")\n",
492
+ " axes[0].legend()\n",
493
+ " axes[0].grid(True, alpha=0.3)\n",
494
+ "\n",
495
+ "# Loss curve\n",
496
+ "if losses:\n",
497
+ " axes[1].plot(losses, color=\"orange\", alpha=0.7)\n",
498
+ " axes[1].set_xlabel(\"Training Step\")\n",
499
+ " axes[1].set_ylabel(\"Loss\")\n",
500
+ " axes[1].set_title(\"Training Loss\")\n",
501
+ " axes[1].grid(True, alpha=0.3)\n",
502
+ "\n",
503
+ "plt.tight_layout()\n",
504
+ "plt.savefig(\"./hcm21-grpo-output/training_curves.png\", dpi=150, bbox_inches=\"tight\")\n",
505
+ "plt.show()\n",
506
+ "print(f\"\\nTraining stats: {len(rewards)} reward entries, mean={np.mean(rewards):.4f}\" if rewards else \"No reward data logged.\")"
507
+ ],
508
+ "outputs": [],
509
+ "execution_count": null
510
+ },
511
+ {
512
+ "cell_type": "markdown",
513
+ "metadata": {},
514
+ "source": [
515
+ "## 7. Save & Push Model"
516
+ ]
517
+ },
518
+ {
519
+ "cell_type": "code",
520
+ "execution_count": null,
521
+ "metadata": {},
522
+ "outputs": [],
523
+ "source": [
524
+ "# Save locally\n",
525
+ "model.save_pretrained(\"./hcm21-agent-lora\")\n",
526
+ "tokenizer.save_pretrained(\"./hcm21-agent-lora\")\n",
527
+ "\n",
528
+ "# Optional: push to HF Hub\n",
529
+ "# model.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
530
+ "# tokenizer.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
531
+ "\n",
532
+ "print(\"Model saved to ./hcm21-agent-lora\")"
533
+ ]
534
+ },
535
+ {
536
+ "cell_type": "markdown",
537
+ "metadata": {},
538
+ "source": [
539
+ "## 8. Evaluate the Trained Agent & Compare to Baselines\n",
540
+ "\n",
541
+ "Run the trained model on the **same seeds** as the baselines.\n",
542
+ "This produces a direct before/after comparison showing whether\n",
543
+ "GRPO training improved the agent's HR decision-making."
544
+ ]
545
+ },
546
+ {
547
+ "cell_type": "code",
548
+ "metadata": {},
549
+ "source": [
550
+ "import json\n",
551
+ "\n",
552
+ "FastLanguageModel.for_inference(model)\n",
553
+ "\n",
554
+ "def run_trained_episode(seed, max_steps=200):\n",
555
+ " \"\"\"Run one episode with the trained model, return final score.\"\"\"\n",
556
+ " env = HCM21Env()\n",
557
+ " try:\n",
558
+ " obs_text = env.reset(seed=seed)\n",
559
+ " messages = [\n",
560
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
561
+ " {\"role\": \"user\", \"content\": f\"Begin. Observation:\\n{obs_text}\"},\n",
562
+ " ]\n",
563
+ " step_num = 0\n",
564
+ " while not env._done and step_num < max_steps:\n",
565
+ " inputs = tokenizer.apply_chat_template(\n",
566
+ " messages, return_tensors=\"pt\", add_generation_prompt=True\n",
567
+ " ).to(model.device)\n",
568
+ " outputs = model.generate(\n",
569
+ " input_ids=inputs, max_new_tokens=256,\n",
570
+ " temperature=0.7, do_sample=True,\n",
571
+ " )\n",
572
+ " response = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
573
+ " try:\n",
574
+ " start = response.index(\"{\")\n",
575
+ " depth = 0\n",
576
+ " for i, c in enumerate(response[start:], start):\n",
577
+ " if c == \"{\": depth += 1\n",
578
+ " elif c == \"}\": depth -= 1\n",
579
+ " if depth == 0:\n",
580
+ " action_json = response[start:i+1]\n",
581
+ " break\n",
582
+ " obs_text = env.step(action_json)\n",
583
+ " except (ValueError, json.JSONDecodeError):\n",
584
+ " obs_text = env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
585
+ " messages.append({\"role\": \"assistant\", \"content\": response})\n",
586
+ " messages.append({\"role\": \"user\", \"content\": obs_text})\n",
587
+ " if len(messages) > 12:\n",
588
+ " messages = messages[:2] + messages[-10:]\n",
589
+ " step_num += 1\n",
590
+ " return env.get_reward(), step_num\n",
591
+ " finally:\n",
592
+ " env.close()\n",
593
+ "\n",
594
+ "print(\"Evaluating trained agent on baseline seeds...\")\n",
595
+ "trained_scores = []\n",
596
+ "for seed in BASELINE_SEEDS:\n",
597
+ " score, steps = run_trained_episode(seed)\n",
598
+ " trained_scores.append(score)\n",
599
+ " print(f\" Seed {seed:>3d}: {score:.4f} ({steps} steps)\")\n",
600
+ "\n",
601
+ "sep = \"=\" * 50\n",
602
+ "print(f\"\\n{sep}\")\n",
603
+ "print(f\" RESULTS COMPARISON\")\n",
604
+ "print(f\"{sep}\")\n",
605
+ "print(f\" Random Agent: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\n",
606
+ "print(f\" Heuristic Agent: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\n",
607
+ "print(f\" Trained Agent: {np.mean(trained_scores):.4f} +/- {np.std(trained_scores):.4f}\")\n",
608
+ "print(f\"{sep}\")\n",
609
+ "\n",
610
+ "improvement = np.mean(trained_scores) - np.mean(random_scores)\n",
611
+ "print(f\"\\n Improvement over random: +{improvement:.4f} ({improvement/max(np.mean(random_scores), 0.01)*100:.1f}%)\")\n",
612
+ "if np.mean(trained_scores) > np.mean(random_scores):\n",
613
+ " print(\" >>> Training shows positive improvement! <<<\")"
614
+ ],
615
+ "outputs": [],
616
+ "execution_count": null
617
+ },
618
+ {
619
+ "cell_type": "markdown",
620
+ "metadata": {},
621
+ "source": [
622
+ "## 9. Results Summary & Visualization\n",
623
+ "\n",
624
+ "Final comparison across all agent types with visualization."
625
+ ]
626
+ },
627
+ {
628
+ "cell_type": "code",
629
+ "metadata": {},
630
+ "source": [
631
+ "import matplotlib.pyplot as plt\n",
632
+ "import os\n",
633
+ "\n",
634
+ "# === Bar chart comparison ===\n",
635
+ "agents = [\"Random\", \"Heuristic\", \"Trained (GRPO)\"]\n",
636
+ "means = [np.mean(random_scores), np.mean(heuristic_scores), np.mean(trained_scores)]\n",
637
+ "stds = [np.std(random_scores), np.std(heuristic_scores), np.std(trained_scores)]\n",
638
+ "colors = [\"#e74c3c\", \"#f39c12\", \"#2ecc71\"]\n",
639
+ "\n",
640
+ "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
641
+ "\n",
642
+ "# Bar chart\n",
643
+ "bars = axes[0].bar(agents, means, yerr=stds, capsize=8, color=colors, edgecolor=\"black\", alpha=0.85)\n",
644
+ "axes[0].set_ylabel(\"Final Score (0-1)\")\n",
645
+ "axes[0].set_title(\"Agent Performance Comparison\")\n",
646
+ "axes[0].set_ylim(0, 1.0)\n",
647
+ "axes[0].grid(True, axis=\"y\", alpha=0.3)\n",
648
+ "for bar, mean in zip(bars, means):\n",
649
+ " axes[0].text(bar.get_x() + bar.get_width()/2., bar.get_height() + 0.02,\n",
650
+ " f\"{mean:.3f}\", ha=\"center\", va=\"bottom\", fontweight=\"bold\")\n",
651
+ "\n",
652
+ "# Per-seed breakdown\n",
653
+ "x = np.arange(len(BASELINE_SEEDS))\n",
654
+ "width = 0.25\n",
655
+ "axes[1].bar(x - width, random_scores, width, label=\"Random\", color=\"#e74c3c\", alpha=0.8)\n",
656
+ "axes[1].bar(x, heuristic_scores, width, label=\"Heuristic\", color=\"#f39c12\", alpha=0.8)\n",
657
+ "axes[1].bar(x + width, trained_scores, width, label=\"Trained\", color=\"#2ecc71\", alpha=0.8)\n",
658
+ "axes[1].set_xlabel(\"Seed\")\n",
659
+ "axes[1].set_ylabel(\"Final Score\")\n",
660
+ "axes[1].set_title(\"Per-Seed Score Breakdown\")\n",
661
+ "axes[1].set_xticks(x)\n",
662
+ "axes[1].set_xticklabels([str(s) for s in BASELINE_SEEDS])\n",
663
+ "axes[1].legend()\n",
664
+ "axes[1].grid(True, axis=\"y\", alpha=0.3)\n",
665
+ "\n",
666
+ "plt.tight_layout()\n",
667
+ "plt.savefig(\"./hcm21-grpo-output/results_comparison.png\", dpi=150, bbox_inches=\"tight\")\n",
668
+ "plt.show()\n",
669
+ "\n",
670
+ "# Save results to JSON\n",
671
+ "os.makedirs(\"./hcm21-grpo-output\", exist_ok=True)\n",
672
+ "results = {\n",
673
+ " \"baselines\": {\n",
674
+ " \"random\": {\"mean\": float(np.mean(random_scores)), \"std\": float(np.std(random_scores)),\n",
675
+ " \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, random_scores)}},\n",
676
+ " \"heuristic\": {\"mean\": float(np.mean(heuristic_scores)), \"std\": float(np.std(heuristic_scores)),\n",
677
+ " \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, heuristic_scores)}},\n",
678
+ " },\n",
679
+ " \"trained\": {\"mean\": float(np.mean(trained_scores)), \"std\": float(np.std(trained_scores)),\n",
680
+ " \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, trained_scores)}},\n",
681
+ " \"improvement_over_random\": float(np.mean(trained_scores) - np.mean(random_scores)),\n",
682
+ " \"model\": \"unsloth/Qwen3-0.6B\",\n",
683
+ " \"method\": \"GRPO\",\n",
684
+ " \"lora_rank\": 16,\n",
685
+ "}\n",
686
+ "with open(\"./hcm21-grpo-output/training_results.json\", \"w\") as f:\n",
687
+ " json.dump(results, f, indent=2)\n",
688
+ "print(\"Results saved to ./hcm21-grpo-output/training_results.json\")\n",
689
+ "print(json.dumps(results, indent=2))"
690
+ ],
691
+ "outputs": [],
692
+ "execution_count": null
693
+ }
694
+ ]
695
+ }