SavK1 Claude Sonnet 4.6 commited on
Commit
1c6aad2
·
1 Parent(s): 38457df

fix(grpo): fresh reward design — runbook-compliance scoring

Browse files

Root cause analysis
-------------------
env_score was always 0.0 for two independent reasons:

1. create_ticket(label='sec-issue', priority='P1') fails TicketingApp
validation for most org configs. The app returns ok=False, no ticket is
stored, and the grader returns 0.0 immediately (no new tickets found).

2. Dataset seeds produced triage briefs but PMOpsEnvironment.reset(seed)
independently ran rng.choice(_TASK_TYPES) — silently generating
release_notes or dep_update tasks for the same seed. The triage grader
was never called; the wrong grader always returned 0.0.

Fixes
-----
dataset.py: generate_triage_dataset now pre-simulates the env's first two
RNG calls (difficulty + task_type) and only includes seeds where the env
WILL run a triage episode, using the exact same difficulty and org params.

rewards.py: replaced all previous reward formulas with compute_rollout_reward,
a single function based on RUNBOOK COMPLIANCE:
read_runbook +0.10 — process: read runbook first
valid_label +0.20/-0.10 — label from label_taxonomy?
valid_priority +0.15/-0.10 — priority from priority_levels?
valid_team +0.20/-0.10 — team from team_map?
right_channel +0.25/-0.10 — posted to oncall channel?
env_bonus +0.10 * env_score — bonus when grader agrees

