Skip to content

Commit ddcefe4

Browse files
intern_nemontron_review_ccclaude
andcommitted
【task002_m0_secondary_fixes】M0 review 次优先级 3 项修复
1. #7 Hermes 多轮:convert_hermes_conversations 不再在第一条 assistant 时 break,把所有 assistant + tool 轮收进 extra_env_info.expected_trajectory; 新增 expected_final_content / expected_turn_count。HERMES_ROLE_MAP 支持 tool / function / function_response / observation 四种 role。input_messages 仍只包含首条 assistant 前的上下文(不泄漏期望答案);expected_tool_calls / expected_assistant_content 保留首轮值,向后兼容。 2. #8 hermes hf_config:显式锁定 func_calling_singleturn config(与 general_tool_calling 环境 max_turns=2 一致);data_registry 注释解释了 为何 hermes 没有 hf_val_split(singleturn config 没有 holdout split, 继续走 fallback 警告路径)。 3. #15 aggregate 重复执行:拆出 score_rows(per-row 打分)与 aggregate_scored_rows(合并指标)两个纯函数;summarize_baselines 缓存 split 级 scored 行,aggregate 直接拼接 split 结果。对 code_execution_python 来说 subprocess fork 数量从 2× 降到 1×。 evaluate_policy 改成 score_rows + aggregate 的薄封装,签名不变。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 53758b9 commit ddcefe4

3 files changed

Lines changed: 145 additions & 39 deletions

File tree

src/nemotron/recipes/super3/milestones/m0_data_env/data_registry.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,19 @@ datasets:
6060
environment: general_tool_calling
6161
domain: general_tool_calling
6262
hf_dataset: NousResearch/hermes-function-calling-v1
63-
hf_config: null
63+
# Hermes ships multiple configs (func_calling_singleturn, func_calling,
64+
# glaive_func_calling, json_mode_singleturn, json_mode_agentic). Without an
65+
# explicit config, `load_dataset` is ambiguous on this dataset. M0 targets
66+
# the single-turn function-calling subset because the `general_tool_calling`
67+
# environment registry currently runs with max_turns=2. Multi-turn config
68+
# (`func_calling`) is reserved for M1 when the agent loop is wired up.
69+
hf_config: func_calling_singleturn
6470
hf_split: train
71+
# NousResearch/hermes-function-calling-v1 does not ship train/val splits per
72+
# config — there is only `train`. We intentionally leave hf_val_split unset
73+
# so prepare_m0_assets falls back to its sequential-slice path and records
74+
# the "not a true holdout" warning in manifest.warnings. This is the same
75+
# signal the README surfaces.
6576
hf_revision: dae3e1d28cfbcf4b915c04ea1e072030529b4bda
6677
source_url: https://huggingface.co/datasets/NousResearch/hermes-function-calling-v1
6778
license: apache-2.0

src/nemotron/recipes/super3/milestones/m0_data_env/prepare_m0_assets.py

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -262,36 +262,78 @@ def strip_tool_call_blocks(text: str) -> str:
262262
return TOOL_CALL_RE.sub("", text).strip()
263263

264264

265+
HERMES_ROLE_MAP = {
266+
"system": "system",
267+
"human": "user",
268+
"user": "user",
269+
"gpt": "assistant",
270+
"assistant": "assistant",
271+
"tool": "tool",
272+
"function": "tool",
273+
"function_response": "tool",
274+
"observation": "tool",
275+
}
276+
277+
265278
def convert_hermes_conversations(conversations: Iterable[Mapping[str, Any]]) -> tuple[list[JsonDict], JsonDict]:
266-
role_map = {
267-
"system": "system",
268-
"human": "user",
269-
"user": "user",
270-
"gpt": "assistant",
271-
"assistant": "assistant",
272-
}
279+
"""Split a Hermes function-calling conversation into model input vs expected trajectory.
280+
281+
`input_messages` covers the prompt context up to (but not including) the first
282+
assistant turn — that is what the policy sees at inference time. Everything from
283+
the first assistant turn onward (assistant tool calls, tool observations,
284+
follow-up assistant turns, final answer) is captured in `expected_trajectory`
285+
so that downstream verifiers can score multi-turn behavior, not just the first
286+
tool emission.
287+
"""
273288
input_messages: list[JsonDict] = []
289+
expected_trajectory: list[JsonDict] = []
274290
expected_tool_calls: list[JsonDict] = []
275291
expected_assistant_content = ""
292+
first_assistant_seen = False
293+
last_assistant_content = ""
294+
last_assistant_had_tool_calls = False
276295

