yashash04 commited on
Commit
46b8c56
·
1 Parent(s): bc8f184

Phase 5: Composable rubric grader + dense step shaping

Browse files
Files changed (2) hide show
  1. graders.py +264 -1
  2. tests/test_graders.py +224 -0
graders.py CHANGED
@@ -1,4 +1,267 @@
1
  """Composable rubric grader + dense step-level reward shaping.
2
 
3
- Will be filled in Phase 5.
 
 
 
4
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Composable rubric grader + dense step-level reward shaping.
2
 
3
+ Reward flow per step:
4
+ compute_step_shaping(state, action, response) -> float (dense signal)
5
+ build_grader()(state) -> RewardBreakdown (terminal-ish)
6
+ Environment combines them into reward.shaped_total and reward.binary.
7
  """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable
11
+
12
+ from models import Action, EpisodeState, RewardBreakdown, ToolResponse
13
+
14
+
15
+ # ─────────────────────────────────────────────────────────────────
16
+ # Rubric base
17
+ # ─────────────────────────────────────────────────────────────────
18
+
19
+ class Rubric:
20
+ name: str = ""
21
+
22
+ def score(self, state: EpisodeState) -> tuple[str, float, dict]:
23
+ raise NotImplementedError
24
+
25
+
26
+ # ─────────────────────────────────────────────────────────────────
27
+ # Concrete rubrics
28
+ # ─────────────────────────────────────────────────────────────────
29
+
30
+ class CompletionRubric(Rubric):
31
+ name = "task_completion"
32
+
33
+ def score(self, state: EpisodeState) -> tuple[str, float, dict]:
34
+ gt = state.ground_truth_final_state
35
+ if not gt:
36
+ return (self.name, 0.0, {"reason": "no ground truth"})
37
+ satisfied = 0
38
+ for key, expected in gt.items():
39
+ if _matches(state.agent_state.get(key), expected):
40
+ satisfied += 1
41
+ total = len(gt)
42
+ return (
43
+ self.name,
44
+ satisfied / total,
45
+ {"satisfied": satisfied, "total": total},
46
+ )
47
+
48
+
49
+ class DriftDetectionRubric(Rubric):
50
+ name = "drift_detection"
51
+
52
+ def score(self, state: EpisodeState) -> tuple[str, float, dict]:
53
+ fired = [d for d in state.drift_plan if d.fires_at_step <= state.step]
54
+ if not fired:
55
+ return (self.name, 0.0, {"reason": "no drifts fired yet"})
56
+ detected = sum(1 for d in fired if d.detected_by_agent)
57
+ return (
58
+ self.name,
59
+ detected / len(fired),
60
+ {"detected": detected, "fired_total": len(fired)},
61
+ )
62
+
63
+
64
+ class AdaptationRubric(Rubric):
65
+ name = "adaptation_quality"
66
+
67
+ def score(self, state: EpisodeState) -> tuple[str, float, dict]:
68
+ fired = [d for d in state.drift_plan if d.fires_at_step <= state.step]
69
+ if not fired:
70
+ return (self.name, 0.0, {"reason": "no drifts fired yet"})
71
+
72
+ adapted = 0
73
+ opportunities = 0
74
+ for drift in fired:
75
+ first_post = None
76
+ for h in state.history:
77
+ if h.step <= drift.fires_at_step:
78
+ continue
79
+ act = h.action
80
+ if act.type == "call_tool" and act.tool_call is not None and act.tool_call.tool == drift.tool:
81
+ first_post = h
82
+ break
83
+ if act.type == "retry_with_variant" and act.retry is not None and act.retry.tool == drift.tool:
84
+ first_post = h
85
+ break
86
+ if first_post is not None:
87
+ opportunities += 1
88
+ if first_post.response is not None and first_post.response.ok:
89
+ adapted += 1
90
+
91
+ if opportunities == 0:
92
+ return (self.name, 0.0, {"reason": "no post-drift calls yet"})
93
+ return (
94
+ self.name,
95
+ adapted / opportunities,
96
+ {"adapted": adapted, "opportunities": opportunities},
97
+ )
98
+
99
+
100
+ class EfficiencyRubric(Rubric):
101
+ name = "efficiency"
102
+
103
+ def score(self, state: EpisodeState) -> tuple[str, float, dict]:
104
+ step_eff = max(0.0, 1.0 - state.step / state.max_steps)
105
+ tok_eff = state.token_budget_remaining / max(1, state.token_budget)
106
+ return (
107
+ self.name,
108
+ 0.5 * step_eff + 0.5 * tok_eff,
109
+ {"step_eff": step_eff, "tok_eff": tok_eff},
110
+ )
111
+
112
+
113
+ # ─────────────────────────────────────────────────────────────────
114
+ # Composition primitives
115
+ # ─────────────────────────────────────────────────────────────────
116
+
117
+ class WeightedSum:
118
+ def __init__(self, rubrics_with_weights: list[tuple[Rubric, float]]) -> None:
119
+ total_w = sum(w for _, w in rubrics_with_weights)
120
+ assert abs(total_w - 1.0) < 1e-6, f"Weights must sum to 1.0, got {total_w}"
121
+ self.items = rubrics_with_weights
122
+
123
+ def score(self, state: EpisodeState) -> tuple[float, dict]:
124
+ total = 0.0
125
+ breakdown: dict[str, dict] = {}
126
+ for rubric, w in self.items:
127
+ name, val, details = rubric.score(state)
128
+ total += w * val
129
+ breakdown[name] = {
130
+ "value": val,
131
+ "weight": w,
132
+ "weighted": w * val,
133
+ "details": details,
134
+ }
135
+ return total, breakdown
136
+
137
+
138
+ class Gate:
139
+ def __init__(self, name: str, predicate: Callable[[EpisodeState], bool]) -> None:
140
+ self.name = name
141
+ self.predicate = predicate
142
+
143
+ def score(self, state: EpisodeState) -> tuple[str, float]:
144
+ return (self.name, 1.0 if self.predicate(state) else 0.0)
145
+
146
+
147
+ # ─────────────────────────────────────────────────────────────────
148
+ # Dense step-level shaping (GRPO convergence fix)
149
+ # ─────────────────────────────────────────────────────────────────
150
+
151
+ def compute_step_shaping(
152
+ state: EpisodeState,
153
+ action: Action,
154
+ response: ToolResponse | None,
155
+ ) -> float:
156
+ """Dense step-level reward — fires during episode to densify GRPO signal."""
157
+ shaped = 0.0
158
+ history = state.history
159
+ prev_response = history[-1].response if history else None
160
+
161
+ # +0.10 — inspecting schema right after a failure
162
+ if action.type == "inspect_schema":
163
+ if prev_response is not None and not prev_response.ok:
164
+ shaped += 0.10
165
+
166
+ # +0.15 — correct drift report (matching tool + kind, already fired, not yet detected)
167
+ if action.type == "report_drift" and action.report is not None:
168
+ for d in state.drift_plan:
169
+ matches = (
170
+ d.tool == action.report.tool
171
+ and d.kind == action.report.drift_kind
172
+ and d.fires_at_step <= state.step
173
+ and not d.detected_by_agent
174
+ )
175
+ if matches:
176
+ shaped += 0.15
177
+ break
178
+
179
+ # +0.20 — successful retry after prior failure (recovery from drift)
180
+ if action.type == "retry_with_variant":
181
+ if (response is not None and response.ok
182
+ and prev_response is not None and not prev_response.ok):
183
+ shaped += 0.20
184
+
185
+ # -0.05 — dumb retry (same failing endpoint twice without inspecting)
186
+ if action.type == "call_tool" and prev_response is not None and not prev_response.ok:
187
+ if history:
188
+ prev_action = history[-1].action
189
+ if (prev_action.type == "call_tool"
190
+ and prev_action.tool_call is not None
191
+ and action.tool_call is not None
192
+ and prev_action.tool_call.endpoint == action.tool_call.endpoint
193
+ and prev_action.tool_call.tool == action.tool_call.tool):
194
+ shaped -= 0.05
195
+
196
+ return shaped
197
+
198
+
199
+ # ─────────────────────────────────────────────────────────────────
200
+ # Match helper + final-state gate
201
+ # ─────────────────────────────────────────────────────────────────
202
+
203
+ def _matches(actual: Any, expected: Any) -> bool:
204
+ if isinstance(expected, list):
205
+ return isinstance(actual, list) and all(e in actual for e in expected)
206
+ if isinstance(expected, bool):
207
+ return actual == expected
208
+ return actual == expected
209
+
210
+
211
+ def _final_state_acceptable(state: EpisodeState) -> bool:
212
+ """Gate: lenient mid-episode (1.0), strict at terminal (0.0 if GT unmet)."""
213
+ if state.agent_state.get("catastrophe", False):
214
+ return False
215
+ if not state.done:
216
+ return True
217
+ gt = state.ground_truth_final_state
218
+ for k, v in gt.items():
219
+ if not _matches(state.agent_state.get(k), v):
220
+ return False
221
+ return True
222
+
223
+
224
+ # ─────────────────────────────────────────────────────────────────
225
+ # Composed grader
226
+ # ─────────────────────────────────────────────────────────────────
227
+
228
+ def build_grader() -> Callable[[EpisodeState], RewardBreakdown]:
229
+ weighted = WeightedSum([
230
+ (CompletionRubric(), 0.40),
231
+ (DriftDetectionRubric(), 0.25),
232
+ (AdaptationRubric(), 0.20),
233
+ (EfficiencyRubric(), 0.15),
234
+ ])
235
+
236
+ gates = [
237
+ Gate("catastrophic_ok", lambda s: not s.agent_state.get("catastrophe", False)),
238
+ Gate("correct_final_gate", _final_state_acceptable),
239
+ ]
240
+
241
+ def grade(state: EpisodeState) -> RewardBreakdown:
242
+ weighted_score, breakdown = weighted.score(state)
243
+
244
+ gate_vals: dict[str, float] = {}
245
+ gate_product = 1.0
246
+ for g in gates:
247
+ name, v = g.score(state)
248
+ gate_vals[name] = v
249
+ gate_product *= v
250
+
251
+ shaped = weighted_score * gate_product
252
+ completion_val = breakdown["task_completion"]["value"]
253
+ binary = 1.0 if (completion_val >= 0.95 and gate_product >= 1.0) else 0.0
254
+
255
+ return RewardBreakdown(
256
+ task_completion=completion_val,
257
+ drift_detection=breakdown["drift_detection"]["value"],
258
+ adaptation_quality=breakdown["adaptation_quality"]["value"],
259
+ efficiency=breakdown["efficiency"]["value"],
260
+ catastrophic_gate=gate_vals["catastrophic_ok"],
261
+ correct_final_gate=gate_vals["correct_final_gate"],
262
+ step_shaping=0.0,
263
+ shaped_total=shaped,
264
+ binary=binary,
265
+ )
266
+
267
+ return grade
tests/test_graders.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grader acceptance tests — Phase 5 (the GRPO reward signal)."""
2
+ from __future__ import annotations
3
+
4
+ from graders import (
5
+ AdaptationRubric,
6
+ CompletionRubric,
7
+ DriftDetectionRubric,
8
+ EfficiencyRubric,
9
+ build_grader,
10
+ compute_step_shaping,
11
+ )
12
+ from models import (
13
+ Action,
14
+ DriftEvent,
15
+ DriftReportParams,
16
+ EpisodeState,
17
+ HistoryStep,
18
+ InspectParams,
19
+ RetryParams,
20
+ RewardBreakdown,
21
+ ToolCallParams,
22
+ ToolResponse,
23
+ )
24
+
25
+
26
+ def _state_with(
27
+ *,
28
+ step: int = 0,
29
+ max_steps: int = 8,
30
+ token_budget: int = 4000,
31
+ token_budget_remaining: int | None = None,
32
+ drift_plan: list[DriftEvent] | None = None,
33
+ history: list[HistoryStep] | None = None,
34
+ agent_state: dict | None = None,
35
+ ground_truth: dict | None = None,
36
+ done: bool = False,
37
+ ) -> EpisodeState:
38
+ s = EpisodeState(
39
+ episode_id="ep",
40
+ task_id="t",
41
+ difficulty="easy",
42
+ max_steps=max_steps,
43
+ token_budget=token_budget,
44
+ token_budget_remaining=(
45
+ token_budget_remaining if token_budget_remaining is not None else token_budget
46
+ ),
47
+ drift_plan=drift_plan or [],
48
+ ground_truth_final_state=ground_truth or {},
49
+ )
50
+ s.step = step
51
+ if agent_state:
52
+ s.agent_state = dict(agent_state)
53
+ if history:
54
+ s.history = list(history)
55
+ s.done = done
56
+ return s
57
+
58
+
59
+ def test_completion_rubric_full() -> None:
60
+ s = _state_with(
61
+ ground_truth={"mail.sent_count": 1, "mail.last_sent_to": "a@x.com"},
62
+ agent_state={"mail.sent_count": 1, "mail.last_sent_to": "a@x.com"},
63
+ )
64
+ name, val, details = CompletionRubric().score(s)
65
+ assert name == "task_completion"
66
+ assert val == 1.0
67
+ assert details["satisfied"] == 2
68
+ assert details["total"] == 2
69
+
70
+
71
+ def test_completion_rubric_partial() -> None:
72
+ s = _state_with(
73
+ ground_truth={"mail.sent_count": 1, "mail.last_sent_to": "a@x.com"},
74
+ agent_state={"mail.sent_count": 1},
75
+ )
76
+ _, val, details = CompletionRubric().score(s)
77
+ assert val == 0.5
78
+ assert details["satisfied"] == 1
79
+ assert details["total"] == 2
80
+
81
+
82
+ def test_completion_rubric_empty_gt() -> None:
83
+ s = _state_with(ground_truth={})
84
+ _, val, details = CompletionRubric().score(s)
85
+ assert val == 0.0
86
+ assert "reason" in details
87
+
88
+
89
+ def test_drift_detection_rubric() -> None:
90
+ d1 = DriftEvent(
91
+ tool="mail", endpoint="send_message", kind="endpoint_deprecation",
92
+ fires_at_step=3, details={}, detected_by_agent=True,
93
+ )
94
+ d2 = DriftEvent(
95
+ tool="calendar", endpoint="create_event", kind="field_rename",
96
+ fires_at_step=5, details={}, detected_by_agent=False,
97
+ )
98
+ s = _state_with(step=5, drift_plan=[d1, d2])
99
+ _, val, details = DriftDetectionRubric().score(s)
100
+ assert val == 0.5
101
+ assert details["detected"] == 1
102
+ assert details["fired_total"] == 2
103
+
104
+
105
+ def test_adaptation_rubric_success() -> None:
106
+ drift = DriftEvent(
107
+ tool="calendar", endpoint="create_event", kind="field_rename",
108
+ fires_at_step=3, details={},
109
+ )
110
+ retry_action = Action(
111
+ type="retry_with_variant",
112
+ retry=RetryParams(
113
+ tool="calendar", endpoint="create_event",
114
+ params={"title": "x", "start": "t1", "end": "t2",
115
+ "participants": [{"email": "a@x.com", "role": "required"}]},
116
+ ),
117
+ )
118
+ hist = [HistoryStep(
119
+ step=4, action=retry_action,
120
+ response=ToolResponse(ok=True, status=200, body={"event_id": "evt_1"}),
121
+ )]
122
+ s = _state_with(step=5, drift_plan=[drift], history=hist)
123
+ _, val, details = AdaptationRubric().score(s)
124
+ assert val == 1.0
125
+ assert details["adapted"] == 1
126
+ assert details["opportunities"] == 1
127
+
128
+
129
+ def test_adaptation_rubric_no_post_drift_calls() -> None:
130
+ drift = DriftEvent(
131
+ tool="calendar", endpoint="create_event", kind="field_rename",
132
+ fires_at_step=3, details={},
133
+ )
134
+ s = _state_with(step=5, drift_plan=[drift], history=[])
135
+ _, val, details = AdaptationRubric().score(s)
136
+ assert val == 0.0
137
+ assert "reason" in details
138
+
139
+
140
+ def test_efficiency_rubric_fresh() -> None:
141
+ s = _state_with(step=0, max_steps=8, token_budget=4000, token_budget_remaining=4000)
142
+ _, val, details = EfficiencyRubric().score(s)
143
+ assert val == 1.0
144
+ assert details["step_eff"] == 1.0
145
+ assert details["tok_eff"] == 1.0
146
+
147
+
148
+ def test_step_shaping_inspect_after_failure() -> None:
149
+ prev_action = Action(
150
+ type="call_tool",
151
+ tool_call=ToolCallParams(tool="mail", endpoint="send_message", params={}),
152
+ )
153
+ failed = ToolResponse(ok=False, status=400, error="bad")
154
+ hist = [HistoryStep(step=1, action=prev_action, response=failed)]
155
+ s = _state_with(step=2, history=hist)
156
+ inspect = Action(type="inspect_schema", inspect=InspectParams(tool="mail"))
157
+ assert compute_step_shaping(s, inspect, None) == 0.10
158
+
159
+
160
+ def test_step_shaping_correct_drift_report() -> None:
161
+ drift = DriftEvent(
162
+ tool="mail", endpoint="send_message", kind="endpoint_deprecation",
163
+ fires_at_step=1, details={},
164
+ )
165
+ s = _state_with(step=2, drift_plan=[drift])
166
+ report = Action(
167
+ type="report_drift",
168
+ report=DriftReportParams(
169
+ tool="mail",
170
+ drift_kind="endpoint_deprecation",
171
+ description="send_message deprecated",
172
+ ),
173
+ )
174
+ assert compute_step_shaping(s, report, None) == 0.15
175
+
176
+
177
+ def test_step_shaping_dumb_retry_penalty() -> None:
178
+ prev_action = Action(
179
+ type="call_tool",
180
+ tool_call=ToolCallParams(tool="mail", endpoint="send_message", params={}),
181
+ )
182
+ failed = ToolResponse(ok=False, status=400, error="bad")
183
+ hist = [HistoryStep(step=1, action=prev_action, response=failed)]
184
+ s = _state_with(step=2, history=hist)
185
+ same = Action(
186
+ type="call_tool",
187
+ tool_call=ToolCallParams(tool="mail", endpoint="send_message", params={}),
188
+ )
189
+ assert compute_step_shaping(s, same, None) == -0.05
190
+
191
+
192
+ def test_build_grader_full_pipeline() -> None:
193
+ drift = DriftEvent(
194
+ tool="mail", endpoint="send_message", kind="endpoint_deprecation",
195
+ fires_at_step=2, details={}, detected_by_agent=True,
196
+ )
197
+ retry_action = Action(
198
+ type="retry_with_variant",
199
+ retry=RetryParams(
200
+ tool="mail", endpoint="messages.send",
201
+ params={"to": "a@x.com", "subject": "hi", "body": "hello"},
202
+ ),
203
+ )
204
+ hist = [HistoryStep(
205
+ step=3, action=retry_action,
206
+ response=ToolResponse(ok=True, status=200, body={"message_id": "m1"}),
207
+ )]
208
+ s = _state_with(
209
+ step=3, max_steps=8, token_budget=4000, token_budget_remaining=3000,
210
+ drift_plan=[drift], history=hist,
211
+ agent_state={"mail.sent_count": 1},
212
+ ground_truth={"mail.sent_count": 1},
213
+ done=True,
214
+ )
215
+ reward = build_grader()(s)
216
+ assert isinstance(reward, RewardBreakdown)
217
+ assert reward.task_completion > 0.9
218
+ assert reward.drift_detection > 0.9
219
+ assert reward.adaptation_quality > 0.9
220
+ assert reward.catastrophic_gate == 1.0
221
+ assert reward.correct_final_gate == 1.0
222
+ assert reward.binary == 1.0
223
+ assert reward.shaped_total > 0.7
224
+ assert reward.step_shaping == 0.0