This creates genuine reward VARIANCE: the same hardcoded model template
(sec-issue, P1, infra, #infra-team) scores differently for different org
configs — correct for some seeds, wrong for others — giving GRPO the
advantage signal it needs.

rollout.py: updated rollout_once to track ticket_label, ticket_priority,
assigned_team, valid_labels/priorities/teams/channels from the runbook
response, and call compute_rollout_reward. Log now shows ✓/✗ per component.

train_v3.ipynb: updated run_grpo_episode and dataset cell to match.

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

training/dataset.py CHANGED
@@ -1,7 +1,10 @@
1
  """Generate fixed-seed training dataset for PM-Ops triage task.
2
 
3
- Each row contains a seed embedded in the prompt string so the rollout
4
- function can pass it to env.reset(seed=...) for reproducible episodes.
 
 
 
5
  """
6
  import json
7
  import os
@@ -15,6 +18,22 @@ from server.world.scenario_gen import generate_scenario
15
 
16
  SEED_PREFIX = "SEED:"
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def _valid_triage(scenario: dict) -> bool:
20
  exp = scenario.get("expected", {})
@@ -22,14 +41,28 @@ def _valid_triage(scenario: dict) -> bool:
22
 
23
 
24
  def generate_triage_dataset(n_episodes: int = 150, base_seed: int = 42) -> list[dict]:
25
- """Return list of dataset rows with embedded seeds."""
 
 
 
 
 
 
 
 
 
26
  rng = random.Random(base_seed)
27
  rows = []
28
 
29
  while len(rows) < n_episodes:
30
  seed = rng.randint(0, 2**31)
31
- difficulty = rng.choice(["easy", "medium", "medium", "hard"])
32
 
 
 
 
 
 
 
33
  org, scenario = None, None
34
  for attempt in range(10):
35
  org = generate_org_config(seed + attempt, difficulty)
@@ -41,7 +74,6 @@ def generate_triage_dataset(n_episodes: int = 150, base_seed: int = 42) -> list[
41
  continue
42
 
43
  rows.append({
44
- # Seed embedded so rollout can parse it and pass to reset()
45
  "prompt": f"{SEED_PREFIX}{seed} | {scenario['brief']}",
46
  "seed": seed,
47
  "difficulty": difficulty,
 
1
  """Generate fixed-seed training dataset for PM-Ops triage task.
2
 
3
+ Each seed is validated against the env's own RNG so the task type, difficulty,
4
+ org_config, and scenario the env generates at reset(seed=S) EXACTLY matches the
5
+ brief embedded in the prompt. Previously, the dataset generated triage briefs but
6
+ the env silently ran a different task type (release_notes, dep_update, etc.) for
7
+ the same seed — causing env_score=0 for every episode.
8
  """
9
  import json
10
  import os
 
18
 
19
  SEED_PREFIX = "SEED:"
20
 
21
+ # Must match pm_ops_environment.py constants exactly
22
+ _ENV_DIFFICULTY_POOL = ["easy", "medium", "medium", "hard"]
23
+ _ENV_TASK_TYPES = ["triage", "incident_routing", "release_notes", "dep_update"]
24
+
25
+
26
+ def _env_params(seed: int) -> tuple[str, str]:
27
+ """Predict difficulty + task_type the env will choose for this seed.
28
+
29
+ Replicates the first two RNG calls in PMOpsEnvironment.reset() so we can
30
+ filter seeds to those that produce the task type we want.
31
+ """
32
+ rng = random.Random(seed)
33
+ difficulty = rng.choice(_ENV_DIFFICULTY_POOL)
34
+ task_type = rng.choice(_ENV_TASK_TYPES)
35
+ return difficulty, task_type
36
+
37
 
38
  def _valid_triage(scenario: dict) -> bool:
39
  exp = scenario.get("expected", {})
 
41
 
42
 
43
  def generate_triage_dataset(n_episodes: int = 150, base_seed: int = 42) -> list[dict]:
44
+ """Return dataset rows where the env WILL run a triage episode for the embedded seed.
45
+
46
+ We pre-simulate the env's RNG to only include seeds where:
47
+ 1. env.reset(seed) picks task_type="triage"
48
+ 2. The resulting org + scenario are valid (have expected channel + team)
49
+ 3. The difficulty, org_config, and brief exactly match what the env will use
50
+
51
+ This eliminates the mismatch where the dataset had triage briefs but the env
52
+ graded as release_notes → guaranteed env_score=0.
53
+ """
54
  rng = random.Random(base_seed)
55
  rows = []
56
 
57
  while len(rows) < n_episodes:
58
  seed = rng.randint(0, 2**31)
 
59
 
60
+ # Only use seeds the env will run as triage
61
+ difficulty, task_type = _env_params(seed)
62
+ if task_type != "triage":
63
+ continue
64
+
65
+ # Generate org + scenario using the SAME difficulty + seed the env will use
66
  org, scenario = None, None
67
  for attempt in range(10):
68
  org = generate_org_config(seed + attempt, difficulty)
 
74
  continue
75
 
76
  rows.append({
 
77
  "prompt": f"{SEED_PREFIX}{seed} | {scenario['brief']}",
78
  "seed": seed,
79
  "difficulty": difficulty,
training/rewards.py CHANGED
@@ -1,149 +1,100 @@
1
- """Reward functions for PM-Ops GRPO training.
2
-
3
- Weights are applied inside each function so GRPOTrainer sums them to a
4
- total reward. All weights sum to 1.0:
5
-
6
- final_score 0.45 — correctness: label + priority + team + channel
7
- no_wrong_channels 0.15 — anti-hack: penalise channel-spray behaviour
8
- valid_json 0.15 — format discipline: fraction of valid JSON outputs
9
- read_runbook 0.15 — process: did agent read runbook before acting?
10
- efficiency 0.10 — speed: steps saved when task completed correctly
11
-
12
- Reward hacking protection (point 8 of hackathon guide):
13
- The main PM-Ops hack is posting to every channel to guarantee hitting the
14
- right one. reward_no_wrong_channels makes this explicit and gradient-visible
15
- rather than buried inside reward_final_score.
16
-
17
- To change weights, edit the WEIGHT_* constants below.
18
- Equal-weight Option B: set all five to 0.20.
19
-
20
- Architecture note — two reward modes:
21
- 1. Split mode (ALL_REWARD_FUNCS): five separate functions, each reading one
22
- kwargs key from the rollout dict. TRL sums them. Requires TRL to pass
23
- rollout extra keys as kwargs to reward_funcs (works in TRL 0.17–0.24 +
24
- Unsloth patch).
25
- 2. Combined mode (COMBINED_REWARD_FUNC): single function that reads all five
26
- kwargs keys and returns the weighted sum. Use this if split mode shows
27
- all-zero rewards (indicates TRL is not passing kwargs, e.g. version
28
- mismatch). Switch by replacing reward_funcs=ALL_REWARD_FUNCS with
29
- reward_funcs=COMBINED_REWARD_FUNC in GRPOTrainer.
 
30
  """
31
 
32
- import warnings
33
-
34
- WEIGHT_FINAL_SCORE = 0.45
35
- WEIGHT_NO_WRONG_CHANNELS = 0.15
36
- WEIGHT_VALID_JSON = 0.15
37
- WEIGHT_READ_RUNBOOK = 0.15
38
- WEIGHT_EFFICIENCY = 0.10
39
-
40
- assert abs(
41
- WEIGHT_FINAL_SCORE + WEIGHT_NO_WRONG_CHANNELS + WEIGHT_VALID_JSON
42
- + WEIGHT_READ_RUNBOOK + WEIGHT_EFFICIENCY - 1.0
43
- ) < 1e-9, "Reward weights must sum to 1.0"
44
-
45
- _EXPECTED_KEYS = frozenset([
46
- "final_score_reward", "no_wrong_channels_reward",
47
- "valid_json_reward", "read_runbook_reward", "efficiency_reward",
48
- ])
49
- _warned_empty = False
50
-
51
-
52
- def _extract(kwargs: dict, key: str, n: int) -> list[float]:
53
- global _warned_empty
54
- rewards = kwargs.get(key, [])
55
- if not rewards:
56
- if not _warned_empty:
57
- present = set(kwargs.keys()) & _EXPECTED_KEYS
58
- missing = _EXPECTED_KEYS - set(kwargs.keys())
59
- warnings.warn(
60
- f"[rewards] kwargs missing rollout reward keys — all rewards will be 0!\n"
61
- f" present: {present or 'none'}\n"
62
- f" missing: {missing}\n"
63
- f" all kwargs keys: {list(kwargs.keys())}\n"
64
- f" CAUSE: TRL version mismatch or rollout_func not returning these keys.\n"
65
- f" FIX: switch to reward_funcs=COMBINED_REWARD_FUNC (see rewards.py).",
66
- stacklevel=3,
67
- )
68
- _warned_empty = True
69
- return [0.0] * n
70
- return [float(r) for r in rewards]
71
-
72
-
73
- def reward_final_score(completions, **kwargs) -> list[float]:
74
- """Primary correctness signal from the env grader (0–1). Weight: 0.45"""
75
- raw = _extract(kwargs, "final_score_reward", len(completions))
76
- return [r * WEIGHT_FINAL_SCORE for r in raw]
77
-
78
-
79
- def reward_no_wrong_channels(completions, **kwargs) -> list[float]:
80
- """Anti-hack: penalise posting to non-oncall channels. Weight: 0.15
81
-
82
- Graduated penalty: 1.0 for zero wrong posts, -0.5 per wrong post.
83
- Computed in rollout from oncall_channels extracted from the runbook.
84
- Explicitly discourages the channel-spray failure mode.
85
- """
86
- raw = _extract(kwargs, "no_wrong_channels_reward", len(completions))
87
- return [r * WEIGHT_NO_WRONG_CHANNELS for r in raw]
88
-
89
-
90
- def reward_valid_json(completions, **kwargs) -> list[float]:
91
- """Fraction of steps with parseable JSON output (0–1). Weight: 0.15
92
-
93
- Anti-staleness: penalises the 'output garbage every step' failure mode.
94
- """
95
- raw = _extract(kwargs, "valid_json_reward", len(completions))
96
- return [r * WEIGHT_VALID_JSON for r in raw]
97
-
98
-
99
- def reward_read_runbook(completions, **kwargs) -> list[float]:
100
- """Binary: did agent call meta.read_runbook before acting? Weight: 0.15
101
-
102
- Teaches the agent to learn org conventions first instead of guessing.
103
- """
104
- raw = _extract(kwargs, "read_runbook_reward", len(completions))
105
- return [r * WEIGHT_READ_RUNBOOK for r in raw]
106
-
107
-
108
- def reward_efficiency(completions, **kwargs) -> list[float]:
109
- """Steps saved when task completed correctly (0–1). Weight: 0.10
110
-
111
- Only non-zero when final_score >= 0.3 — does not reward fast failure.
112
- """
113
- raw = _extract(kwargs, "efficiency_reward", len(completions))
114
- return [r * WEIGHT_EFFICIENCY for r in raw]
115
-
116
-
117
- def combined_reward(completions, **kwargs) -> list[float]:
118
- """Single combined reward — fallback when TRL does not pass split kwargs.
119
 
120
- Reads all five rollout reward keys and returns their weighted sum.
121
- Use with: reward_funcs=[combined_reward]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  """
123
- n = len(completions)
124
- final = _extract(kwargs, "final_score_reward", n)
125
- no_wrong = _extract(kwargs, "no_wrong_channels_reward", n)
126
- vj = _extract(kwargs, "valid_json_reward", n)
127
- rb = _extract(kwargs, "read_runbook_reward", n)
128
- eff = _extract(kwargs, "efficiency_reward", n)
129
- return [
130
- f * WEIGHT_FINAL_SCORE
131
- + nw * WEIGHT_NO_WRONG_CHANNELS
132
- + v * WEIGHT_VALID_JSON
133
- + r * WEIGHT_READ_RUNBOOK
134
- + e * WEIGHT_EFFICIENCY
135
- for f, nw, v, r, e in zip(final, no_wrong, vj, rb, eff)
136
- ]
137
-
138
-
139
- # Split mode: TRL sums five separate signals (preferred — gives per-signal trackio curves)
140
- ALL_REWARD_FUNCS = [
141
- reward_final_score,
142
- reward_no_wrong_channels,
143
- reward_valid_json,
144
- reward_read_runbook,
145
- reward_efficiency,
146
- ]
147
-
148
- # Combined mode: single function use when kwargs aren't flowing in split mode
149
- COMBINED_REWARD_FUNC = [combined_reward]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runbook-compliance reward for PM-Ops GRPO training.
2
+
3
+ Design philosophy (v4)
4
+ ----------------------
5
+ Previous reward formulas (json_ratio, ok_ratio, diversity_bonus) were all constant
6
+ once the model learned to output valid JSON — giving zero GRPO advantage.
7
+
8
+ Root problem: env_score was always 0 because:
9
+ 1. The model used hardcoded label='sec-issue' / priority='P1', which fail
10
+ TicketingApp validation for most org configs no ticket created → grader
11
+ returns 0.0 immediately.
12
+ 2. Dataset seeds produced triage briefs but the env ran a DIFFERENT task type
13
+ (release_notes, dep_update) for the same seed guaranteed mismatch.
14
+
15
+ Fix: reward is computed from the agent's ACTIONS compared to RUNBOOK DATA.
16
+ The org_config (returned by meta.read_runbook) varies by seed — different orgs
17
+ have different valid labels, priorities, teams, and oncall channels. The model's
18
+ fixed template (sec-issue, P1, infra) scores well for some orgs and badly for
19
+ others, creating the reward VARIANCE that GRPO needs.
20
+
21
+ Components (sum = 1.0 when all correct):
22
+ read_runbook 0.10 — process: did agent read runbook first?
23
+ valid_label 0.20 — used a label from label_taxonomy? (+0.20 / -0.10)
24
+ valid_priority 0.15 — used a priority from priority_levels? (+0.15 / -0.10)
25
+ valid_team 0.20 — assigned to a team from team_map? (+0.20 / -0.10)
26
+ right_channel 0.25 — posted to an oncall channel? (+0.25 / -0.10 per wrong)
27
+ env_bonus 0.10 — env grader bonus when everything lines up correctly
28
+
29
+ This is computed IN the rollout (not by TRL reward_funcs) because it needs
30
+ access to the per-episode runbook data.
31
  """
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ def compute_rollout_reward(
35
+ *,
36
+ read_runbook_done: bool,
37
+ valid_labels: set, # from org_config.label_taxonomy.values()
38
+ valid_priorities: set, # from org_config.priority_levels
39
+ valid_teams: set, # from org_config.team_map.values()
40
+ oncall_channels: set, # from org_config.oncall_channels.values()
41
+ ticket_label: str | None, # label used in create_ticket (None if no ticket)
42
+ ticket_priority: str | None, # priority used in create_ticket
43
+ assigned_team: str | None, # team from assign_ticket (None if not called)
44
+ posted_channels: list[str], # all channels from chat.post_message
45
+ env_score: float, # final env grader score (0–1)
46
+ valid_json_count: int, # steps with parseable JSON output
47
+ ) -> float:
48
+ """Compute a single reward scalar for one episode.
49
+
50
+ Returns a value in [-1.0, 1.0].
51
  """
52
+ if valid_json_count == 0:
53
+ return -1.0
54
+
55
+ reward = 0.0
56
+
57
+ # 1. Runbook read (+0.10 process bonus)
58
+ if read_runbook_done:
59
+ reward += 0.10
60
+
61
+ # 2. Ticket label valid (only scored if a ticket was created)
62
+ if ticket_label is not None:
63
+ if valid_labels:
64
+ if ticket_label in valid_labels:
65
+ reward += 0.20
66
+ else:
67
+ reward -= 0.10 # wrong label — validation would have rejected it
68
+
69
+ # 3. Ticket priority valid
70
+ if ticket_priority is not None:
71
+ if valid_priorities:
72
+ if ticket_priority in valid_priorities:
73
+ reward += 0.15
74
+ else:
75
+ reward -= 0.10
76
+
77
+ # 4. Ticket assigned to a valid team
78
+ if assigned_team is not None:
79
+ if valid_teams:
80
+ if assigned_team in valid_teams:
81
+ reward += 0.20
82
+ else:
83
+ reward -= 0.10
84
+
85
+ # 5. Posted to the right oncall channel
86
+ if posted_channels:
87
+ if oncall_channels:
88
+ correct_posts = [ch for ch in posted_channels if ch in oncall_channels]
89
+ wrong_posts = [ch for ch in posted_channels if ch not in oncall_channels]
90
+ if correct_posts:
91
+ reward += 0.25
92
+ reward -= 0.10 * len(wrong_posts) # -0.10 per channel-spray post
93
+ else:
94
+ # Posted without reading runbook — can't verify, mild penalty
95
+ reward -= 0.05 * len(posted_channels)
96
+
97
+ # 6. Env grader bonus — full env score adds on top when everything is correct
98
+ reward += env_score * 0.10
99
+
100
+ return max(-1.0, min(1.0, reward))
training/rollout.py CHANGED
@@ -262,17 +262,22 @@ def rollout_once(
262
  # Each entry: {"obs_text": str, "completion": str, "is_runbook": bool}
263
  turn_history: list[dict] = []
264
 
265
- # Reward accumulators
266
  valid_action_count = 0
267
- read_runbook_done = False
268
  final_score = 0.0
269
  step = 0
270
  done = False
271
- ok_count = 0 # number of steps where env accepted the action (last_action_result.ok)
272
 
273
- # Channel tracking for reward_no_wrong_channels
274
- oncall_channels: set[str] = set() # populated after reading runbook
275
- posted_channels: list[str] = [] # every channel the agent posts to
 
 
 
 
 
 
 
276
 
277
  while not done and step < max_steps:
278
  # obs_text for THIS step — stored in history BEFORE stepping
@@ -309,10 +314,16 @@ def rollout_once(
309
  action_type: str = parsed.get("action_type", "meta.noop")
310
  args: dict = parsed.get("args", {})
311
 
312
- if action_type == "meta.read_runbook" and is_valid_json and not read_runbook_done:
313
  read_runbook_done = True
314
 
315
- # Track chat.post_message targets for anti-hack reward
 
 
 
 
 
 
316
  if action_type == "chat.post_message" and is_valid_json:
317
  ch = args.get("channel", "")
318
  if ch:
@@ -331,86 +342,49 @@ def rollout_once(
331
  new_obs = result.observation if hasattr(result, "observation") else result
332
  obs_dict = _obs_to_dict(new_obs)
333
 
334
- # Track per-step env acceptance for intermediate reward signal
335
  last_result = obs_dict.get("last_action_result") or {}
336
- if last_result.get("ok"):
337
- ok_count += 1
338
-
339
- # Extract oncall channels from runbook response (available one step later)
340
- if action_type == "meta.read_runbook":
341
- if last_result.get("ok"):
342
- data = last_result.get("data") or {}
343
- if isinstance(data, dict):
344
- org = data.get("org_config") or {}
345
- oncall_channels = set(org.get("oncall_channels", {}).values())
346
 
347
  done = bool(getattr(result, "done", obs_dict.get("done", False)))
348
  final_score = float(getattr(result, "reward", obs_dict.get("reward", 0.0)))
349
  step += 1
350
 
351
- # --- Auxiliary reward signals ---
352
-
353
- valid_json_ratio = valid_action_count / max(step, 1)
354
- efficiency = max(0.0, 1.0 - step / max_steps) if final_score >= 0.3 else 0.0
355
-
356
- # no_wrong_channels: 1.0 baseline, -0.5 per wrong post
357
- # Only meaningful if we know the oncall channels (runbook was read)
358
- if oncall_channels:
359
- wrong_posts = sum(1 for ch in posted_channels if ch not in oncall_channels)
360
- no_wrong_channels = max(0.0, 1.0 - 0.5 * wrong_posts)
361
- elif posted_channels:
362
- # Posted without reading runbook — can't verify, treat all as wrong
363
- no_wrong_channels = 0.0
364
- else:
365
- # No posts at all — no spray happened (reward_final_score handles missing notif)
366
- no_wrong_channels = 1.0
367
-
368
- read_runbook_reward = 1.0 if read_runbook_done else 0.0
369
-
370
- # ok_ratio: fraction of steps where env accepted the action — provides per-step
371
- # intermediate signal that varies across rollouts even when final_score=0.
372
- ok_ratio = ok_count / max(step, 1)
373
-
374
- # --- Reward gating ---
375
- # If model never output valid JSON, it never actually tried anything.
376
- # Strip all process rewards and apply a harsh penalty.
377
- if valid_action_count == 0:
378
- combined = -1.0
379
- elif final_score == 0.0:
380
- # Model tried (valid JSON) but task failed.
381
- # ok_ratio gives gradient signal that distinguishes "wrong but env-valid" actions
382
- # from "env-rejected" ones — this is the variance source GRPO needs when
383
- # final_score is always 0 (model not yet solving tasks).
384
- combined = (
385
- ok_ratio * 0.20
386
- + read_runbook_reward * 0.10
387
- - 0.30 # hard penalty for zero task completion
388
- )
389
- else:
390
- combined = (
391
- final_score * 0.40
392
- + ok_ratio * 0.10
393
- + no_wrong_channels * 0.15
394
- + valid_json_ratio * 0.10
395
- + read_runbook_reward * 0.15
396
- + efficiency * 0.10
397
- )
398
 
399
  print(
400
- f"[rollout] steps={step} final={final_score:.3f} ok={ok_ratio:.2f} "
401
- f"json={valid_json_ratio:.2f} runbook={read_runbook_reward:.0f} "
402
- f"no_wrong={no_wrong_channels:.2f} eff={efficiency:.2f} "
403
- f"valid_acts={valid_action_count} combined={combined:.3f}"
 
 
404
  )
405
  return {
406
- "prompt_ids": prompt_ids,
407
  "completion_ids": completion_ids,
408
- "logprobs": logprobs,
409
- "final_score_reward": final_score,
410
- "no_wrong_channels_reward": no_wrong_channels,
411
- "valid_json_reward": valid_json_ratio,
412
- "read_runbook_reward": read_runbook_reward,
413
- "efficiency_reward": efficiency,
414
  }
415
 
416
 
@@ -429,14 +403,10 @@ def make_rollout_func(sync_env, tokenizer, max_steps: int = 15):
429
  """
430
  def rollout_func(prompts: list[str], trainer=None) -> dict:
431
  out: dict[str, list] = {
432
- "prompt_ids": [],
433
  "completion_ids": [],
434
- "logprobs": [],
435
- "final_score_reward": [],
436
- "no_wrong_channels_reward": [],
437
- "valid_json_reward": [],
438
- "read_runbook_reward": [],
439
- "efficiency_reward": [],
440
  }
441
  # Track how many times each unique prompt has appeared so we can pass
442
  # a gen_offset — ensures repeated prompts (num_generations > 1) hit
 
262
  # Each entry: {"obs_text": str, "completion": str, "is_runbook": bool}
263
  turn_history: list[dict] = []
264
 
265
+ # Rollout accumulators
266
  valid_action_count = 0
 
267
  final_score = 0.0
268
  step = 0
269
  done = False
 
270
 
271
+ # Runbook-compliance reward tracking
272
+ read_runbook_done = False
273
+ valid_labels: set[str] = set() # org label_taxonomy values
274
+ valid_priorities: set[str] = set() # org priority_levels
275
+ valid_teams: set[str] = set() # org team_map values
276
+ oncall_channels: set[str] = set() # org oncall_channels values
277
+ ticket_label: str | None = None # label used in create_ticket
278
+ ticket_priority: str | None = None # priority used in create_ticket
279
+ assigned_team: str | None = None # team from assign_ticket
280
+ posted_channels: list[str] = [] # every channel posted to
281
 
282
  while not done and step < max_steps:
283
  # obs_text for THIS step — stored in history BEFORE stepping
 
314
  action_type: str = parsed.get("action_type", "meta.noop")
315
  args: dict = parsed.get("args", {})
316
 
317
+ if action_type == "meta.read_runbook" and is_valid_json:
318
  read_runbook_done = True
319
 
320
+ if action_type == "ticketing.create_ticket" and is_valid_json and ticket_label is None:
321
+ ticket_label = args.get("label")
322
+ ticket_priority = args.get("priority")
323
+
324
+ if action_type == "ticketing.assign_ticket" and is_valid_json and assigned_team is None:
325
+ assigned_team = args.get("team")
326
+
327
  if action_type == "chat.post_message" and is_valid_json:
328
  ch = args.get("channel", "")
329
  if ch:
 
342
  new_obs = result.observation if hasattr(result, "observation") else result
343
  obs_dict = _obs_to_dict(new_obs)
344
 
345
+ # Extract full org_config from runbook response (one step after the call)
346
  last_result = obs_dict.get("last_action_result") or {}
347
+ if action_type == "meta.read_runbook" and last_result.get("ok"):
348
+ data = last_result.get("data") or {}
349
+ if isinstance(data, dict):
350
+ org = data.get("org_config") or {}
351
+ valid_labels = set(org.get("label_taxonomy", {}).values())
352
+ valid_priorities = set(org.get("priority_levels", []))
353
+ valid_teams = set(org.get("team_map", {}).values())
354
+ oncall_channels = set(org.get("oncall_channels", {}).values())
 
 
355
 
356
  done = bool(getattr(result, "done", obs_dict.get("done", False)))
357
  final_score = float(getattr(result, "reward", obs_dict.get("reward", 0.0)))
358
  step += 1
359
 
360
+ from training.rewards import compute_rollout_reward
361
+ combined = compute_rollout_reward(
362
+ read_runbook_done = read_runbook_done,
363
+ valid_labels = valid_labels,
364
+ valid_priorities = valid_priorities,
365
+ valid_teams = valid_teams,
366
+ oncall_channels = oncall_channels,
367
+ ticket_label = ticket_label,
368
+ ticket_priority = ticket_priority,
369
+ assigned_team = assigned_team,
370
+ posted_channels = posted_channels,
371
+ env_score = final_score,
372
+ valid_json_count = valid_action_count,
373
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
  print(
376
+ f"[rollout] steps={step} env={final_score:.3f} "
377
+ f"label={'✓' if ticket_label and ticket_label in valid_labels else '✗' if ticket_label else '-'} "
378
+ f"priority={'✓' if ticket_priority and ticket_priority in valid_priorities else '✗' if ticket_priority else '-'} "
379
+ f"team={'✓' if assigned_team and assigned_team in valid_teams else '✗' if assigned_team else '-'} "
380
+ f"channel={'✓' if any(ch in oncall_channels for ch in posted_channels) else '✗' if posted_channels else '-'} "
381
+ f"→ reward={combined:.3f}"
382
  )
383
  return {
384
+ "prompt_ids": prompt_ids,
385
  "completion_ids": completion_ids,
386
+ "logprobs": logprobs,
387
+ "reward": combined,
 
 
 
 
388
  }
389
 
390
 
 
403
  """
404
  def rollout_func(prompts: list[str], trainer=None) -> dict:
405
  out: dict[str, list] = {
406
+ "prompt_ids": [],
407
  "completion_ids": [],
408
+ "logprobs": [],
409
+ "reward": [],
 
 
 
 
410
  }
411
  # Track how many times each unique prompt has appeared so we can pass
412
  # a gen_offset — ensures repeated prompts (num_generations > 1) hit
training/train_v3.ipynb CHANGED
@@ -489,14 +489,7 @@
489
  "id": "cell-13",
490
  "metadata": {},
491
  "outputs": [],
492
- "source": [
493
- "from training.dataset import generate_triage_dataset\n",
494
- "\n",
495
- "rows = generate_triage_dataset(n_episodes=N_GRPO_EPISODES, base_seed=42)\n",
496
- "grpo_dataset = Dataset.from_list([{'prompt': r['prompt']} for r in rows])\n",
497
- "print(f'GRPO dataset: {len(grpo_dataset)} triage episodes')\n",
498
- "print(f'Difficulties: {set(r[\"difficulty\"] for r in rows)}')"
499
- ]
500
  },
501
  {
502
  "cell_type": "markdown",
@@ -512,7 +505,7 @@
512
  "id": "cell-14",
513
  "metadata": {},
514
  "outputs": [],
515
- "source": "from training.rollout import (\n _obs_to_dict, _current_obs_text, build_messages,\n extract_json_action, step_aware_fallback,\n _generate_no_vllm,\n)\nfrom training.dataset import parse_seed_from_prompt\n\ngrpo_env = GenericEnvClient(base_url=ENV_URL).sync()\ngrpo_env.connect()\nprint('GRPO training env connected')\n\n# Must be > 1.0 so rollouts of the same prompt diverge and GRPO gets non-zero advantage.\nROLLOUT_TEMPERATURE = 1.1\n\n\ndef run_grpo_episode(trainer, env, tok, dataset_prompt, max_steps=TRAIN_MAX_STEPS,\n gen_offset=0):\n \"\"\"Run one PM-ops episode. gen_offset shifts the env seed so each GRPO\n generation for the same prompt explores a different env episode.\n \"\"\"\n seed = parse_seed_from_prompt(dataset_prompt)\n if seed is not None:\n result = env.reset(seed=seed + gen_offset)\n else:\n result = env.reset()\n\n obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n task_brief = obs_dict.get('task_brief') or dataset_prompt\n\n prompt_ids, completion_ids, logprobs = [], [], []\n turn_history = []\n valid_json_count = 0\n ok_count = 0 # steps where env accepted the action (last_action_result.ok)\n read_runbook_done = False\n env_score = 0.0\n step, done = 0, False\n _sample_logged = False\n\n while not done and step < max_steps:\n obs_text = _current_obs_text(obs_dict, step, task_brief)\n msgs = build_messages(turn_history, obs_text)\n prompt_text = tok.apply_chat_template(\n msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n )\n\n # Always use _generate_no_vllm at ROLLOUT_TEMPERATURE.\n # generate_rollout_completions defaults to greedy/low-temp, which collapses\n # all rollouts for the same prompt to identical outputs → zero GRPO advantage.\n rollout_out = _generate_no_vllm(\n trainer, prompt_text, tok,\n max_new_tokens=MAX_COMP_LEN,\n temperature=ROLLOUT_TEMPERATURE,\n )\n prompt_ids.extend(rollout_out['prompt_ids'])\n completion_ids.extend(rollout_out['completion_ids'])\n logprobs.extend(rollout_out['logprobs'])\n completion_text = rollout_out['text']\n\n if not _sample_logged:\n print(f' [sample] {repr(completion_text[:180])}')\n _sample_logged = True\n\n parsed = extract_json_action(completion_text)\n if parsed is not None:\n valid_json_count += 1\n else:\n parsed = step_aware_fallback(step, max_steps)\n\n action_type = parsed.get('action_type', 'meta.noop')\n args = parsed.get('args', {})\n\n if action_type == 'meta.read_runbook':\n read_runbook_done = True\n\n turn_history.append({\n 'obs_text' : obs_text,\n 'completion': completion_text,\n 'is_runbook': (action_type == 'meta.read_runbook' and parsed is not None),\n })\n\n try:\n result = env.step({'action_type': action_type, 'args': args})\n except RuntimeError as exc:\n if 'VALIDATION_ERROR' in str(exc):\n result = env.step({'action_type': 'meta.noop', 'args': {}})\n else:\n raise\n\n obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n\n # Per-step intermediate signal: did env accept this action?\n last_res = obs_dict.get('last_action_result') or {}\n if last_res.get('ok'):\n ok_count += 1\n\n done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n env_score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n step += 1\n\n json_ratio = valid_json_count / max(step, 1)\n ok_ratio = ok_count / max(step, 1)\n rb_reward = 1.0 if read_runbook_done else 0.0\n\n # Reward formula designed to produce VARIANCE across rollouts:\n # - ok_ratio varies per episode (different actions accepted/rejected by env)\n # - env_score varies when the model solves the task with different quality\n # Using json_ratio alone (always ~1.0 after SFT) gives a constant reward → zero gradient.\n if valid_json_count == 0:\n reward = -1.0\n elif env_score == 0.0:\n # Task not completed ok_ratio differentiates rollouts that took env-valid\n # actions from ones that didn't, giving GRPO a gradient even before task completion.\n reward = ok_ratio * 0.20 + rb_reward * 0.10 - 0.30\n else:\n reward = env_score * 0.60 + ok_ratio * 0.10 + json_ratio * 0.10 + rb_reward * 0.20\n\n reward = max(-1.0, min(1.0, reward))\n print(f' [rollout] steps={step} env={env_score:.3f} ok={ok_ratio:.2f} '\n f'json={json_ratio:.2f} rb={rb_reward:.0f} offset={gen_offset} -> reward={reward:.3f}')\n return {\n 'prompt_ids' : prompt_ids,\n 'completion_ids': completion_ids,\n 'logprobs' : logprobs,\n 'reward' : reward,\n }\n\n\ndef grpo_rollout_func(prompts, trainer=None):\n out = {'prompt_ids': [], 'completion_ids': [], 'logprobs': [], 'reward': []}\n # Count how many times each prompt has appeared so far in this batch.\n # TRL sends [p, p, ...] (same prompt num_gen times) → gen_offset breaks symmetry.\n prompt_seen: dict = {}\n for prompt in prompts:\n gen_offset = prompt_seen.get(prompt, 0)\n prompt_seen[prompt] = gen_offset + 1\n ep = run_grpo_episode(trainer, grpo_env, tokenizer, prompt,\n TRAIN_MAX_STEPS, gen_offset=gen_offset)\n for k in out:\n out[k].append(ep[k])\n return out\n\n\n_warned_missing_reward = False\ndef grpo_reward_func(completions, **kwargs):\n \"\"\"Passthrough — reward is pre-computed in grpo_rollout_func.\"\"\"\n global _warned_missing_reward\n rewards = kwargs.get('reward', [])\n if not rewards:\n if not _warned_missing_reward:\n print(f\"[WARN] reward_func fallback with no reward key. kwargs keys: {list(kwargs.keys())}\")\n _warned_missing_reward = True\n return [0.0] * len(completions)\n return [float(r) for r in rewards]\n\n\nprint(f'GRPO rollout ready max_steps={TRAIN_MAX_STEPS} temperature={ROLLOUT_TEMPERATURE}')"
516
  },
517
  {
518
  "cell_type": "markdown",
 
489
  "id": "cell-13",
490
  "metadata": {},
491
  "outputs": [],
492
+ "source": "from training.dataset import generate_triage_dataset\n\n# generate_triage_dataset now pre-simulates the env's RNG to only include seeds\n# where env.reset(seed) will actually run a TRIAGE episode — previously the env\n# silently ran release_notes/dep_update for the same seed, guaranteeing env_score=0.\nrows = generate_triage_dataset(n_episodes=N_GRPO_EPISODES, base_seed=42)\ngrpo_dataset = Dataset.from_list([{'prompt': r['prompt']} for r in rows])\nprint(f'GRPO dataset: {len(grpo_dataset)} triage episodes')\nprint(f'Difficulties: {set(r[\"difficulty\"] for r in rows)}')\nprint(f'Sample prompt: {rows[0][\"prompt\"][:120]}')"
 
 
 
 
 
 
 
493
  },
494
  {
495
  "cell_type": "markdown",
 
505
  "id": "cell-14",
506
  "metadata": {},
507
  "outputs": [],
508
+ "source": "from training.rollout import (\n _obs_to_dict, _current_obs_text, build_messages,\n extract_json_action, step_aware_fallback,\n _generate_no_vllm,\n)\nfrom training.dataset import parse_seed_from_prompt\nfrom training.rewards import compute_rollout_reward\n\ngrpo_env = GenericEnvClient(base_url=ENV_URL).sync()\ngrpo_env.connect()\nprint('GRPO training env connected')\n\nROLLOUT_TEMPERATURE = 1.1 # must be > 1.0 for rollout diversity\n\n\ndef run_grpo_episode(trainer, env, tok, dataset_prompt, max_steps=TRAIN_MAX_STEPS,\n gen_offset=0):\n \"\"\"Run one PM-ops triage episode and return trajectory + reward.\n\n Reward is runbook-compliance based (see training/rewards.py):\n - Did the model read the runbook?\n - Did it use a valid label/priority/team from the runbook?\n - Did it notify a correct oncall channel?\n\n This creates genuine reward variance across rollouts because org configs\n vary by seed — the same hardcoded label/team/channel is correct for some\n orgs and wrong for others, giving GRPO a real gradient signal.\n \"\"\"\n seed = parse_seed_from_prompt(dataset_prompt)\n if seed is not None:\n result = env.reset(seed=seed + gen_offset)\n else:\n result = env.reset()\n\n obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n task_brief = obs_dict.get('task_brief') or dataset_prompt\n\n prompt_ids, completion_ids, logprobs = [], [], []\n turn_history = []\n valid_json_count = 0\n env_score = 0.0\n step, done = 0, False\n _sample_logged = False\n\n # Runbook-compliance tracking\n read_runbook_done = False\n valid_labels: set = set()\n valid_priorities: set = set()\n valid_teams: set = set()\n oncall_channels: set = set()\n ticket_label: str | None = None\n ticket_priority: str | None = None\n assigned_team: str | None = None\n posted_channels: list = []\n\n while not done and step < max_steps:\n obs_text = _current_obs_text(obs_dict, step, task_brief)\n msgs = build_messages(turn_history, obs_text)\n prompt_text = tok.apply_chat_template(\n msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n )\n\n rollout_out = _generate_no_vllm(\n trainer, prompt_text, tok,\n max_new_tokens=MAX_COMP_LEN,\n temperature=ROLLOUT_TEMPERATURE,\n )\n prompt_ids.extend(rollout_out['prompt_ids'])\n completion_ids.extend(rollout_out['completion_ids'])\n logprobs.extend(rollout_out['logprobs'])\n completion_text = rollout_out['text']\n\n if not _sample_logged:\n print(f' [sample] {repr(completion_text[:180])}')\n _sample_logged = True\n\n parsed = extract_json_action(completion_text)\n if parsed is not None:\n valid_json_count += 1\n else:\n parsed = step_aware_fallback(step, max_steps)\n\n action_type = parsed.get('action_type', 'meta.noop')\n args = parsed.get('args', {})\n\n if action_type == 'meta.read_runbook' and parsed is not None:\n read_runbook_done = True\n\n if action_type == 'ticketing.create_ticket' and parsed is not None and ticket_label is None:\n ticket_label = args.get('label')\n ticket_priority = args.get('priority')\n\n if action_type == 'ticketing.assign_ticket' and parsed is not None and assigned_team is None:\n assigned_team = args.get('team')\n\n if action_type == 'chat.post_message' and parsed is not None:\n ch = args.get('channel', '')\n if ch:\n posted_channels.append(ch)\n\n turn_history.append({\n 'obs_text' : obs_text,\n 'completion': completion_text,\n 'is_runbook': (action_type == 'meta.read_runbook' and parsed is not None),\n })\n\n try:\n result = env.step({'action_type': action_type, 'args': args})\n except RuntimeError as exc:\n if 'VALIDATION_ERROR' in str(exc):\n result = env.step({'action_type': 'meta.noop', 'args': {}})\n else:\n raise\n\n obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n\n # Extract org_config from runbook response (available the step AFTER read_runbook)\n last_res = obs_dict.get('last_action_result') or {}\n if action_type == 'meta.read_runbook' and last_res.get('ok'):\n data = last_res.get('data') or {}\n if isinstance(data, dict):\n org = data.get('org_config') or {}\n valid_labels = set(org.get('label_taxonomy', {}).values())\n valid_priorities = set(org.get('priority_levels', []))\n valid_teams = set(org.get('team_map', {}).values())\n oncall_channels = set(org.get('oncall_channels', {}).values())\n\n done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n env_score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n step += 1\n\n reward = compute_rollout_reward(\n read_runbook_done = read_runbook_done,\n valid_labels = valid_labels,\n valid_priorities = valid_priorities,\n valid_teams = valid_teams,\n oncall_channels = oncall_channels,\n ticket_label = ticket_label,\n ticket_priority = ticket_priority,\n assigned_team = assigned_team,\n posted_channels = posted_channels,\n env_score = env_score,\n valid_json_count = valid_json_count,\n )\n\n lbl_ok = '✓' if ticket_label and ticket_label in valid_labels else ('✗' if ticket_label else '-')\n pri_ok = '✓' if ticket_priority and ticket_priority in valid_priorities else ('✗' if ticket_priority else '-')\n tm_ok = '✓' if assigned_team and assigned_team in valid_teams else ('✗' if assigned_team else '-')\n ch_ok = '✓' if any(ch in oncall_channels for ch in posted_channels) else ('✗' if posted_channels else '-')\n print(f' [rollout] steps={step} env={env_score:.3f} '\n f'label={lbl_ok} pri={pri_ok} team={tm_ok} ch={ch_ok} '\n f'offset={gen_offset} -> reward={reward:.3f}')\n\n return {\n 'prompt_ids' : prompt_ids,\n 'completion_ids': completion_ids,\n 'logprobs' : logprobs,\n 'reward' : reward,\n }\n\n\ndef grpo_rollout_func(prompts, trainer=None):\n out = {'prompt_ids': [], 'completion_ids': [], 'logprobs': [], 'reward': []}\n prompt_seen: dict = {}\n for prompt in prompts:\n gen_offset = prompt_seen.get(prompt, 0)\n prompt_seen[prompt] = gen_offset + 1\n ep = run_grpo_episode(trainer, grpo_env, tokenizer, prompt,\n TRAIN_MAX_STEPS, gen_offset=gen_offset)\n for k in out:\n out[k].append(ep[k])\n return out\n\n\n_warned_missing_reward = False\ndef grpo_reward_func(completions, **kwargs):\n \"\"\"Passthrough — reward is pre-computed in grpo_rollout_func.\"\"\"\n global _warned_missing_reward\n rewards = kwargs.get('reward', [])\n if not rewards:\n if not _warned_missing_reward:\n print(f\"[WARN] reward_func fallback, no reward key. kwargs: {list(kwargs.keys())}\")\n _warned_missing_reward = True\n return [0.0] * len(completions)\n return [float(r) for r in rewards]\n\n\nprint(f'GRPO rollout ready max_steps={TRAIN_MAX_STEPS} temperature={ROLLOUT_TEMPERATURE}')"
509
  },
510
  {
511
  "cell_type": "markdown",