277296
for turn in conversations:
278297
raw_role = str(turn.get("from") or turn.get("role") or "").strip()
279-
role = role_map.get(raw_role)
298+
role = HERMES_ROLE_MAP.get(raw_role)
280299
if role is None:
281300
continue
282301
content = str(turn.get("value") or turn.get("content") or "")
283302
if role == "assistant":
284-
expected_tool_calls = parse_tool_calls(content)
285-
expected_assistant_content = strip_tool_call_blocks(content)
286-
break
287-
input_messages.append({"role": role, "content": content})
303+
tool_calls = parse_tool_calls(content)
304+
stripped = strip_tool_call_blocks(content)
305+
expected_trajectory.append(
306+
{
307+
"role": "assistant",
308+
"content": stripped,
309+
"tool_calls": tool_calls,
310+
}
311+
)
312+
last_assistant_content = stripped
313+
last_assistant_had_tool_calls = bool(tool_calls)
314+
if not first_assistant_seen:
315+
expected_tool_calls = tool_calls
316+
expected_assistant_content = stripped
317+
first_assistant_seen = True
318+
elif role == "tool":
319+
expected_trajectory.append({"role": "tool", "content": content, "tool_calls": []})
320+
else: # system / user
321+
if first_assistant_seen:
322+
# Late system/user turn after the assistant has spoken — record in
323+
# the trajectory rather than leaking it into model input.
324+
expected_trajectory.append({"role": role, "content": content, "tool_calls": []})
325+
else:
326+
input_messages.append({"role": role, "content": content})
288327

289328
if not any(message["role"] == "system" for message in input_messages):
290329
input_messages.insert(0, {"role": "system", "content": SYSTEM_PROMPTS["general_tool_calling"]})
291330

292331
return input_messages, {
293332
"expected_tool_calls": expected_tool_calls,
294333
"expected_assistant_content": expected_assistant_content,
334+
"expected_trajectory": expected_trajectory,
335+
"expected_final_content": last_assistant_content if not last_assistant_had_tool_calls else "",
336+
"expected_turn_count": len(expected_trajectory),
295337
}
296338

297339

