Spaces:
Sleeping
Sleeping
Phase 5: Composable rubric grader + dense step shaping
Browse files- graders.py +264 -1
- tests/test_graders.py +224 -0
graders.py
CHANGED
|
@@ -1,4 +1,267 @@
|
|
| 1 |
"""Composable rubric grader + dense step-level reward shaping.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|