File size: 7,599 Bytes
504e7f9 | 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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 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 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | """
Genesis-2.0 RLHF — Reward Functions Module
Rule-based reward functions for scoring tool-use trajectories.
Used by both the DPO preference pair generator and the GRPO trainer.
"""
import json
import re
from typing import Any, Optional
def extract_tool_calls(text: str) -> list[dict[str, Any]]:
"""Extract <tool_call> JSON blocks from assistant messages."""
tool_calls = []
pattern = re.compile(r'<tool_call>\s*(\{.*?\})\s*</tool_call>', re.DOTALL)
for match in pattern.finditer(text):
try:
tc = json.loads(match.group(1))
if isinstance(tc, dict) and "name" in tc:
tool_calls.append(tc)
except json.JSONDecodeError:
continue
return tool_calls
def extract_assistant_messages(text: str) -> list[str]:
"""Split a conversation into individual assistant response blocks."""
parts = text.split("<|im_start|>assistant\n")
if len(parts) <= 1:
return []
messages = []
for p in parts[1:]:
end_idx = p.find("<|im_end|>")
if end_idx >= 0:
messages.append(p[:end_idx].strip())
return messages
def reward_tool_call_validity(trajectory: str) -> float:
"""
+1.0 if all <tool_call> blocks contain valid JSON with valid tool names.
0.0 if ANY tool call has invalid JSON or missing 'name' field.
"""
tool_calls = extract_tool_calls(trajectory)
if not tool_calls:
return 0.0
for tc in tool_calls:
if not isinstance(tc.get("name"), str) or not tc["name"].strip():
return 0.0
if "arguments" not in tc:
return 0.5 # partial: has name but no args
return 1.0
def reward_task_completion(trajectory: str, expected_keywords: Optional[list[str]] = None) -> float:
"""
+1.0 if the final assistant message provides a substantive answer.
0.0 if the trajectory ends without a meaningful response.
-1.0 if it ends with an error or hallucination.
"""
msgs = extract_assistant_messages(trajectory)
if not msgs:
return -1.0
last = msgs[-1]
# If the last message has tool calls but no text answer, task isn't complete
text_after_tools = re.sub(r'<tool_call>.*?</tool_call>', '', last, flags=re.DOTALL).strip()
text_after_tools = re.sub(r'<tool_result>.*?</tool_result>', '', text_after_tools, flags=re.DOTALL).strip()
tool_calls = extract_tool_calls(last)
if not text_after_tools and tool_calls:
return 0.0 # only tool calls, no answer
if text_after_tools and len(text_after_tools) > 20:
# Has substantive text — likely gave an answer
if expected_keywords:
hits = sum(1 for kw in expected_keywords if kw.lower() in text_after_tools.lower())
return min(1.0, hits / max(len(expected_keywords), 1) * 1.5)
return 1.0
if text_after_tools and len(text_after_tools) > 5:
return 0.5 # short answer, partial completion
return 0.0
def reward_tool_result_usage(trajectory: str) -> float:
"""
+1.0 if tool calls are followed by reasoning that references them.
0.0 if tool calls are made but no subsequent reasoning.
"""
segments = trajectory.split("<|im_start|>assistant\n")
tool_call_segments = 0
reasoning_after_call = 0
for seg in segments[1:]:
has_tool_call = bool(re.search(r'<tool_call>', seg))
if has_tool_call:
tool_call_segments += 1
# Remove tool_call blocks and check remaining text
text_after = re.sub(r'<tool_call>.*?</tool_call>', '', seg, flags=re.DOTALL)
text_after = re.sub(r'<think>.*?</think>', '', text_after, flags=re.DOTALL)
text_after = text_after.strip()
if len(text_after) > 50:
reasoning_after_call += 1
if tool_call_segments == 0:
return 0.5 # no tools used — neutral
if reasoning_after_call >= tool_call_segments * 0.5:
return 1.0
if reasoning_after_call > 0:
return 0.5
return 0.0
def reward_trajectory_efficiency(trajectory: str) -> float:
"""
Score based on how efficiently tools are used.
Ideal: 1-3 tool calls for a typical query.
Penalty for excessive tool calls (>5) or zero tool calls when tools would help.
"""
tool_calls = extract_tool_calls(trajectory)
n = len(tool_calls)
if n == 0:
return 0.3 # no tools used — might be fine for simple queries
elif n <= 3:
return 1.0 # efficient
elif n <= 5:
return 0.7 # acceptable
elif n <= 8:
return 0.4 # somewhat excessive
else:
return 0.0 # too many tool calls
def reward_format_validity(trajectory: str) -> float:
"""
+1.0 if tool calls use the correct <tool_call> JSON format.
0.0 if there are malformed tool calls.
"""
# Check for malformed tool_call markers
malformed = re.findall(r'<tool_call>[^<]*?$', trajectory)
if malformed:
return 0.0
# Check for unclosed tool_call tags
open_tags = len(re.findall(r'<tool_call>', trajectory))
close_tags = len(re.findall(r'</tool_call>', trajectory))
if open_tags != close_tags:
return 0.0
# All parsed tool calls should have valid JSON
tool_calls = extract_tool_calls(trajectory)
expected = len(re.findall(r'<tool_call>', trajectory))
if expected > 0 and len(tool_calls) < expected:
return 0.5 # some valid, some not
return 1.0
def reward_recovery_from_error(trajectory: str) -> float:
"""
+1.0 if the model retries after a failed tool call (multi-turn recovery).
Detects consecutive assistant segments making tool calls.
0.0 otherwise.
"""
segments = trajectory.split("<|im_start|>assistant\n")[1:]
consecutive_tool_calls = 0
for i, seg in enumerate(segments):
has_tc = bool(re.search(r'<tool_call>', seg))
if has_tc and i > 0:
prev_has_tc = bool(re.search(r'<tool_call>', segments[i-1]))
if prev_has_tc:
consecutive_tool_calls += 1
if consecutive_tool_calls >= 1:
return 1.0 # did a multi-step tool sequence
return 0.0
def combined_reward(trajectory: str, expected_keywords: Optional[list[str]] = None) -> float:
"""
Weighted combination of all reward signals.
Used by both DPO pair generation and GRPO training.
"""
weights = {
"validity": 0.25,
"completion": 0.40,
"usage": 0.15,
"efficiency": 0.10,
"format": 0.05,
"recovery": 0.05,
}
scores = {
"validity": reward_tool_call_validity(trajectory),
"completion": reward_task_completion(trajectory, expected_keywords),
"usage": reward_tool_result_usage(trajectory),
"efficiency": reward_trajectory_efficiency(trajectory),
"format": reward_format_validity(trajectory),
"recovery": reward_recovery_from_error(trajectory),
}
total = sum(scores[k] * weights[k] for k in weights)
return max(-1.0, min(1.0, total)) # clamp to [-1, 1]
def reward_debug(trajectory: str) -> dict:
"""Return breakdown of all reward components for debugging."""
return {
"validity": reward_tool_call_validity(trajectory),
"completion": reward_task_completion(trajectory),
"usage": reward_tool_result_usage(trajectory),
"efficiency": reward_trajectory_efficiency(trajectory),
"format": reward_format_validity(trajectory),
"recovery": reward_recovery_from_error(trajectory),
"combined": combined_reward(trajectory),
}
|