@@ -329,6 +371,9 @@ def transform_hermes_function_calling(row: Mapping[str, Any], spec: Mapping[str,
329371
extra_env_info={
330372
"expected_tool_calls": expected["expected_tool_calls"],
331373
"expected_assistant_content": expected["expected_assistant_content"],
374+
"expected_trajectory": expected["expected_trajectory"],
375+
"expected_final_content": expected["expected_final_content"],
376+
"expected_turn_count": expected["expected_turn_count"],
332377
"category": row.get("category"),
333378
"subcategory": row.get("subcategory"),
334379
"task": row.get("task"),

src/nemotron/recipes/super3/milestones/m0_data_env/run_m0_health_baseline.py

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -220,22 +220,20 @@ def candidates_for_policy(record: Mapping[str, Any], policy: str) -> list[Any]:
220220
raise ValueError(f"unknown baseline policy: {policy}")
221221

222222

223-
def evaluate_policy(
223+
def score_rows(
224224
rows: Sequence[Mapping[str, Any]],
225225
*,
226226
policy: str,
227227
best_k: int,
228228
run_code: bool,
229-
) -> JsonDict:
230-
pass_at_1 = 0
231-
best_at_k = 0
232-
total_score_at_1 = 0.0
233-
total_best_score_at_k = 0.0
234-
errors: list[JsonDict] = []
235-
skipped_rows = 0
236-
scored_rows = 0
237-
threshold = 1.0
238-
229+
) -> list[JsonDict]:
230+
"""Score each row once and return raw per-row results.
231+
232+
Pulled out of `evaluate_policy` so that aggregate metrics can be derived from
233+
the same per-row scores as the split metrics, instead of re-invoking the
234+
verifier (and, for python_unit_tests, re-spawning subprocesses).
235+
"""
236+
out: list[JsonDict] = []
239237
for index, record in enumerate(rows):
240238
candidates = candidates_for_policy(record, policy)
241239
scores: list[float] = []
@@ -246,8 +244,36 @@ def evaluate_policy(
246244
if score is None:
247245
continue
248246
scores.append(score)
247+
out.append(
248+
{
249+
"row_index": index,
250+
"scores": scores,
251+
"diagnostics": diagnostics,
252+
"metadata": record.get("metadata", {}),
253+
"verifier": record.get("reward_config", {}).get("verifier"),
254+
}
255+
)
256+
return out
257+
258+
259+
def aggregate_scored_rows(
260+
scored: Sequence[Mapping[str, Any]],
261+
*,
262+
policy: str,
263+
best_k: int,
264+
) -> JsonDict:
265+
pass_at_1 = 0
266+
best_at_k = 0
267+
total_score_at_1 = 0.0
268+
total_best_score_at_k = 0.0
269+
errors: list[JsonDict] = []
270+
skipped_rows = 0
271+
scored_rows = 0
272+
threshold = 1.0
273+
274+
for entry in scored:
275+
scores = entry["scores"]
249276
if not scores:
250-
# Every candidate for this row was skipped (e.g. code execution disabled).
251277
skipped_rows += 1
252278
continue
253279
scored_rows += 1
@@ -258,19 +284,19 @@ def evaluate_policy(
258284
total_score_at_1 += first_score
259285
total_best_score_at_k += best_score
260286
if best_score < threshold:
261-
metadata = record.get("metadata", {})
287+
metadata = entry["metadata"]
262288
errors.append(
263289
{
264-
"row_index": index,
290+
"row_index": entry["row_index"],
265291
"source_dataset": metadata.get("source_dataset"),
266292
"source_id": metadata.get("source_id"),
267293
"source_row_index": metadata.get("source_row_index"),
268-
"verifier": record.get("reward_config", {}).get("verifier"),
269-
"diagnostics": diagnostics,
294+
"verifier": entry["verifier"],
295+
"diagnostics": entry["diagnostics"],
270296
}
271297
)
272298

273-
total = len(rows)
299+
total = len(scored)
274300
denominator = scored_rows or 1
275301
return {
276302
"policy": policy,
@@ -286,6 +312,20 @@ def evaluate_policy(
286312
}
287313

288314

315+
def evaluate_policy(
316+
rows: Sequence[Mapping[str, Any]],
317+
*,
318+
policy: str,
319+
best_k: int,
320+
run_code: bool,
321+
) -> JsonDict:
322+
"""Score and aggregate in one pass. Kept for callers that don't need
323+
the per-row breakdown (existing tests, direct CLI users).
324+
"""
325+
scored = score_rows(rows, policy=policy, best_k=best_k, run_code=run_code)
326+
return aggregate_scored_rows(scored, policy=policy, best_k=best_k)
327+
328+
289329
def load_environment_specs(path: Path) -> dict[str, JsonDict]:
290330
registry = load_yaml(path)
291331
specs = {}
@@ -366,16 +406,26 @@ def summarize_baselines(
366406
) -> JsonDict:
367407
summary: JsonDict = {"best_k": best_k, "policies": list(policies), "environments": {}}
368408
for env_id, splits in rows_by_env.items():
369-
env_summary = {"splits": {}, "aggregate": {}}
370-
aggregate_rows = []
409+
env_summary: JsonDict = {"splits": {}, "aggregate": {}}
410+
# Cache per-(split, policy) scored rows so the aggregate metric reuses
411+
# the same scores instead of re-running the verifier (which, for
412+
# python_unit_tests, halves the number of subprocess forks).
413+
scored_cache: dict[tuple[str, str], list[JsonDict]] = {}
371414
for split, rows in sorted(splits.items()):
372-
aggregate_rows.extend(rows)
373-
env_summary["splits"][split] = {
374-
policy: evaluate_policy(rows, policy=policy, best_k=best_k, run_code=run_code) for policy in policies
375-
}
376-
env_summary["aggregate"] = {
377-
policy: evaluate_policy(aggregate_rows, policy=policy, best_k=best_k, run_code=run_code) for policy in policies
378-
}
415+
env_summary["splits"][split] = {}
416+
for policy in policies:
417+
scored = score_rows(rows, policy=policy, best_k=best_k, run_code=run_code)
418+
scored_cache[(split, policy)] = scored
419+
env_summary["splits"][split][policy] = aggregate_scored_rows(
420+
scored, policy=policy, best_k=best_k
421+
)
422+
for policy in policies:
423+
combined: list[JsonDict] = []
424+
for split in sorted(splits):
425+
combined.extend(scored_cache[(split, policy)])
426+
env_summary["aggregate"][policy] = aggregate_scored_rows(
427+
combined, policy=policy, best_k=best_k
428+
)
379429
summary["environments"][env_id] = env_summary
380430
return summary
381431

0 commit comments

Comments
 (0)