Skip to content

【No.9】feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics - #336

Closed
DriverBen wants to merge 3 commits into
redai-studio:mainfrom
DriverBen:feat/task9-stop-reason-metrics
Closed

DriverBen wants to merge 3 commits into
redai-studio:mainfrom
DriverBen:feat/task9-stop-reason-metrics

Conversation

@DriverBen

@DriverBen DriverBen commented Sep 18, 2026 •

Copy link
Copy Markdown

Task: 【No.9】Agentic Rollout 停止原因分布与轮数分位数指标(Relax 开源贡献者计划第二期,认领记录见 #321 评论区)

Summary

在现有 rollout 指标中补充停止原因分布(计数 + 占比)与轮数分位数(P50/P90/P95/P99),用于识别长尾轨迹和异常停止。全部逻辑为纯聚合函数,只读取输入 samples 并返回指标字典,不修改 sample、不依赖全局状态,不改变 rollout 主循环、停止条件或调度流程。

调研备注:verl / OpenRLHF / TRL / rllm / slime / AReaL / SkyRL / ROLL 八个框架中,停止原因全分布仅 rllm 有(batch/termination_reason/{reason},含 unknown 桶),轮数分位数无一家输出——本 PR 填补该空白,键名与口径对齐生态惯例(小写斜杠、统计量末段、np.percentile 默认线性插值)。

Changes

relax/utils/metrics/metric_utils.py

  • compute_num_turn_metrics(samples):读 metadata["rollout_turns"](缺失按历史默认计 1),保留 num_turn/mean|max|min(输出与原内联实现逐位一致),新增 num_turn/p50|p90|p95|p99。
  • compute_stop_reason_metrics(samples):每条 sample 恰落一桶——metadata["rollout_stop_reason"](非空字符串,strip().lower() 归一化;DeepEyes rollout 已在写入该字段)优先,回退 sample.status(5 档枚举值),再回退 unknown。输出 stop_reason/{reason}/count 与 stop_reason/{reason}/frac,分母为全部 sample,占比之和恒为 1。
  • _resolve_rollout_stop_reason(sample):上述三级归因链的私有助手。

relax/distributed/ray/rollout.py

  • compute_metrics_from_samples 中原三行内联 num_turn 统计替换为对上述两函数的合并调用;truncated_ratio 等既有指标不动。

tests/utils/test_metric_utils_stop_reason_turns.py(新增)

18 个用例:空输入返回 {}、单条样本、多原因混合(占比和=1)、显式原因优先于 status、空/非字符串原因回退、原因归一化、5 档枚举全覆盖、status 缺失归 unknown、float 轮数语义、键序确定性、线性插值分位数精确值锁定(turns 1..10 → p50=5.5/p90=9.1/p95=9.55/p99=9.91)、rollout_turns 缺失默认 1、以及 megatron 门控的接入测试(新键经 compute_metrics_from_samples 正常上报)。

Acceptance criteria mapping

  1. 单测覆盖空输入/单条/多条/缺失 metadata;空输入返回空指标不抛异常
  2. 每种停止原因输出计数与占比,占比之和为 1(分母=全部有效 sample,含 unknown)
  3. 输出 num_turn/p50|p90|p95|p99,经 compute_metrics_from_samples 正常上报
  4. 保留 num_turn/min|mean|max(提取口径与输出逐位一致);不改主循环/停止条件/调度;无新增状态字段或控制分支

Test plan

  • 本机(CPU):pytest tests/utils/test_metric_utils_stop_reason_turns.py tests/utils/test_metric_utils_rloo.py → 22 passed, 2 skipped(skipped 均为 megatron 门控用例,CI 全量环境执行)
  • 语义等价实验:2000 组随机输入下 num_turn/mean|max|min 与原内联实现逐位一致;分位数与手写线性插值公式独立复核一致
  • pre-commit(ruff check/format、docformatter、gitleaks 等)对全部改动文件通过

…antile metrics

# ⭐ Feature

## Stop-reason distribution

- Add `compute_stop_reason_metrics` pure aggregation in
  `relax/utils/metrics/metric_utils.py`: buckets each sample into exactly one
  stop reason — explicit `metadata["rollout_stop_reason"]` (normalized via
  `strip().lower()`) first, then `sample.status` (the 5-value `Sample.Status`
  enum), then an explicit `unknown` fallback — and emits
  `stop_reason/{reason}/count` plus `stop_reason/{reason}/frac`, whose fractions
  sum to 1 over the whole batch (denominator = all samples, `unknown` included).

## Turn-count tail quantiles

- Add `compute_num_turn_metrics` pure aggregation reading
  `metadata["rollout_turns"]` (missing key still counts as 1, preserving the
  previous inline semantics): keeps `num_turn/mean|max|min` output-identical
  and adds `num_turn/p50|p90|p95|p99` via `np.percentile` (default linear
  interpolation) to surface long-tail trajectories.

## Wiring

- `compute_metrics_from_samples` now merges both helpers, replacing the three
  inline `num_turn` lines; the rollout main loop, stop conditions and
  scheduling flow are unchanged and no state fields were added.

# ✅ Tests

- New `tests/utils/test_metric_utils_stop_reason_turns.py` covering: empty
  input returns `{}`, single-sample stats, mixed reasons with fractions
  summing to 1, explicit reason taking precedence over status, empty /
  non-string reason fallback, reason normalization, all 5 enum statuses
  bucketed, missing status falling into `unknown`, linear-interpolation
  percentile values pinned (p50=5.5, p90=9.1, p95=9.55, p99=9.91 for turns
  1..10), missing `rollout_turns` defaulting to 1, and a megatron-gated test
  asserting the new keys flow through `compute_metrics_from_samples`.
@DriverBen

Copy link
Copy Markdown
Author

Hi maintainers 👋 This PR is from a first-time fork contributor, and the workflow runs are awaiting approval (action_required). Could someone kindly approve the CI runs? Thanks!

(首次从 fork 提交,CI 运行需要维护者批准,麻烦有空点一下,感谢!)

@SigureMo

Copy link
Copy Markdown
Member

Closes #321 (【No.9】Agentic Rollout 停止原因分布与轮数分位数指标)

怎么还要给我们总任务给 close 了呢?

@SigureMo SigureMo changed the title feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics ‘feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics Sep 18, 2026
@SigureMo SigureMo changed the title ‘feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics 【No. 9】feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics Sep 18, 2026
@SigureMo SigureMo changed the title 【No. 9】feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics 【No.9】feat(metrics): add rollout stop-reason distribution and turn-count quantile metrics Sep 18, 2026
@DriverBen

Copy link
Copy Markdown
Author

抱歉!已修正 PR 描述:移除了 Closes #321——#321 是活动总帖,不应被自动关闭。现已改为普通引用关联(合入不会触发任何 Issue 关闭)。感谢指出!

@rai-studio-bot rai-studio-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 结论

方向与官方任务口径一致:纯聚合函数、不碰 rollout 主循环与停止条件、接入 compute_metrics_from_samples 的方式正确。我另外把改动函数从源码里抽出来跑了 3000 组随机输入做交叉验证:num_turn/mean|max|min 与原内联实现逐位一致,frac 之和恒为 1、count 之和恒等于样本数,分位数与任务示例(1..10 → 5.5 / 9.1 / 9.55 / 9.91)一致;空输入返回 {} 也成立。

但停止原因的回退链需要先修一处,另有两项与任务 issue #326 的约定不一致:

需要先修(P1)

  • _resolve_rollout_stop_reason 只读 rollout_stop_reason,读不到 agentic 路径真正写入的 stop_reason,分布会退化成状态枚举桶(见行级评论)。

建议同批处理(P2)

  • compute_num_turn_metrics 未归一化轮数,且 metadata 非 dict 时抛异常(见行级评论)。
  • 总入口对空输入仍会抛异常,与验收项 1 / #326 的要求不一致(见行级评论)。

非行级:PR 描述关联的 issue 指向错误(P3)

Closes #321 指向的是「开源贡献者计划第二期|活动流程指南」这个长期活动总 Issue,合入后会被自动关闭(@SigureMo 已在 PR 下提出同一问题);本任务自己的 issue 是 #326【No.9】Agentic Rollout 停止原因分布与轮数分位数指标。建议改为 Closes #326。

非行级:占比键名与任务 issue 约定不一致(P3)

#326 约定的键名是 stop_reason/{reason}/ratio(示例:rollout/stop_reason/unknown/ratio),本 PR 输出 .../frac。仓库里 repetition_frac 与 truncated_ratio 两种命名都有先例,但如果验收以 #326 为准,改名的成本现在最低,建议先和导师确认口径。

测试文件位置与 #326 建议的 tests/distributed/ray/test_rollout_metadata_metrics.py 不同,内容本身完整(空输入/单条/多类/缺失 metadata/归一化/枚举全覆盖/键序/线性插值精确值),这一条只作提示,不影响结论。

补充说明:megatron 门控的接入测试在本机无法执行(环境缺 ray/megatron),我只做了静态核对——compute_metrics_from_samples 触及的 _compute_zero_std_metrics、_compute_spec_metrics、_compute_prefix_cache_metrics、_compute_reward_cat_metrics、compute_mopd_metrics 对测试里的 SimpleNamespace 都走 getattr(...) 默认值,不会因缺属性失败;实际结论以 CI 为准。

Powered by Nyanpasu with deepseek-v4.1-flash-ali medium, please check the suggestions carefully.

Comment thread relax/utils/metrics/metric_utils.py Outdated
Comment on lines +181 to +186
status = getattr(sample, "status", None)
if isinstance(status, Sample.Status):
return status.value
if isinstance(status, str) and status.strip():
return status.strip().lower()
return "unknown"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 优先级:P1

回退链只读 rollout_stop_reason,漏掉了 agentic 路径真正写入的 stop_reason,这些配方下新增的分布会退化成状态枚举桶:

  1. 生产方写的是 stop_reason:examples/deepeyes_agentic/app/agent.py:69、examples/deepeyes_v2_agentic/app/agent.py:239、examples/on_policy_distillation/agentic_opd/*/app/agent.py 返回的 metadata 里都是 stop_reason;它经 relax/agentic/session/service.py:2781(_merge_export_metadata(unit_metadata, output_record["metadata"]))合并、由 relax/agentic/session/state.py:744 进入 sample.metadata。仓库里已有消费者按该键取值:examples/on_policy_distillation/agentic_opd/alfworld/reward_alfworld.py:51 用 metadata.get("stop_reason") 做 one-hot。而 rollout_stop_reason 目前只有旧的 examples/deepeyes/rollout.py:434 在写。
  2. 影响:这些 agentic 配方下只会输出 stop_reason/completed|truncated|pending/...,拿不到 env_done / max_turns / finish_length——正是用来区分「哪个预算在起作用」、把高 truncated_ratio 归因下去的那部分信息,也是本任务存在的理由。
  3. 影响面:status 兜底只在「没写原因字段」的路径上生效——旧 DeepEyes rollout 正常返回前必定调用 _record_rollout_stats(examples/deepeyes/rollout.py:579-581),所以它基本只在拿不到真实原因时触发。另外 stop_reason/truncated/frac 与已有的 rollout/truncated_ratio 语义重叠且数值可能不一致,并和 reward_alfworld.py 产出的 stop_reason/{reason} 系列共用前缀、混入两套词表。

建议第二级改读兼容字段,两级都无有效值时归 unknown(官方任务口径同样是「缺失或空值归入明确的 unknown 类别」,而非按状态推导):

    compat_reason = sample.metadata.get("stop_reason")
    if isinstance(compat_reason, str) and compat_reason.strip():
        return compat_reason.strip().lower()
    return "unknown"

test_single_sample_falls_back_to_status、test_every_status_value_gets_its_own_bucket、test_mixed_reasons_fractions_sum_to_one、test_missing_status_counts_as_unknown 目前固化了状态兜底与两套词表混用,需要一并调整。

Comment thread relax/utils/metrics/metric_utils.py Outdated
"""
if not samples:
return {}
turns = [sample.metadata.get("rollout_turns", 1) for sample in samples]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 优先级:P2

rollout_turns 未做归一化,且 metadata 非 dict 时会直接抛异常:

  • sample.metadata = None → AttributeError: 'NoneType' object has no attribute 'get'。同一路径的其他消费方都做了防御:relax/utils/opd/opd_utils.py:1490 用 (s.metadata or {}),examples/deepeyes_v2_agentic/reward_deepeyes_v2.py:774、examples/on_policy_distillation/agentic_opd/alfworld/reward_alfworld.py 也各做了 or {} / isinstance 判断。任务 issue 【No.9】Agentic Rollout 停止原因分布与轮数分位数指标 #326 明确要求「metadata 为 None 或字段缺失 → 使用已有单轮默认值 1」。
  • 非法值会原样进入统计(实测):{"rollout_turns": -3} → num_turn/min=-3;True/False 被当作 1/0(num_turn/max=True);2.5 参与均值与分位(test_float_rollout_turns_are_aggregated_as_is 固化了该行为)。【No.9】Agentic Rollout 停止原因分布与轮数分位数指标 #326 的归一化表要求:非负整数保留(0 有效)、数学上为整数的浮点转 int、None/布尔/字符串/小数/负数/NaN/±inf/容器一律回退 1。

建议抽一个私有归一化助手(metadata 缺失同时给 _resolve_rollout_stop_reason 用上):

def _normalize_rollout_turns(sample: Sample) -> int:
    metadata = sample.metadata if isinstance(sample.metadata, dict) else None
    value = metadata.get("rollout_turns") if metadata else None
    if value is None or isinstance(value, bool) or isinstance(value, (str, list, tuple, dict)):
        return 1
    if isinstance(value, (int, np.integer)):
        return int(value) if value >= 0 else 1
    if isinstance(value, (float, np.floating)) and np.isfinite(value) and value >= 0 and float(value).is_integer():
        return int(value)
    return 1

现有 test_float_rollout_turns_are_aggregated_as_is 与「非法轮数默认 1」的口径冲突,需要相应调整。

log_dict["num_turn/mean"] = np.mean([s.metadata.get("rollout_turns", 1) for s in samples]).item()
log_dict["num_turn/max"] = np.max([s.metadata.get("rollout_turns", 1) for s in samples]).item()
log_dict["num_turn/min"] = np.min([s.metadata.get("rollout_turns", 1) for s in samples]).item()
log_dict |= compute_num_turn_metrics(samples)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 优先级:P2

两个聚合函数对空输入已返回 {},但总入口在到达这两行之前就会抛异常:compute_statistics(response_lengths) 对空数组取 np.max,实测得到 ValueError: zero-size array to reduction operation maximum which has no identity(relax/utils/metrics/metric_utils.py:82-89 没有空值保护,在它前面的 _compute_min_mean_max_stats 反而有)。

这不是本 PR 引入的,但 _log_eval_rollout_data 的判断是 if (samples := data[key].get("samples")) is not None(relax/distributed/ray/rollout.py:4781),"samples": [] 会进入该分支,所以评估侧存在可触发路径;任务 issue #326 也把「总入口也需要在任何统计和参数读取前处理空输入」列进了实现范围,PR 描述里的验收项 1 同样写的是空输入不抛异常。

建议在 compute_metrics_from_samples 开头早退一行:

    if not samples:
        return {}

@rai-studio-bot

rai-studio-bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Nyanpasu 审查看板

审查状态: 🚧 需要修改

审查版本: a0259a7

目标分支: main

已按 a0259a7 复查(维护者将 main 合入特性分支触发本轮):逐位比对确认 PR 所属改动与已审的 0c5cc07 完全一致,合入仅带入 main #329 的 has_repetition 重构且与新增聚合函数无交叉;新头上 CI 复现且仅复现 F6——Unit Tests (H20, 4 GPUs) 唯一失败仍是新增用例末行的 flushed 断言(1 failed, 2953 passed, 30 skipped),其余检查全绿。F6(P1)与 F7(P3,PR 描述仍写已移除的 status 回退/strip().lower()/frac 键名并引用已删除的测试文件)维持未解决,结论与上一轮一致:修正该断言并同步 PR 描述即可,无需其他改动。本轮完成生产/测试必要性审计(见审查阶段表),无新增发现。

审查阶段进度范围与结果
常规审查 ✅ 已完成 核对 a0259a7 与已审 0c5cc07 逐位一致(仅 metric_utils.py 带入 main #329 的 has_repetition 重构,不在 PR hunks 内);检查与 #329 等主线改动的交互(无冲突,PR 的空输入守卫反而使 repetition_frac 行免受空批次告警);新头 CI 除 F6 外全绿;F7 描述仍过期;独立复算 #326 四样本示例的分位数与分布口径一致。可靠既有覆盖支持增量复查。
深度审查 ✅ 已完成 独立设计跳过(本轮为无行为变化的合入验证,纯聚合设计已在既往轮次按 #326 示例独立复算验证);必要性审计由父审直接完成并记录于下方两表,生产与测试侧均判保留、无简化项。缺口:本地无 Python≥3.10+torch 环境,未做定向变异实验,检测力依据 CI 基线与独立 oracle。

CI 异常

检查版本: a0259a7 · 已分析

失败检查原因与现状证据建议下一步
Unit Tests (H20, 4 GPUs) 仅 1 个用例失败:tests/distributed/ray/test_rollout_metadata_metrics.py::TestEntryIntegration::test_training_and_eval_loggers_publish_prefixed_metrics 末行 assert flushed == [0] 与代码实际行为不符(评估入口同样会刷新,实际值为 [0, 0]),即审查发现 F6;其余 2953 通过、30 跳过。 该 job 第 2 次尝试日志汇总为 1 failed, 2953 passed, 30 skipped;唯一 FAILED 即上述用例,pytest 输出 assert [0, 0] == [0],与 F6 线程(见发现表)的分析一致:_log_eval_rollout_data 结尾同样调用 flush_metrics,使 flushed 变为 [0, 0]。 按 F6 建议将 tests/distributed/ray/test_rollout_metadata_metrics.py:348 的断言改为 assert flushed == [0, 0],修正后重跑该 job;当前无其他失败检查。

审查发现

待处理
编号 严重性 问题状态规则来源
F6 High severity 新增测试末行断言 flushed == [0] 与代码行为不符,会让 CI 的 GPU 单测变红 🚧 未解决 任务 issue #326 测试矩阵:上报入口需断言训练/评估的刷新调用
F7 Low severity PR 描述仍描述已移除的 status 回退与 frac 键名,并引用已删除的测试文件 🚧 未解决 —
已解决或已取代
编号 严重性 问题状态规则来源
F1 High severity 停止原因回退链漏读 stop_reason,agentic 路径的分布退化成状态枚举桶 ✅ 已解决 任务 issue #326 验收要求:不从 Sample.status 推导停止原因
F2 Medium severity compute_num_turn_metrics 未归一化轮数,且 metadata 非 dict 时抛异常 ✅ 已解决 任务 issue #326 验收要求:轮数归一化表(None/布尔/负数/小数/NaN 一律回退 1)
F3 Medium severity 总入口对空输入仍会抛异常,与验收项 1 不一致 ✅ 已解决 任务 issue #326 验收要求:辅助函数和总入口对空输入返回 {}
F4 Low severity PR 描述误用 Closes #321 关联活动总 Issue,合入会误关总帖 ✅ 已解决 —
F5 Low severity 占比键名 frac 与任务 issue #326 约定的 ratio 不一致,待确认口径 ✅ 已解决 任务 issue #326 约定的键名 stop_reason/{reason}/ratio
提交范围 · 接收 4 · 建议移出 0 · 待确认 0

接收 4 个文件 · 建议移出 0 个文件 · 待确认 0 个文件。移出与待确认部分暂停深审,不代表审查通过。

文件结论仓库维护必要性依据替代去向或方案
relax/distributed/ray/rollout.py
relax/utils/metrics/metric_utils.py
接收 compute_metrics_from_samples(rollout 日志统计入口,训练/评估上报路径共用)改为调用新增的 compute_num_turn_metrics/compute_stop_reason_metrics 并补空输入守卫;两个纯聚合函数是 #326 要求交付的生产行为,合入后由仓库长期维护。 任务 issue #326(https://github.com//issues/326)验收项 2/3/4;调用点 relax/distributed/ray/rollout.py compute_metrics_from_samples。 保持原三行内联统计则不满足 #326 对纯聚合函数与新增分位数/停止原因分布的要求;外部证据无法替代需随版本演进的生产指标逻辑。
tests/distributed/ray/test_rollout_metadata_metrics.py
tests/test_agentic_rollout.py
接收 前者直接执行真实入口(compute_metrics_from_samples 与 _log_rollout_data/_log_eval_rollout_data 前缀上报),后者复用真实 SessionForest 导出路径断言 rollout_turns/stop_reason 元数据流入指标,是合入后仅存的回归保护。 #326 测试矩阵与验收项 1/2/3;被测入口 relax/distributed/ray/rollout.py、relax/agentic/session/state.py SessionForest.build_sample。 一次性验证脚本或 PR 附件证据不能在后续重构中守住指标口径;故保留为仓库内回归测试。
精简审查与验证依据
审查范围进度结论
生产代码 ✅ 已完成 生产改动均为 #326 验收要求的最小实现,审计后无可删除/合并/替换项。
测试 ✅ 已完成 三族测试边界互补、oracle 独立,无可合并/删除项;F6 为其中一处错误断言,见发现表。

生产代码的必要性与替代方案

范围必须保留的契约更简单的方案结论依据与限制
relax/utils/metrics/metric_utils.py 新增 compute_num_turn_metrics/_normalize_rollout_turns 与 compute_stop_reason_metrics/_resolve_rollout_stop_reason #326 验收:轮数按归一化表读取且 num_turn/min|mean|max 口径不变并新增 p50/p90/p95/p99;停止原因每样本恰落一桶、count+ratio 成对、分母为全部样本、不从 status 推导。调用方:relax/distributed/ray/rollout.py compute_metrics_from_samples(唯一生产调用点 rollout.py:4871-4872)。 两个聚合函数合并为一个,或把两个私有助手内联进各自唯一调用点。 保留 合并会耦合读取不同元数据字段、归一化规则不同的两族指标,入口处两行 |= 已是最小接线;助手分别隔离 #326 明确规定的轮数归一化表与三级归因链,文档化边界清晰。a0259a7 CI 全量通过(除 F6 的错误断言)。局限:本地无运行环境,未做变异实验。
relax/distributed/ray/rollout.py 的 compute_metrics_from_samples 改动(导入、空输入守卫、两行 |= 聚合调用、staleness 的 (metadata or {}) 守卫) 训练/评估上报经 _log_rollout_data/_log_eval_rollout_data 调用该入口;#326 要求新增指标经入口正常上报且既有指标不回归。 维持原三行内联统计并另处补分位数——重复归一化逻辑且不满足 #326 对纯聚合函数的要求。 保留 git grep 确认无其他生产调用点;空输入守卫与 staleness None 守卫分别对应既往已验证的 F3/F2 修复;入口集成用例在 CI 上确认既有指标(raw_reward/truncated_ratio/response_len 等)不回归。

测试的必要性与替代方案

范围必须保留的契约更简单的方案结论依据与限制
tests/distributed/ray/test_rollout_metadata_metrics.py 的纯聚合语义 / 纯度与恒等式 / 入口集成三族用例 纯函数族锁定 #326 验收语义(轮数归一化表、三级归因链、ratio 和=1、不改输入、批次间无残留);入口族锁定 compute_metrics_from_samples 接线、rollout//eval 前缀上报与既有指标不回归。 把纯函数用例并入入口集成测试(或反之)以削减用例数;合并文件内三处空输入用例。 保留 oracle 独立于实现:#326 四样本示例、参数化归一化表、数学恒等式(count 和=样本数、ratio 和=1、重排不变);入口族经 megatron 门控在 CI 真实执行(a0259a7 上 2953 passed)。两族保护不同边界(语义 vs 接线),三处空输入用例各守不同函数/入口契约且仅数行,合并收益为负。
tests/test_agentic_rollout.py 的新增断言与 test_session_forest_multi_turn_export_feeds_metadata_metrics SessionForest 导出须把 rollout_turns/stop_reason 写入 sample.metadata 供指标函数消费(F1 保护的 agentic 链路)。 删除该导出测试,仅依赖直接构造 metadata 的聚合测试。 保留 聚合测试直接构造 metadata,无法发现导出侧字段改名或丢失(届时批次整体退化为 unknown/单轮而测试仍绿);本测试走真实 append_resp/append_obs/build_sample 路径,手搭两轮森林为独立 oracle。附带记录:被扩展用例中新增的 seed_stage 断言属相邻导出行为,不构成可行动问题。
Powered by Nyanpasu with glm-5.3[1m] xhigh, please check the suggestions carefully.

@SigureMo

Copy link
Copy Markdown
Member

抱歉!已修正 PR 描述:移除了 Closes #321——#321 是活动总帖,不应被自动关闭。现已改为普通引用关联(合入不会触发任何 Issue 关闭)。感谢指出!

这种回复……建议……还是手动回复吧,连这么基本的对话都直接用 Agent 回我有点担忧对题目的理解程度

@DriverBen

Copy link
Copy Markdown
Author

抱歉!已修正 PR 描述:移除了 Closes #321——#321 是活动总帖,不应被自动关闭。现已改为普通引用关联(合入不会触发任何 Issue 关闭)。感谢指出!

这种回复……建议……还是手动回复吧,连这么基本的对话都直接用 Agent 回我有点担忧对题目的理解程度

yes sir ,不好意思

…uard with task redai-studio#326

# 🐛 Bug Fix

## Stop-reason fallback chain (review P1)

- `_resolve_rollout_stop_reason` now reads the legacy
  `metadata["rollout_stop_reason"]` first and the agentic
  `metadata["stop_reason"]` (written by DeepEyes agentic apps via the session
  service merge) second, falling back to `unknown` only when neither holds a
  valid value. Stop reasons are no longer derived from `Sample.status`.
- Valid values are non-blank strings; surrounding whitespace is stripped while
  case and inner content are preserved. An explicit `unknown` value no longer
  falls back.

## Turn normalization (review P2)

- New `_normalize_rollout_turns`: non-negative Python/NumPy integers are kept
  (0 valid), finite non-negative integral floats convert to int, and None /
  bool / string / fractional / negative / NaN / inf / container values — plus
  missing or None metadata — fall back to the single-turn default of 1.
- `compute_num_turn_metrics` computes the four quantiles from a single
  `np.percentile(turns, [50, 90, 95, 99])` call (linear interpolation).

## Entry-point empty guard (review P2)

- `compute_metrics_from_samples` returns `{}` immediately for empty input,
  before any statistics or args reads; the partial-rollout staleness read now
  tolerates `metadata=None` (missing `start_rollout_id` keeps gap 0).

## Naming per task redai-studio#326

- Ratio keys renamed `stop_reason/{reason}/frac` -> `stop_reason/{reason}/ratio`;
  counts are emitted as float integers.

# ✅ Tests

- New `tests/distributed/ray/test_rollout_metadata_metrics.py` covering the
  issue's matrix: empty input (warnings-as-errors), single sample, the
  four-sample issue example with exact counts/ratios/quantiles, the turn
  normalization table, reason compatibility (precedence, fallback, whitespace,
  case, explicit unknown), denominator independence from reward/status,
  purity/identities, entry integration (megatron-gated) including
  partial-rollout None metadata and real training/eval logger prefix checks.
- `tests/test_agentic_rollout.py`: SessionForest export now asserts
  `rollout_turns == agentic_trace.turn_count`, real `stop_reason` metadata
  passthrough and non-mutating aggregation (single- and multi-turn).
- Superseded `tests/utils/test_metric_utils_stop_reason_turns.py` removed
  (coverage consolidated per the issue).
@DriverBen
DriverBen force-pushed the feat/task9-stop-reason-metrics branch from b3d4533 to 0c5cc07 Compare September 18, 2026 13:11

@rai-studio-bot rai-studio-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 结论

已按 0c5cc07 复查(上一轮的目标版本 b3d4533 已被同内容、仅换行符不同的提交取代):上一轮的三条行级问题均已修复。我按 #326 的可核对示例与轮数归一化表独立复算确认——四样本示例的 count/ratio 为 2/1/1 与 0.5/0.25/0.25,轮数 min/mean/max 为 1/2/4,P50/P90/P95/P99 为 1.5/3.4/3.7/3.94,与约定一致;stop_reason 兼容读取与优先级、大小写保留、unknown 兜底、空输入与 metadata=None 分支、以及 ratio 键名也都已对齐。

需要先修(P1)

  • 新增测试中有一处断言与代码实际行为不符,会让 CI 的 Unit Tests (H20, 4 GPUs) 变红,详见行级评论(含建议改法)。

测试位置与覆盖面已按 #326 的建议调整(新增 tests/distributed/ray/test_rollout_metadata_metrics.py,并复用 tests/test_agentic_rollout.py 的 SessionForest 用例补齐导出断言)。

补充说明:megatron 门控用例在本机无法执行(环境缺 megatron/ray),上述结论来自静态核对与对聚合函数的独立执行验证;该 job 在 CI 上会真实执行这些门控用例,上面那条断言问题正源于此。

Powered by Nyanpasu with deepseek-v4.1-flash-ali medium, please check the suggestions carefully.

assert logged["eval/gsm8k/stop_reason/unknown/ratio"] == pytest.approx(0.5)
assert logged["eval/gsm8k/num_turn/p99"] == pytest.approx(8.94)
assert logged["eval/step"] == 0
assert flushed == [0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 优先级:P1

本行断言与代码实际行为不符,会让 CI 的 Unit Tests (H20, 4 GPUs) 变红。

_log_eval_rollout_data 结尾同样会调用 tracking_utils.flush_metrics(relax/distributed/ray/rollout.py:4801),所以第 343 行执行完之后 flushed 已经有两条记录:训练入口那次由第 339 行刚断言过(flushed == [0] 通过,说明第一次 append 确实发生),评估入口再 append 一次,此时 flushed == [0, 0],本行必然抛 AssertionError。

该用例被 pytest.importorskip("megatron.core") 门控,本地会整条跳过(本机「22 passed, 2 skipped」覆盖不到它),但 CI 的 GPU 单测跑在 ghcr.io/redai-studio/relaxrl:latest 镜像里、megatron 可导入:上一个 head 的同一 job 中,同属门控的 test_compute_metrics_from_samples_reports_stop_reason_and_turn_quantiles 是真实执行的(junit 里 time=0.002、未被 skip),所以这条断言在 CI 一定会执行。

按任务要求「训练/评估均断言刷新调用」,建议把两次刷新都断言出来:

Suggested change
assert flushed == [0]
assert flushed == [0, 0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI 已在 0c5cc07d 上跑完,这一处已被复现:Unit Tests (H20, 4 GPUs) 的唯一失败项就是本用例。

tests/distributed/ray/test_rollout_metadata_metrics.py:348: in test_training_and_eval_loggers_publish_prefixed_metrics
    assert flushed == [0]
E   assert [0, 0] == [0]
E     Left contains one more item: 0

整轮结果为 1 failed, 2749 passed, 18 skipped,其余用例(含同文件其余门控用例与 GPU 集成测试)均通过,所以当前只差这一处;按上面的 suggestion 改为断言两次刷新即可。

@rai-studio-bot rai-studio-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 结论

0c5cc07 上 CI 已跑完:唯一失败项是上一轮提出的 F6(新增测试末行断言与代码行为不符),复现输出与改法见该行级讨论;pre-commit、Python 3.10–3.12、GPU 集成测试等其余检查均通过。因此结论不变:修掉该断言即可。

非行级:PR 描述与当前实现已不一致(P3)

  • Changes 段对 compute_stop_reason_metrics 的说明仍写着 strip().lower() 归一化、回退 sample.status(5 档枚举值)、键名 frac。当前实现是去空白但保留大小写、只读 metadata["rollout_stop_reason"] 与 metadata["stop_reason"]、键名为 ratio;描述里保留的 status 回退恰好是任务 issue #326 明确排除的做法,容易被评审误读为未按验收实现。
  • Changes 与 Test plan 引用的 tests/utils/test_metric_utils_stop_reason_turns.py(18 个用例)已在本次提交中删除,实际新增的是 tests/distributed/ray/test_rollout_metadata_metrics.py;本机验证命令也应对应改为后者。

建议按当前实现改写这两处后再合入,避免评审者照描述核对验收项。

Powered by Nyanpasu with deepseek-v4.1-flash-ali medium, please check the suggestions carefully.

@SigureMo

SigureMo commented Oct 8, 2026

Copy link
Copy Markdown
Member

本赛题已经由 #330 合入并锁定,感谢参与~

@SigureMo SigureMo closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants