Spaces:
Sleeping
fix(grpo): fresh reward design — runbook-compliance scoring
Browse filesRoot 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 +37 -5
- training/rewards.py +96 -145
- training/rollout.py +54 -84
- training/train_v3.ipynb +2 -9
|
@@ -1,7 +1,10 @@
|
|
| 1 |
"""Generate fixed-seed training dataset for PM-Ops triage task.
|
| 2 |
|
| 3 |
-
Each
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -1,149 +1,100 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 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 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
"""
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
#
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -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 |
-
#
|
| 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 |
-
#
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 313 |
read_runbook_done = True
|
| 314 |
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 335 |
last_result = obs_dict.get("last_action_result") or {}
|
| 336 |
-
if last_result.get("ok"):
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 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 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 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}
|
| 401 |
-
f"
|
| 402 |
-
f"
|
| 403 |
-
f"
|
|
|
|
|
|
|
| 404 |
)
|
| 405 |
return {
|
| 406 |
-
"prompt_ids":
|
| 407 |
"completion_ids": completion_ids,
|
| 408 |
-
"logprobs":
|
| 409 |
-
"
|
| 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 |
-
"
|
| 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
|
|
@@ -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\
|
| 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",
|