diff --git a/examples/codex_home_example/AGENTS.md b/examples/codex_home_example/AGENTS.md
index 234e20f..483372d 100644
--- a/examples/codex_home_example/AGENTS.md
+++ b/examples/codex_home_example/AGENTS.md
@@ -75,7 +75,8 @@
- 用户要求查看 Judger 过程或评测明细时,优先读取 `judger.pkl` 事件流
- `train` 优先使用 Trainer Skill
- SFT 必须使用 `train_stage=sft, train_framework=llamafactory`;GRPO 必须使用 `train_stage=grpo, train_framework=verl`,不要交叉组合
-- Verl GRPO 默认使用 Conda 环境 `verl`,输入必须是包含 `prompt`、`data_source`、`reward_model` 的训练/验证 Parquet;优先使用 `auto` 或经过用户确认的 LoopAI Reward 预设,只有预设无法覆盖任务时才使用自定义 reward Python 文件
+- Verl GRPO 默认使用 Conda 环境 `verl`;原生输入使用包含 `prompt`、`data_source`、`reward_model` 的 Parquet,Constructor 生成的 JSON/JSONL 则交给 Trainer 在 `prepare()` 内转换、切分并记录 manifest,不要要求 Constructor 输出 Verl 专用格式;优先使用 `auto` 或经过用户确认的 LoopAI Reward 预设,无法可靠识别时必须请用户指定,不得猜测 reward 或 ground truth
+- Verl 多轮默认继承上一轮已确认 YAML 的训练超参和成功导出的最佳 Hugging Face 模型,但每轮必须刷新当前数据、输出目录和 version,并重新展示完整 YAML 取得确认
- `obtain`、训练前数据获取、SFT 数据集构造、能力定向提升数据规划,优先读取 Obtainer Skill:`skills/obtainer/SKILL.md`
- 涉及 DataMixer、数据湖入湖、SFT recipe/export、按 math/code/text2sql/reasoning 域找数据时,先按 `skills/obtainer/SKILL.md` 和其中的 ObtainerCLI 流程执行,不要从 `outputs/` 里的旧 run 或旧 recipe 反推当前流程
- 执行数据搜集/下载/入湖时,starter 外层只能通过 CLI wrapper 启动 `dataset-acquisition-agent`;如果运行环境不是当前 shell 的 Python,先设置 `LOOPAI_PYTHON_EXECUTABLE=/path/to/loopai-env/bin/python`,再用 `${LOOPAI_PYTHON_EXECUTABLE:-python} -m loopai.skills.ObtainerCLI.cli dm ... dataset-acquisition-agent start`,或在 start 命令上显式传 `--python-executable /path/to/loopai-env/bin/python`;然后轮询/续跑。不要使用通用 `spawn_agent` worker,不要在外层自己创建 SearchAgent task JSON、调用 `searchagent`、调用 `download manifest` 或直接入湖
diff --git a/examples/config/starter.yaml b/examples/config/starter.yaml
index 6d26cb3..77285da 100644
--- a/examples/config/starter.yaml
+++ b/examples/config/starter.yaml
@@ -168,10 +168,20 @@ default_states:
verl_algorithm: "grpo"
verl_rollout_backend: "vllm" # vllm 或 sglang
verl_model_backend: "fsdp"
- train_input_eval_dataset_path: "" # 验证集 Parquet 路径
- verl_reward_mode: "auto" # auto、preset 或 custom
+ # 可直接填原生 Verl Parquet;多轮闭环也可留空,由 Trainer 优先读取本轮 Constructor JSON/JSONL 输出。
+ verl_source_dataset_path: ""
+ verl_source_eval_dataset_path: ""
+ verl_data_adapter: "auto" # auto、native、messages、alpaca 或 qa
+ verl_validation_ratio: 0.05 # 没有验证集时确定性切分
+ verl_split_seed: 42
+ verl_reuse_previous_validation: true
+ train_input_eval_dataset_path: "" # 可选;上一轮验证 Parquet 默认复用
+ verl_reward_mode: "auto" # 明确指定 preset/custom 时优先,否则 Trainer 对生成数据给出保守建议
verl_reward_preset: "auto"
verl_reward_kwargs: {}
verl_selection_metric: "val-core/*/acc/mean@*"
verl_selection_mode: "max"
verl_max_actor_ckpt_to_keep: 10
+ verl_inherit_previous_config: true
+ verl_use_previous_best_model: true
+ verl_multi_round_enabled: true
diff --git a/loopai/common/db_tool/task.py b/loopai/common/db_tool/task.py
index 88db0f4..fd3af4e 100644
--- a/loopai/common/db_tool/task.py
+++ b/loopai/common/db_tool/task.py
@@ -155,7 +155,19 @@ def _extract_state_section(states_config: dict[str, Any], section_name: str) ->
def _coerce_update_item(raw_item: Any) -> dict[str, Any]:
if isinstance(raw_item, dict):
- return format_value(dict(raw_item))
+ # Configer accepts both schema-style wrappers (``{"value": ...}``)
+ # and direct field values. A direct mapping value must not be mistaken
+ # for the wrapper itself; that used to turn fields such as
+ # constructor.mapping_results into None. Likewise, infer the wrapper
+ # type when callers omit it so ``{"value": {...}}`` remains a mapping
+ # instead of being stringified.
+ if "value" not in raw_item and "default" not in raw_item and "type" not in raw_item:
+ return {"value": dict(raw_item), "type": "dict"}
+ item = dict(raw_item)
+ if "type" not in item:
+ value = item.get("value", item.get("default"))
+ item["type"] = wrap_attr(value)["type"]
+ return format_value(item)
return {"value": raw_item}
diff --git a/loopai/schema/states.py b/loopai/schema/states.py
index c3e66fa..c875e75 100644
--- a/loopai/schema/states.py
+++ b/loopai/schema/states.py
@@ -1358,9 +1358,60 @@ class TrainerState(BaseModel):
train_input_eval_dataset_path: str = Field(
default="",
title="验证数据集路径",
- description="Verl GRPO 使用的验证 Parquet 路径",
+ description="Verl GRPO 使用的验证 Parquet 路径;生成数据可由 Trainer 自动切分",
json_schema_extra={"ui_type": "file_path", "ui_group": "训练模型"}
)
+ verl_source_dataset_path: str = Field(
+ default="",
+ title="Verl 原始训练数据",
+ description="JSON/JSONL/Parquet 原始数据;留空时优先使用本轮 Constructor 输出",
+ json_schema_extra={"ui_type": "file_path", "ui_group": "训练模型"},
+ )
+ verl_source_dataset_origin: str = Field(
+ default="",
+ title="Verl 原始数据来源",
+ description="Trainer 记录 user/constructor/obtainer,用于下一轮区分显式覆盖与旧路径",
+ json_schema_extra={"ui_type": "text", "readOnly": True, "ui_group": "训练模型"},
+ )
+ verl_source_eval_dataset_path: str = Field(
+ default="",
+ title="Verl 原始验证数据",
+ description="可选的 JSON/JSONL/Parquet 验证数据;留空时自动切分或复用上一轮验证集",
+ json_schema_extra={"ui_type": "file_path", "ui_group": "训练模型"},
+ )
+ verl_data_adapter: str = Field(
+ default="auto",
+ title="Verl 数据适配器",
+ description="Trainer 将生成数据转换为 Verl Parquet 时使用的字段适配器",
+ json_schema_extra={"ui_type": "list", "ui_group": "训练模型",
+ "allowed_values": ["auto", "native", "messages", "alpaca", "qa"]},
+ )
+ verl_data_source: str = Field(
+ default="",
+ title="Verl data_source 覆盖值",
+ description="通常留空由 Trainer/reward 决定;仅在数据协议明确时手动填写",
+ json_schema_extra={"ui_type": "text", "ui_group": "训练模型"},
+ )
+ verl_validation_ratio: float = Field(
+ default=0.05,
+ gt=0.0,
+ lt=1.0,
+ title="Verl 验证集比例",
+ description="未提供验证数据时 Trainer 的确定性切分比例",
+ json_schema_extra={"ui_type": "number", "ui_group": "训练模型"},
+ )
+ verl_split_seed: int = Field(
+ default=42,
+ title="Verl 数据切分种子",
+ description="控制生成数据去重后的确定性训练/验证切分",
+ json_schema_extra={"ui_type": "number", "ui_group": "训练模型"},
+ )
+ verl_reuse_previous_validation: bool = Field(
+ default=True,
+ title="复用上一轮验证集",
+ description="多轮训练时保持验证集稳定;显式提供本轮验证源时以本轮为准",
+ json_schema_extra={"ui_type": "toggle_switch", "ui_group": "训练模型"},
+ )
verl_reward_function_path: str = Field(
default="",
title="Verl Reward 函数路径",
@@ -1380,6 +1431,12 @@ class TrainerState(BaseModel):
json_schema_extra={"ui_type": "list", "ui_group": "训练模型",
"allowed_values": ["auto", "preset", "custom"]},
)
+ verl_reward_origin: str = Field(
+ default="",
+ title="Verl Reward 选择来源",
+ description="记录 reward 是自动匹配还是用户指定,用于新一轮按新数据重新匹配",
+ json_schema_extra={"ui_type": "text", "readOnly": True, "ui_group": "训练模型"},
+ )
verl_reward_preset: str = Field(
default="auto",
title="Verl Reward 预设",
@@ -1416,6 +1473,24 @@ class TrainerState(BaseModel):
description="最多保留的 actor checkpoint 数量",
json_schema_extra={"ui_type": "number", "ui_group": "训练模型"}
)
+ verl_inherit_previous_config: bool = Field(
+ default=True,
+ title="继承上一轮 Verl 配置",
+ description="下一轮以此前已确认 YAML 为基线,仅刷新数据、模型、reward 和运行字段",
+ json_schema_extra={"ui_type": "toggle_switch", "ui_group": "训练模型"},
+ )
+ verl_use_previous_best_model: bool = Field(
+ default=True,
+ title="使用上一轮最佳模型",
+ description="上一轮成功导出 Hugging Face 模型后,自动作为下一轮 GRPO 初始模型",
+ json_schema_extra={"ui_type": "toggle_switch", "ui_group": "训练模型"},
+ )
+ verl_multi_round_enabled: bool = Field(
+ default=True,
+ title="启用 Verl 多轮衔接",
+ description="确保保存 checkpoint 并导出下一轮可直接加载的 Hugging Face 模型",
+ json_schema_extra={"ui_type": "toggle_switch", "ui_group": "训练模型"},
+ )
CUDA_VISIBLE_DEVICES: str = Field(
default="",
title="CUDA 可见设备",
@@ -1598,6 +1673,42 @@ class TrainerState(BaseModel):
description="每次启动 Trainer 子节点时生成的 version_id,用于 TaskRuntimeItem 和版本化输出目录",
json_schema_extra={"ui_type": "text", "ui_group": "训练模型"}
)
+ trainer_parent_version_id: str = Field(
+ default="",
+ title="上一轮 Trainer 版本 ID",
+ description="当前训练轮继承的数据、配置和模型所来自的 Trainer 版本",
+ json_schema_extra={"ui_type": "text", "readOnly": True, "ui_group": "训练模型"},
+ )
+ trainer_round_index: int = Field(
+ default=0,
+ title="Trainer 轮次",
+ description="同一 task 下从 1 开始递增的训练轮次",
+ json_schema_extra={"ui_type": "number", "readOnly": True, "ui_group": "训练模型"},
+ )
+ trainer_model_inheritance: Dict[str, Any] = Field(
+ default_factory=dict,
+ title="Trainer 模型继承结果",
+ description="是否采用上一轮最佳模型及其校验原因",
+ json_schema_extra={"ui_type": "json_viewer", "readOnly": True, "ui_group": "训练模型"},
+ )
+ verl_data_manifest_path: str = Field(
+ default="",
+ title="Verl 数据清单路径",
+ description="Trainer 数据转换、切分、去重和 reward 决策的版本化清单",
+ json_schema_extra={"ui_type": "file_path", "readOnly": True, "ui_group": "训练模型"},
+ )
+ verl_data_prepare_result: Dict[str, Any] = Field(
+ default_factory=dict,
+ title="Verl 数据准备结果",
+ description="本轮生成数据适配为 Verl Parquet 的统计结果",
+ json_schema_extra={"ui_type": "json_viewer", "readOnly": True, "ui_group": "训练模型"},
+ )
+ verl_reward_recommendation: Dict[str, Any] = Field(
+ default_factory=dict,
+ title="Verl Reward 建议",
+ description="Trainer 自动选择或确认 reward 的依据;不明确时不会猜测",
+ json_schema_extra={"ui_type": "json_viewer", "readOnly": True, "ui_group": "训练模型"},
+ )
trainer_output_dir: str = Field(
default="",
title="Trainer 本次输出目录",
diff --git a/loopai/skills/Trainer/README.md b/loopai/skills/Trainer/README.md
index 5dfb603..b058e2b 100644
--- a/loopai/skills/Trainer/README.md
+++ b/loopai/skills/Trainer/README.md
@@ -45,6 +45,38 @@ result = run_prepared(
GRPO 训练/验证 Parquet 至少需要 `prompt`、`data_source`、`reward_model` 三列。训练通过 `conda run --no-capture-output -n verl python -m verl.trainer.main_ppo ...` 启动;指标写入 Trainer 的 `metrics` 目录,checkpoint 按 `global_step_N` 识别,根据 YAML 中的验证指标选择最佳项,再把选中的 FSDP actor 合并为 Hugging Face 模型。
+## 生成数据与多轮 GRPO
+
+Constructor 仍负责生成和清洗数据;当 `train_framework=verl` 时,Trainer 在每轮 `prepare()` 中负责最后一段训练协议适配:
+
+1. 优先读取本轮 `constructor.mapping_results.output_file`,也可用 `verl_source_dataset_path` 显式覆盖。
+2. 自动识别原生 Verl、messages/ShareGPT、Alpaca 或普通 QA,转换为本轮目录下的 `prepared_data/train.parquet`。
+3. 若未提供验证数据,按 `verl_validation_ratio` 和 `verl_split_seed` 确定性切分;多轮仅在 reward 协议兼容时复用上一轮验证 Parquet。
+4. 生成 `dataset_manifest.json` 和 `rejected_rows.jsonl`,记录来源、去重、拒绝、切分、哈希和 reward 决策。
+5. 以上一轮已确认的 Verl YAML 为超参基线,只刷新本轮数据、最佳模型、reward、GPU、输出目录和 version;然后仍向用户展示整份新 YAML,等待本轮确认。
+
+生成数据的最小增量配置如下;通常不需要用户手写 Verl Parquet:
+
+```yaml
+trainer:
+ train_framework: verl
+ train_stage: grpo
+ verl_dir: /path/to/verl
+ verl_env_path: verl
+ train_input_model_name: /path/to/base-model
+ train_input_task_description: Mathematics reasoning with GRPO
+ verl_source_dataset_path: "" # 留空时使用本轮 Constructor 输出
+ verl_data_adapter: auto
+ verl_validation_ratio: 0.05
+ verl_reuse_previous_validation: true
+ verl_inherit_previous_config: true
+ verl_use_previous_best_model: true
+ verl_multi_round_enabled: true
+ verl_reward_mode: auto
+```
+
+`auto` 会针对每轮新的上游数据重新判断,只在任务/数据来源或答案标记能可靠对应已有 preset 时采用建议;无法判断时 `prepare()` 会停止并要求设置 `verl_reward_mode=preset`/`verl_reward_preset` 或 custom reward,不会猜测 ground truth 或 reward 语义。上一轮模型只有在训练成功、导出无错误且目录包含可加载的 Hugging Face 配置和权重时才会自动传给下一轮;若要改用指定模型,可在本轮调用显式传 `train_input_model_name`,或关闭 `verl_use_previous_best_model`。
+
## Verl Reward 预设
LoopAI 通过 `loopai/skills/Trainer/rewards/router.py` 提供稳定入口,预设只调用当前 Verl 环境中的实现,不复制 Verl 源码。
@@ -89,10 +121,11 @@ Verl 实时进度优先读取 `metrics/verl_metrics.jsonl` 的 `training/global_
### 1. 数据检查节点 (Data Check Node)
-**功能:** 验证数据集格式是否符合 LlamaFactory 要求
+**功能:** 验证 SFT 数据;对 Verl 则先适配生成数据,再验证训练/验证 Parquet 与 reward 协议
**输入:**
-- `train_input_dataset_path`: 数据集文件路径(支持 JSON/JSONL 格式)
+- `train_input_dataset_path`: SFT 数据或原生 Verl Parquet
+- `verl_source_dataset_path`: 可选的生成数据路径;未配置时读取 Constructor 输出
**输出:**
- 数据格式验证报告
diff --git a/loopai/skills/Trainer/nodes/config_generation_node.py b/loopai/skills/Trainer/nodes/config_generation_node.py
index 9352b97..aa23d52 100644
--- a/loopai/skills/Trainer/nodes/config_generation_node.py
+++ b/loopai/skills/Trainer/nodes/config_generation_node.py
@@ -1,11 +1,10 @@
"""
配置生成节点
-根据任务描述生成 LlamaFactory 训练配置(YAML格式)
+生成 LlamaFactory SFT 或 Verl GRPO 训练配置(YAML格式)
"""
import os
import yaml
-from pathlib import Path
from loopai.schema.states import LoopAIState
from loopai.skills.Trainer.utils.config_generator import ConfigGenerator, generate_config_explanation
from loopai.skills.Trainer.utils.verl_config_generator import (
@@ -22,7 +21,7 @@ def config_generation_node(state: LoopAIState) -> LoopAIState:
"""
配置生成节点
- 根据任务描述和数据集信息生成合理的 LlamaFactory 训练配置
+ 根据任务描述和数据集信息生成对应后端训练配置
Args:
state: LoopAIState 对象,需要包含:
@@ -155,9 +154,9 @@ def config_generation_node(state: LoopAIState) -> LoopAIState:
or './output/trainer'
)
os.makedirs(output_dir, exist_ok=True)
- config_output_path = state.get('trainer', {}).get('train_output_config_path')
- if not config_output_path or Path(str(config_output_path)).suffix.lower() not in {'.yaml', '.yml'}:
- config_output_path = os.path.join(output_dir, 'training_config.yaml')
+ # Every prepare() round owns a version-scoped YAML. Reusing a stale
+ # path would overwrite the previous round's approved configuration.
+ config_output_path = os.path.join(output_dir, 'training_config.yaml')
config = generate_verl_grpo_config(state, template_path)
config_output_path = save_verl_grpo_config(config, config_output_path)
diff --git a/loopai/skills/Trainer/nodes/data_check_node.py b/loopai/skills/Trainer/nodes/data_check_node.py
index 45ff8b5..c01c4bc 100644
--- a/loopai/skills/Trainer/nodes/data_check_node.py
+++ b/loopai/skills/Trainer/nodes/data_check_node.py
@@ -1,9 +1,10 @@
"""
数据检查节点
-验证数据集格式是否符合 LlamaFactory 要求
+验证 SFT 数据;为 Verl 准备并验证 GRPO 数据与 reward 协议
"""
import os
+from pathlib import Path
from loopai.skills.Trainer.utils.stream_events import prepare_trainer_run
from loopai.schema.states import LoopAIState
@@ -12,16 +13,29 @@
check_verl_grpo_inputs,
generate_verl_data_report,
)
+from loopai.skills.Trainer.utils.verl_dataset_builder import prepare_verl_grpo_datasets
from loopai.logger import get_logger
logger = get_logger()
+def _mapping_output_file(state: LoopAIState, section_name: str):
+ section = state.get(section_name) or {}
+ if not isinstance(section, dict):
+ raise ValueError(f"{section_name} state must be a mapping")
+ mapping = section.get('mapping_results')
+ if mapping in (None, ""):
+ return None
+ if not isinstance(mapping, dict):
+ raise ValueError(f"{section_name}.mapping_results must be a mapping")
+ return mapping.get('output_file')
+
+
def data_check_node(state: LoopAIState) -> LoopAIState:
"""
数据检查节点
- 检查数据集格式是否符合 LlamaFactory 要求
+ 检查 SFT 数据格式;Verl 模式下将生成数据适配为 Parquet 后预检查
Args:
state: LoopAIState 对象,需要包含:
@@ -38,14 +52,69 @@ def data_check_node(state: LoopAIState) -> LoopAIState:
try:
# 获取数据集路径 - 优先使用 obtainer/constructor 映射结果
- obtainer_output_file = state.get('obtainer', {}).get('mapping_results', {}).get('output_file') if state.get('obtainer', {}).get('mapping_results') else None
- constructor_output_file = state.get('constructor', {}).get('mapping_results', {}).get('output_file') if state.get('constructor', {}).get('mapping_results') else None
+ obtainer_output_file = _mapping_output_file(state, 'obtainer')
+ constructor_output_file = _mapping_output_file(state, 'constructor')
- framework = state.get('trainer', {}).get('train_framework')
+ trainer_state = state.setdefault('trainer', {})
+ framework = trainer_state.get('train_framework')
if framework == "verl":
- # Constructor/Obtainer currently produce SFT JSON. An explicitly supplied
- # RL Parquet must not be silently replaced by those artifacts.
- dataset_path = state.get('trainer', {}).get('train_input_dataset_path')
+ if trainer_state.get('_trainer_use_prepared_config'):
+ # run_prepared() must validate exactly the train/validation files in
+ # the YAML that the user approved. Never regenerate data at this gate.
+ dataset_path = trainer_state.get('train_input_dataset_path')
+ else:
+ explicit_source = trainer_state.get('_verl_source_dataset_explicit')
+ generated_source = constructor_output_file or obtainer_output_file
+ source_path = (
+ trainer_state.get('verl_source_dataset_path')
+ if explicit_source
+ else generated_source
+ or trainer_state.get('verl_source_dataset_path')
+ or trainer_state.get('train_input_dataset_path')
+ )
+ if not source_path:
+ raise ValueError(
+ "Verl 缺少数据源;请设置 verl_source_dataset_path,或先让 Constructor 生成数据"
+ )
+ trainer_state['verl_source_dataset_path'] = source_path
+ if not explicit_source and generated_source:
+ trainer_state['verl_source_dataset_origin'] = (
+ 'constructor' if constructor_output_file else 'obtainer'
+ )
+ logger.info(f"准备 Verl GRPO 数据源: {source_path}")
+ prepare_result = prepare_verl_grpo_datasets(state)
+ if generated_source and not explicit_source:
+ expected_source = Path(str(generated_source)).expanduser().resolve()
+ actual_source = prepare_result.get('source_path')
+ if not actual_source or Path(str(actual_source)).expanduser().resolve() != expected_source:
+ raise RuntimeError(
+ "Verl prepared data does not come from the current upstream output: "
+ f"expected {expected_source}, got {actual_source or 'missing'}"
+ )
+ manifest_reward = prepare_result.get('reward')
+ if not isinstance(manifest_reward, dict):
+ raise RuntimeError("Verl data manifest is missing its reward contract")
+ manifest_mode = str(manifest_reward.get('mode') or '')
+ current_mode = str(trainer_state.get('verl_reward_mode') or '')
+ if manifest_mode != current_mode:
+ raise RuntimeError(
+ f"Verl reward mode mismatch: manifest={manifest_mode}, state={current_mode}"
+ )
+ if current_mode in {'auto', 'preset'}:
+ manifest_preset = str(manifest_reward.get('preset') or '')
+ current_preset = str(trainer_state.get('verl_reward_preset') or '')
+ if manifest_preset != current_preset:
+ raise RuntimeError(
+ "Verl reward preset mismatch: "
+ f"manifest={manifest_preset}, state={current_preset}"
+ )
+ dataset_path = trainer_state.get('train_input_dataset_path')
+ logger.info(
+ "Verl 数据准备完成: train=%s, validation=%s, status=%s",
+ dataset_path,
+ trainer_state.get('train_input_eval_dataset_path'),
+ prepare_result.get('status'),
+ )
elif obtainer_output_file and os.path.exists(obtainer_output_file):
dataset_path = obtainer_output_file
logger.info(f"使用 obtainer 映射结果作为训练数据集: {dataset_path}")
@@ -102,7 +171,6 @@ def data_check_node(state: LoopAIState) -> LoopAIState:
for warning in check_result['warnings'][:3]: # 只显示前3个警告
logger.warning(f" - {warning}")
elif framework == "verl":
- trainer_state = state.setdefault('trainer', {})
if trainer_state.get('train_stage') != 'grpo':
raise ValueError("Verl 当前只支持 GRPO")
eval_path = trainer_state.get('train_input_eval_dataset_path')
diff --git a/loopai/skills/Trainer/runner.py b/loopai/skills/Trainer/runner.py
index 22b7929..cf779f5 100644
--- a/loopai/skills/Trainer/runner.py
+++ b/loopai/skills/Trainer/runner.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import copy
import hashlib
import json
import os
@@ -18,6 +19,36 @@
_TRAINER_TASK_STATE_UPDATE_FIELDS = {
+ "train_framework",
+ "train_stage",
+ "train_input_dataset_path",
+ "train_input_eval_dataset_path",
+ "train_input_model_name",
+ "train_input_task_description",
+ "train_input_config_template_path",
+ "verl_source_dataset_path",
+ "verl_source_dataset_origin",
+ "verl_source_eval_dataset_path",
+ "verl_data_adapter",
+ "verl_data_source",
+ "verl_validation_ratio",
+ "verl_split_seed",
+ "verl_reuse_previous_validation",
+ "verl_reward_mode",
+ "verl_reward_origin",
+ "verl_reward_preset",
+ "verl_reward_function_path",
+ "verl_reward_function_name",
+ "verl_reward_kwargs",
+ "verl_inherit_previous_config",
+ "verl_use_previous_best_model",
+ "verl_multi_round_enabled",
+ "trainer_parent_version_id",
+ "trainer_round_index",
+ "trainer_model_inheritance",
+ "verl_data_manifest_path",
+ "verl_data_prepare_result",
+ "verl_reward_recommendation",
"trainer_task_id",
"data_check_passed",
"data_check_result",
@@ -70,6 +101,62 @@
}
+_TRAINER_ROUND_TRANSIENT_FIELDS = {
+ "data_check_passed",
+ "data_check_result",
+ "data_check_report_path",
+ "data_check_error",
+ "config_generation_success",
+ "config_explanation_path",
+ "config_generation_error",
+ "training_success",
+ "training_execution_time",
+ "training_final_status",
+ "training_log_path",
+ "training_report_path",
+ "training_error",
+ "current_training_status",
+ "update_model_path",
+ "trainer_event_log_path",
+ "trainer_run_state_path",
+ "trainer_worker_log_path",
+ "trainer_worker_pid",
+ "trainer_state_update_error",
+ "trainer_output_dir",
+ "trainer_result",
+ "trainer_last_error",
+ "trainer_result_analysis",
+ "trainer_result_analysis_version_id",
+ "trainer_result_summary",
+ "trainer_best_checkpoint",
+ "trainer_best_metric",
+ "trainer_best_checkpoint_path",
+ "trainer_model_export_error",
+ "trainer_model_export_log_path",
+ "train_config",
+ "training_checkpoints",
+ "training_step_losses",
+ "trainer_data_check_result",
+ "trainer_data_check_passed",
+ "trainer_data_check_error",
+ "train_output_config_path",
+ "train_output_data_check_report_path",
+ "trainer_config_explanation_path",
+ "trainer_config_generation_success",
+ "trainer_config_generation_error",
+ "train_output_training_log_path",
+ "train_output_training_report_path",
+ "train_output_training_error",
+ "trainer_training_success",
+ "trainer_training_execution_time",
+ "trainer_training_final_status",
+ "trainer_current_training_status",
+ "verl_data_manifest_path",
+ "verl_data_prepare_result",
+ "verl_reward_recommendation",
+}
+
+
def _to_configer_update_item(value: Any) -> Dict[str, Any]:
if isinstance(value, bool):
type_name = "bool"
@@ -172,6 +259,208 @@ def _resolve_explicit_trainer_output_dir(kwargs: Dict[str, Any]) -> str | None:
return None
+def _previous_training_completed(trainer_state: Dict[str, Any]) -> bool:
+ if trainer_state.get("trainer_training_success") is True or trainer_state.get("training_success") is True:
+ return True
+ final_status = (
+ trainer_state.get("trainer_training_final_status")
+ or trainer_state.get("training_final_status")
+ or {}
+ )
+ return isinstance(final_status, dict) and str(final_status.get("status") or "").lower() == "completed"
+
+
+def _is_huggingface_model_dir(path_value: Any) -> bool:
+ if not path_value:
+ return False
+ path = Path(str(path_value)).expanduser().resolve()
+ if not path.is_dir() or not (path / "config.json").is_file():
+ return False
+ candidates = (
+ "model.safetensors",
+ "model.safetensors.index.json",
+ "pytorch_model.bin",
+ "pytorch_model.bin.index.json",
+ "adapter_model.safetensors",
+ "adapter_model.bin",
+ )
+ return any((path / name).is_file() for name in candidates)
+
+
+def _prepare_fresh_trainer_round(
+ state: Dict[str, Any],
+ *,
+ kwargs: Dict[str, Any],
+) -> None:
+ """Snapshot the previous successful Verl round and clear stale outputs."""
+ trainer_state = state.setdefault("trainer", {})
+ if trainer_state.get("train_framework") != "verl":
+ return
+
+ previous_version = str(trainer_state.get("trainer_version_id") or "")
+ previous_round = int(trainer_state.get("trainer_round_index") or 0)
+ previous_completed = _previous_training_completed(trainer_state)
+ previous_config = trainer_state.get("train_config")
+ if (
+ previous_completed
+ and trainer_state.get("verl_inherit_previous_config", True)
+ and isinstance(previous_config, dict)
+ and previous_config.get("framework") == "verl"
+ and previous_config.get("stage") == "grpo"
+ ):
+ inherited_config = copy.deepcopy(previous_config)
+ else:
+ inherited_config = None
+
+ previous_model = _first_non_empty(
+ trainer_state.get("update_model_path"),
+ trainer_state.get("trainer_best_checkpoint_path"),
+ )
+ explicit_model = _first_non_empty(
+ kwargs.get("train_input_model_name"),
+ kwargs.get("model_path"),
+ os.getenv("TRAIN_MODEL_PATH"),
+ )
+ model_inheritance: Dict[str, Any] = {
+ "applied": False,
+ "source_version_id": previous_version or None,
+ "source_model_path": str(previous_model) if previous_model else None,
+ }
+ inherited_model: str | None = None
+ if explicit_model:
+ model_inheritance["reason"] = "current round supplied an explicit model override"
+ elif not trainer_state.get("verl_use_previous_best_model", True):
+ model_inheritance["reason"] = "verl_use_previous_best_model is disabled"
+ elif not previous_completed:
+ model_inheritance["reason"] = "no previous successful training round"
+ elif trainer_state.get("trainer_model_export_error"):
+ model_inheritance["reason"] = "previous Verl model export failed"
+ elif _is_huggingface_model_dir(previous_model):
+ inherited_model = str(Path(str(previous_model)).expanduser().resolve())
+ model_inheritance.update({
+ "applied": True,
+ "model_path": inherited_model,
+ "reason": "previous best checkpoint is a loadable Hugging Face model",
+ })
+ else:
+ model_inheritance["reason"] = "previous best checkpoint is not a loadable Hugging Face model directory"
+
+ for field in _TRAINER_ROUND_TRANSIENT_FIELDS:
+ trainer_state.pop(field, None)
+
+ trainer_state["trainer_parent_version_id"] = previous_version if previous_completed else ""
+ trainer_state["trainer_round_index"] = max(1, previous_round + 1)
+ trainer_state["trainer_model_inheritance"] = model_inheritance
+ if inherited_config is not None:
+ trainer_state["_trainer_previous_config"] = inherited_config
+ else:
+ trainer_state.pop("_trainer_previous_config", None)
+ if inherited_model:
+ trainer_state["train_input_model_name"] = inherited_model
+ explicit_eval = _first_non_empty(
+ kwargs.get("train_input_eval_dataset_path"),
+ kwargs.get("eval_dataset_path"),
+ kwargs.get("verl_source_eval_dataset_path"),
+ os.getenv("TRAIN_EVAL_DATASET_PATH"),
+ os.getenv("VERL_SOURCE_EVAL_DATASET_PATH"),
+ trainer_state.get("verl_source_eval_dataset_path"),
+ )
+ previous_reward = previous_config.get("loopai_reward") if isinstance(previous_config, dict) else None
+ trainer_state["_trainer_eval_explicit"] = bool(explicit_eval)
+ if previous_completed and isinstance(previous_reward, dict):
+ trainer_state["_trainer_previous_reward"] = copy.deepcopy(previous_reward)
+ else:
+ trainer_state.pop("_trainer_previous_reward", None)
+ reward_contract_changed = False
+ if (
+ previous_completed
+ and isinstance(previous_reward, dict)
+ and trainer_state.get("verl_reward_origin") != "auto"
+ ):
+ previous_mode = str(previous_reward.get("mode") or "")
+ current_mode = str(trainer_state.get("verl_reward_mode") or "auto")
+ previous_reward_signature = (
+ previous_mode,
+ str(previous_reward.get("preset") or ""),
+ str(previous_reward.get("function_path") or "") if previous_mode == "custom" else "",
+ str(previous_reward.get("function_name") or "") if previous_mode == "custom" else "",
+ )
+ current_reward_signature = (
+ current_mode,
+ str(trainer_state.get("verl_reward_preset") or ""),
+ str(trainer_state.get("verl_reward_function_path") or "") if current_mode == "custom" else "",
+ str(trainer_state.get("verl_reward_function_name") or "compute_score") if current_mode == "custom" else "",
+ )
+ reward_contract_changed = previous_reward_signature != current_reward_signature
+ if (
+ not trainer_state.get("verl_reuse_previous_validation", True)
+ or reward_contract_changed
+ ) and not explicit_eval:
+ trainer_state["train_input_eval_dataset_path"] = ""
+
+
+def _first_config_path(value: Any) -> str | None:
+ if isinstance(value, list):
+ value = value[0] if value else None
+ return str(value) if value not in (None, "") else None
+
+
+def _hydrate_state_from_approved_config(
+ trainer_state: Dict[str, Any],
+ config: Dict[str, Any],
+) -> None:
+ """Make preflight validate the exact paths/reward contract in approved YAML."""
+ framework = str(config.get("framework") or trainer_state.get("train_framework") or "")
+ stage = str(config.get("stage") or trainer_state.get("train_stage") or "")
+ if framework:
+ trainer_state["train_framework"] = framework
+ if stage:
+ trainer_state["train_stage"] = stage
+ if framework != "verl":
+ return
+
+ overrides = config.get("overrides") or {}
+ environment = config.get("environment") or {}
+ result = config.get("result") or {}
+ reward = config.get("loopai_reward") or {}
+ train_path = _first_config_path(overrides.get("data.train_files"))
+ eval_path = _first_config_path(overrides.get("data.val_files"))
+ if train_path:
+ trainer_state["train_input_dataset_path"] = train_path
+ if eval_path:
+ trainer_state["train_input_eval_dataset_path"] = eval_path
+ if overrides.get("actor_rollout_ref.model.path"):
+ trainer_state["train_input_model_name"] = str(overrides["actor_rollout_ref.model.path"])
+ if overrides.get("actor_rollout_ref.rollout.name"):
+ trainer_state["verl_rollout_backend"] = str(overrides["actor_rollout_ref.rollout.name"])
+ if environment.get("verl_dir"):
+ trainer_state["verl_dir"] = str(environment["verl_dir"])
+ if environment.get("verl_env_path"):
+ trainer_state["verl_env_path"] = str(environment["verl_env_path"])
+ if environment.get("cuda_visible_devices") is not None:
+ trainer_state["CUDA_VISIBLE_DEVICES"] = str(environment["cuda_visible_devices"])
+ if reward.get("mode"):
+ trainer_state["verl_reward_mode"] = str(reward["mode"])
+ if reward.get("origin"):
+ trainer_state["verl_reward_origin"] = str(reward["origin"])
+ if reward.get("preset"):
+ trainer_state["verl_reward_preset"] = str(reward["preset"])
+ if reward.get("mode") == "custom":
+ trainer_state["verl_reward_function_path"] = str(reward.get("function_path") or "")
+ trainer_state["verl_reward_function_name"] = str(
+ reward.get("function_name") or "compute_score"
+ )
+ reward_kwargs = overrides.get("+reward.custom_reward_function.reward_kwargs")
+ if isinstance(reward_kwargs, dict):
+ reward_kwargs = copy.deepcopy(reward_kwargs)
+ reward_kwargs.pop("preset", None)
+ trainer_state["verl_reward_kwargs"] = reward_kwargs
+ if result.get("selection_metric"):
+ trainer_state["verl_selection_metric"] = str(result["selection_metric"])
+ if result.get("selection_mode"):
+ trainer_state["verl_selection_mode"] = str(result["selection_mode"])
+
+
def inspect_prepared_trainer_config(config_path: str) -> Dict[str, Any]:
"""Return the exact YAML text and digest used by the approval workflow."""
path = Path(config_path).expanduser().resolve()
@@ -381,6 +670,11 @@ def run_trainer_standalone(
resolved_state = runtime["state"]
trainer_state = resolved_state.setdefault("trainer", {})
explicit_version_id = _resolve_trainer_version_id(kwargs)
+ if prepared_config_path and explicit_version_id is None:
+ existing_version = trainer_state.get("trainer_version_id")
+ explicit_version_id = str(existing_version) if existing_version else None
+ if explicit_version_id is None and not prepared_config_path:
+ _prepare_fresh_trainer_round(resolved_state, kwargs=kwargs)
event_writer = prepare_trainer_run(
resolved_state,
version_id=explicit_version_id,
@@ -439,6 +733,7 @@ def run_trainer_standalone(
)
trainer_state["train_output_config_path"] = prepared_config["config_path"]
trainer_state["train_config"] = prepared_config["config"]
+ _hydrate_state_from_approved_config(trainer_state, prepared_config["config"])
# Carry the exact approved bytes into the trusted worker request.
# The worker materializes this snapshot instead of rereading a
# user-editable source path after the approval digest check.
@@ -475,7 +770,7 @@ def run_trainer_standalone(
graph = trainer()
graph_config = {
"configurable": {
- "thread_id": kwargs.get("graph_thread_id") or f"trainer_{runtime['thread_id']}",
+ "thread_id": kwargs.get("graph_thread_id") or f"trainer_{runtime['thread_id']}_{version_id}",
}
}
@@ -502,8 +797,33 @@ def run_trainer_standalone(
trainer_state.pop("_trainer_worker_runtime", None)
trainer_state.pop("_trainer_approved_config_yaml", None)
trainer_state.pop("_trainer_approved_config_sha256", None)
+ trainer_state.pop("_trainer_previous_config", None)
+ trainer_state.pop("_trainer_previous_reward", None)
+ trainer_state.pop("_trainer_eval_explicit", None)
+ trainer_state.pop("_verl_source_dataset_explicit", None)
+ trainer_state.pop("_verl_source_dataset_replaced", None)
if prepare_only:
+ if not trainer_state.get("trainer_config_generation_success"):
+ detail = _first_non_empty(
+ trainer_state.get("trainer_data_check_error"),
+ trainer_state.get("trainer_config_generation_error"),
+ "Trainer did not generate a configuration",
+ )
+ exc = ValueError(str(detail))
+ payload = emit_error(
+ exc,
+ code=ErrorCode.INVALID_INPUT,
+ recoverable=True,
+ stream_writer=event_writer,
+ message="Trainer config preparation failed.",
+ exit_process=emit_result,
+ print_payload=emit_result,
+ )
+ trainer_state["trainer_result"] = payload
+ trainer_state["trainer_last_error"] = payload["error"]
+ _update_trainer_task_state(runtime, trainer_state)
+ raise exc
try:
prepared_config = inspect_prepared_trainer_config(
str(trainer_state.get("train_output_config_path") or "")
@@ -526,7 +846,13 @@ def run_trainer_standalone(
preparation_data = {
"task_id": runtime["thread_id"],
"trainer_version_id": trainer_state.get("trainer_version_id"),
+ "trainer_parent_version_id": trainer_state.get("trainer_parent_version_id"),
+ "trainer_round_index": trainer_state.get("trainer_round_index"),
"trainer_output_dir": trainer_state.get("trainer_output_dir"),
+ "trainer_model_inheritance": trainer_state.get("trainer_model_inheritance"),
+ "verl_data_manifest_path": trainer_state.get("verl_data_manifest_path"),
+ "verl_data_prepare_result": trainer_state.get("verl_data_prepare_result"),
+ "verl_reward_recommendation": trainer_state.get("verl_reward_recommendation"),
"approval_required": True,
**prepared_config,
}
@@ -586,6 +912,8 @@ def run_trainer_standalone(
success_data = {
"task_id": runtime["thread_id"],
"trainer_version_id": trainer_state.get("trainer_version_id"),
+ "trainer_parent_version_id": trainer_state.get("trainer_parent_version_id"),
+ "trainer_round_index": trainer_state.get("trainer_round_index"),
"trainer_output_dir": trainer_state.get("trainer_output_dir"),
"trainer_training_task_id": trainer_state.get("trainer_training_task_id"),
"trainer_training_success": trainer_state.get("trainer_training_success"),
diff --git a/loopai/skills/Trainer/runtime_config.py b/loopai/skills/Trainer/runtime_config.py
index 0636b69..13f48c7 100644
--- a/loopai/skills/Trainer/runtime_config.py
+++ b/loopai/skills/Trainer/runtime_config.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import ast
import copy
import json
import os
@@ -50,8 +51,8 @@
},
"train_input_dataset_path": {
"required": True,
- "source": "user",
- "description": "Absolute path to the training dataset file, usually json/jsonl for SFT.",
+ "source": "user_or_upstream",
+ "description": "SFT dataset or native Verl Parquet; Verl may instead use the latest generated source.",
"example": "/path/to/data/alpaca_en_demo.json",
},
"train_input_model_name": {
@@ -100,9 +101,27 @@
"train_input_eval_dataset_path": {
"required": False,
"source": "user",
- "description": "Validation parquet required by the initial verl GRPO adapter.",
+ "description": "Optional validation data. Trainer can deterministically split generated Verl data when omitted.",
"example": "/path/to/validation.parquet",
},
+ "verl_source_dataset_path": {
+ "required": False,
+ "source": "user_or_upstream",
+ "description": "Generated JSON/JSONL/Parquet source; defaults to the latest Constructor output.",
+ "example": "/path/to/generated.jsonl",
+ },
+ "verl_source_eval_dataset_path": {
+ "required": False,
+ "source": "user",
+ "description": "Optional generated validation source before Trainer converts it to Verl Parquet.",
+ "example": "/path/to/generated-validation.jsonl",
+ },
+ "verl_data_adapter": {
+ "required": False,
+ "source": "auto",
+ "default": "auto",
+ "description": "Generated-data adapter: auto, native, messages, alpaca, or qa.",
+ },
"verl_reward_function_path": {
"required": False,
"source": "user",
@@ -121,6 +140,12 @@
"default": "auto",
"description": "Reward source: auto, preset, or custom.",
},
+ "verl_reward_origin": {
+ "required": False,
+ "source": "auto",
+ "default": "",
+ "description": "Whether the reward contract is automatically inferred or user-selected.",
+ },
"verl_reward_preset": {
"required": False,
"source": "auto",
@@ -145,6 +170,18 @@
"default": True,
"description": "Keep training, progress persistence, and finalization alive after the caller disconnects.",
},
+ "verl_inherit_previous_config": {
+ "required": False,
+ "source": "auto",
+ "default": True,
+ "description": "Use the previous approved Verl YAML as the next round's hyperparameter baseline.",
+ },
+ "verl_use_previous_best_model": {
+ "required": False,
+ "source": "auto",
+ "default": True,
+ "description": "Use the previous successful exported Hugging Face checkpoint as the next round model.",
+ },
}
@@ -174,6 +211,23 @@ def _as_int(value: Any, default: int) -> int:
return default
+def _as_float(value: Any, default: float) -> float:
+ if value is None or value == "":
+ return default
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def _has_merge_value(value: Any) -> bool:
+ if value is None or value == "":
+ return False
+ if isinstance(value, (dict, list, tuple, set)) and not value:
+ return False
+ return True
+
+
def _as_dict(value: Any, field_name: str) -> Dict[str, Any]:
if value is None or value == "":
return {}
@@ -189,6 +243,46 @@ def _as_dict(value: Any, field_name: str) -> Dict[str, Any]:
raise ValueError(f"{field_name} must be a mapping")
+def _normalize_mapping_results(value: Any, section_name: str) -> Dict[str, Any]:
+ """Return one upstream mapping payload, including legacy serialized values.
+
+ Older Configer calls could persist a mapping as Python ``repr`` text. Read
+ those snapshots safely so an existing task can continue, while rejecting
+ malformed values instead of silently falling back to a previous dataset.
+ """
+ if value in (None, ""):
+ return {}
+ if isinstance(value, dict):
+ return copy.deepcopy(value)
+ if isinstance(value, str):
+ raw = value.strip()
+ parsed: Any
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ try:
+ parsed = ast.literal_eval(raw)
+ except (SyntaxError, ValueError) as exc:
+ raise ValueError(
+ f"{section_name}.mapping_results must be a mapping"
+ ) from exc
+ if isinstance(parsed, dict):
+ return parsed
+ raise ValueError(f"{section_name}.mapping_results must be a mapping")
+
+
+def _upstream_mapping_results(state: Dict[str, Any], section_name: str) -> Dict[str, Any]:
+ section = state.get(section_name)
+ if section is None:
+ return {}
+ if not isinstance(section, dict):
+ raise ValueError(f"{section_name} state must be a mapping")
+ mapping = _normalize_mapping_results(section.get("mapping_results"), section_name)
+ if mapping:
+ section["mapping_results"] = mapping
+ return mapping
+
+
def _unwrap_config_value(value: Any) -> Any:
if isinstance(value, dict) and "value" in value:
return _unwrap_config_value(value.get("value"))
@@ -199,24 +293,61 @@ def _unwrap_config_value(value: Any) -> Any:
return value
-def _load_task_trainer_config(task_id: str, db_path: str | None) -> Dict[str, Any]:
+def _load_task_section_config(
+ section_name: str,
+ task_id: str,
+ db_path: str | None,
+) -> Dict[str, Any]:
if not task_id:
- raise ValueError("task_id is required for task-scoped Trainer config")
+ raise ValueError(f"task_id is required for task-scoped {section_name} config")
if not db_path:
raise ValueError("DB_PATH is required when TASK_ID is provided")
from loopai.skills.Configer import get_configer_task_state_config
os.environ["DB_PATH"] = str(db_path)
- result = get_configer_task_state_config(section_name="trainer", task_id=task_id)
+ result = get_configer_task_state_config(section_name=section_name, task_id=task_id)
if not result.get("ok"):
detail = (result.get("error") or {}).get("detail") or result.get("message")
- raise RuntimeError(detail or "failed to load Trainer task state config")
+ raise RuntimeError(detail or f"failed to load {section_name} task state config")
raw_config = result.get("data", {}).get("config", {})
return _unwrap_config_value(raw_config) if isinstance(raw_config, dict) else {}
+def _load_task_trainer_config(task_id: str, db_path: str | None) -> Dict[str, Any]:
+ return _load_task_section_config("trainer", task_id, db_path)
+
+
+def _merge_optional_task_section(
+ state: Dict[str, Any],
+ section_name: str,
+ task_id: str,
+ db_path: str | None,
+) -> None:
+ """Load only upstream mapping results; never pull unrelated section secrets."""
+ try:
+ persisted = _load_task_section_config(section_name, task_id, db_path)
+ except Exception:
+ return
+ caller_section = state.get(section_name)
+ caller_mapping = (
+ caller_section.get("mapping_results")
+ if isinstance(caller_section, dict)
+ else None
+ )
+ persisted_mapping = (
+ persisted.get("mapping_results")
+ if isinstance(persisted, dict)
+ else None
+ )
+ mapping_results = caller_mapping if _has_merge_value(caller_mapping) else persisted_mapping
+ if _has_merge_value(mapping_results):
+ merged = copy.deepcopy(caller_section) if isinstance(caller_section, dict) else {}
+ merged["mapping_results"] = copy.deepcopy(mapping_results)
+ state[section_name] = merged
+
+
def _load_config_state(config_path: str) -> Dict[str, Any]:
path = Path(config_path)
if not path.exists():
@@ -385,6 +516,7 @@ def resolve_trainer_runtime_config(
state = strip_retired_tracking_fields(state)
trainer = _trainer(state)
+ caller_trainer_config = copy.deepcopy(trainer)
system = _system(state)
explicit_task_id = _first_non_empty(
@@ -401,11 +533,36 @@ def resolve_trainer_runtime_config(
task_state_loaded = False
if explicit_task_id and explicit_task_id != _DEFAULT_THREAD_ID and (os.getenv("TASK_ID") or db_path):
db_trainer_config = _load_task_trainer_config(str(explicit_task_id), str(db_path) if db_path else None)
- trainer.update({
+ merged_trainer = {
key: value
for key, value in db_trainer_config.items()
if value is not None and value != "" and not is_retired_tracking_key(key)
- })
+ }
+ # Task state is the persistent baseline. Values supplied by the current
+ # caller are newer and therefore win, matching the documented
+ # kwargs > env > state > system > defaults contract.
+ caller_overrides = {
+ key: value
+ for key, value in caller_trainer_config.items()
+ if _has_merge_value(value) and not is_retired_tracking_key(key)
+ }
+ merged_trainer.update(caller_overrides)
+ state["trainer"] = merged_trainer
+ trainer = _trainer(state)
+ # A Trainer-only skill invocation still needs the current round's
+ # generated artifact. Read upstream task sections without mutating them.
+ _merge_optional_task_section(
+ state,
+ "constructor",
+ str(explicit_task_id),
+ str(db_path) if db_path else None,
+ )
+ _merge_optional_task_section(
+ state,
+ "obtainer",
+ str(explicit_task_id),
+ str(db_path) if db_path else None,
+ )
if db_path:
state["DB_PATH"] = str(db_path)
task_state_loaded = True
@@ -458,6 +615,64 @@ def resolve_trainer_runtime_config(
os.getenv("TRAIN_DATASET_PATH"),
trainer.get("train_input_dataset_path"),
)
+ constructor_mapping = _upstream_mapping_results(state, "constructor")
+ obtainer_mapping = _upstream_mapping_results(state, "obtainer")
+ generated_dataset_path = _first_non_empty(
+ constructor_mapping.get("output_file"),
+ obtainer_mapping.get("output_file"),
+ )
+ generated_dataset_origin = (
+ "constructor"
+ if constructor_mapping.get("output_file")
+ else "obtainer" if obtainer_mapping.get("output_file") else ""
+ )
+ requested_verl_source = _first_non_empty(
+ kwargs.get("verl_source_dataset_path"),
+ os.getenv("VERL_SOURCE_DATASET_PATH"),
+ )
+ persisted_verl_source = trainer.get("verl_source_dataset_path")
+ persisted_source_origin = str(trainer.get("verl_source_dataset_origin") or "").strip().lower()
+ # Only kwargs/environment values are explicit for *this* invocation. A
+ # task-state path marked ``user`` may simply be the previous round's seed
+ # dataset and must not permanently mask the current Constructor output.
+ explicit_verl_source = requested_verl_source
+ selected_verl_source = _first_non_empty(
+ explicit_verl_source,
+ generated_dataset_path,
+ persisted_verl_source,
+ (
+ trainer.get("train_input_dataset_path")
+ if str(trainer.get("train_input_dataset_path") or "").lower().endswith(
+ (".json", ".jsonl")
+ )
+ else None
+ ),
+ )
+ trainer["_verl_source_dataset_explicit"] = bool(explicit_verl_source)
+ trainer["verl_source_dataset_path"] = selected_verl_source
+ if explicit_verl_source:
+ trainer["verl_source_dataset_origin"] = "user"
+ elif generated_dataset_path:
+ trainer["verl_source_dataset_origin"] = generated_dataset_origin
+ elif persisted_verl_source:
+ trainer["verl_source_dataset_origin"] = persisted_source_origin or "user"
+ source_replaced_by_upstream = bool(
+ trainer.get("train_framework") == "verl"
+ and generated_dataset_path
+ and not explicit_verl_source
+ and str(generated_dataset_path) != str(persisted_verl_source or "")
+ )
+ trainer["_verl_source_dataset_replaced"] = source_replaced_by_upstream
+ if source_replaced_by_upstream:
+ # These fields describe Parquet derived from the previous source. The
+ # data builder will repopulate them from the new upstream artifact.
+ trainer["train_input_dataset_path"] = ""
+ trainer.pop("verl_data_manifest_path", None)
+ trainer.pop("verl_data_prepare_result", None)
+ if trainer.get("train_framework") == "verl" and not trainer.get("train_input_dataset_path"):
+ # The graph's generic required-field gate still expects this alias.
+ # data_check_node replaces it with the version-scoped prepared Parquet.
+ trainer["train_input_dataset_path"] = trainer.get("verl_source_dataset_path")
trainer["train_input_model_name"] = _first_non_empty(
kwargs.get("train_input_model_name"),
kwargs.get("model_path"),
@@ -476,6 +691,52 @@ def resolve_trainer_runtime_config(
os.getenv("TRAIN_EVAL_DATASET_PATH"),
trainer.get("train_input_eval_dataset_path"),
)
+ trainer["verl_source_eval_dataset_path"] = _first_non_empty(
+ kwargs.get("verl_source_eval_dataset_path"),
+ os.getenv("VERL_SOURCE_EVAL_DATASET_PATH"),
+ caller_trainer_config.get("verl_source_eval_dataset_path"),
+ trainer.get("verl_source_eval_dataset_path"),
+ )
+ trainer["verl_data_adapter"] = str(_first_non_empty(
+ kwargs.get("verl_data_adapter"),
+ os.getenv("VERL_DATA_ADAPTER"),
+ trainer.get("verl_data_adapter"),
+ "auto",
+ )).strip().lower()
+ if trainer["verl_data_adapter"] not in {"auto", "native", "messages", "alpaca", "qa"}:
+ raise ValueError("verl_data_adapter must be auto, native, messages, alpaca, or qa")
+ trainer["verl_data_source"] = str(_first_non_empty(
+ kwargs.get("verl_data_source"),
+ os.getenv("VERL_DATA_SOURCE"),
+ trainer.get("verl_data_source"),
+ "",
+ ) or "").strip()
+ trainer["verl_validation_ratio"] = _as_float(
+ _first_non_empty(
+ kwargs.get("verl_validation_ratio"),
+ os.getenv("VERL_VALIDATION_RATIO"),
+ trainer.get("verl_validation_ratio"),
+ ),
+ default=0.05,
+ )
+ if not 0.0 < trainer["verl_validation_ratio"] < 1.0:
+ raise ValueError("verl_validation_ratio must be between 0 and 1")
+ trainer["verl_split_seed"] = _as_int(
+ _first_non_empty(
+ kwargs.get("verl_split_seed"),
+ os.getenv("VERL_SPLIT_SEED"),
+ trainer.get("verl_split_seed"),
+ ),
+ default=42,
+ )
+ trainer["verl_reuse_previous_validation"] = _as_bool(
+ _first_non_empty(
+ kwargs.get("verl_reuse_previous_validation"),
+ os.getenv("VERL_REUSE_PREVIOUS_VALIDATION"),
+ trainer.get("verl_reuse_previous_validation"),
+ ),
+ default=True,
+ )
trainer["train_input_config_template_path"] = _first_non_empty(
kwargs.get("train_input_config_template_path"),
kwargs.get("config_template_path"),
@@ -551,24 +812,60 @@ def resolve_trainer_runtime_config(
trainer.get("verl_reward_function_name"),
"compute_score",
))
- raw_reward_mode = _first_non_empty(
+ requested_reward_mode = _first_non_empty(
kwargs.get("verl_reward_mode"),
os.getenv("VERL_REWARD_MODE"),
+ )
+ requested_reward_preset = _first_non_empty(
+ kwargs.get("verl_reward_preset"),
+ os.getenv("VERL_REWARD_PRESET"),
+ )
+ explicit_reward_override = any(
+ _has_merge_value(value)
+ for value in (
+ requested_reward_mode,
+ requested_reward_preset,
+ kwargs.get("verl_reward_function_path"),
+ os.getenv("VERL_REWARD_FUNCTION_PATH"),
+ )
+ )
+ raw_reward_mode = _first_non_empty(
+ requested_reward_mode,
trainer.get("verl_reward_mode"),
)
# A path without the new mode field is the legacy custom-reward contract.
if not raw_reward_mode:
raw_reward_mode = "custom" if trainer.get("verl_reward_function_path") else "auto"
+ persisted_reward_origin = str(trainer.get("verl_reward_origin") or "").strip().lower()
+ previous_recommendation = trainer.get("verl_reward_recommendation") or {}
+ if explicit_reward_override:
+ reward_origin = "auto" if str(raw_reward_mode).strip().lower() == "auto" else "user"
+ elif persisted_reward_origin in {"auto", "user"}:
+ reward_origin = persisted_reward_origin
+ elif (
+ isinstance(previous_recommendation, dict)
+ and previous_recommendation.get("source") == "trainer_generated_data_inference"
+ ):
+ reward_origin = "auto"
+ else:
+ reward_origin = "auto" if str(raw_reward_mode).strip().lower() == "auto" else "user"
+
+ # An automatically selected preset belongs to the previous source. Reset
+ # it before adapting a newly generated dataset so reward inference runs
+ # again. User-selected preset/custom rewards remain untouched.
+ if source_replaced_by_upstream and reward_origin == "auto" and not explicit_reward_override:
+ raw_reward_mode = "auto"
+ trainer["verl_reward_preset"] = "auto"
trainer["verl_reward_mode"] = str(raw_reward_mode).strip().lower()
if trainer["verl_reward_mode"] not in {"auto", "preset", "custom"}:
raise ValueError("verl_reward_mode must be auto, preset, or custom")
+ trainer["verl_reward_origin"] = reward_origin
if trainer["verl_reward_mode"] == "auto":
trainer["verl_reward_preset"] = "auto"
elif trainer["verl_reward_mode"] == "preset":
trainer["verl_reward_preset"] = normalize_reward_preset(_first_non_empty(
- kwargs.get("verl_reward_preset"),
- os.getenv("VERL_REWARD_PRESET"),
+ requested_reward_preset,
trainer.get("verl_reward_preset"),
"auto",
))
@@ -623,6 +920,30 @@ def resolve_trainer_runtime_config(
),
default=True,
)
+ trainer["verl_inherit_previous_config"] = _as_bool(
+ _first_non_empty(
+ kwargs.get("verl_inherit_previous_config"),
+ os.getenv("VERL_INHERIT_PREVIOUS_CONFIG"),
+ trainer.get("verl_inherit_previous_config"),
+ ),
+ default=True,
+ )
+ trainer["verl_use_previous_best_model"] = _as_bool(
+ _first_non_empty(
+ kwargs.get("verl_use_previous_best_model"),
+ os.getenv("VERL_USE_PREVIOUS_BEST_MODEL"),
+ trainer.get("verl_use_previous_best_model"),
+ ),
+ default=True,
+ )
+ trainer["verl_multi_round_enabled"] = _as_bool(
+ _first_non_empty(
+ kwargs.get("verl_multi_round_enabled"),
+ os.getenv("VERL_MULTI_ROUND_ENABLED"),
+ trainer.get("verl_multi_round_enabled"),
+ ),
+ default=True,
+ )
prefill_guide = build_trainer_prefill_guide(
state,
task_type=trainer["train_stage"],
@@ -642,13 +963,20 @@ def resolve_trainer_runtime_config(
def get_missing_trainer_fields(state: Dict[str, Any]) -> list[str]:
trainer = state.get("trainer") or {}
- missing = [field for field in _REQUIRED_TRAINER_FIELDS if not trainer.get(field)]
+ missing = [
+ field
+ for field in _REQUIRED_TRAINER_FIELDS
+ if field != "train_input_dataset_path" and not trainer.get(field)
+ ]
+ if not (
+ trainer.get("train_input_dataset_path")
+ or trainer.get("verl_source_dataset_path")
+ ):
+ missing.append("train_input_dataset_path")
if trainer.get("train_framework") == "llamafactory" and not trainer.get("llamafactory_dir"):
missing.append("llamafactory_dir")
if trainer.get("train_framework") == "verl" and not trainer.get("verl_dir"):
missing.append("verl_dir")
- if trainer.get("train_framework") == "verl" and not trainer.get("train_input_eval_dataset_path"):
- missing.append("train_input_eval_dataset_path")
if (
trainer.get("train_framework") == "verl"
and trainer.get("verl_reward_mode") == "custom"
diff --git a/loopai/skills/Trainer/utils/verl_config_generator.py b/loopai/skills/Trainer/utils/verl_config_generator.py
index c8e72a0..7cb904c 100644
--- a/loopai/skills/Trainer/utils/verl_config_generator.py
+++ b/loopai/skills/Trainer/utils/verl_config_generator.py
@@ -32,11 +32,25 @@ def generate_verl_grpo_config(state: Dict[str, Any], template_path: str) -> Dict
if not isinstance(raw, dict):
raise ValueError(f"Verl GRPO template must contain a YAML mapping: {path}")
- config = copy.deepcopy(raw)
- if config.get("framework") != "verl" or config.get("stage") != "grpo":
+ if raw.get("framework") != "verl" or raw.get("stage") != "grpo":
raise ValueError("Verl template must declare framework: verl and stage: grpo")
trainer = state.get("trainer") or {}
+ previous_config = trainer.get("_trainer_previous_config")
+ inherit_previous = bool(trainer.get("verl_inherit_previous_config", True))
+ if (
+ inherit_previous
+ and isinstance(previous_config, dict)
+ and previous_config.get("framework") == "verl"
+ and previous_config.get("stage") == "grpo"
+ ):
+ # Previous YAML has already crossed the user approval boundary. Use it
+ # as the hyperparameter baseline, then replace every round-scoped field.
+ config = copy.deepcopy(previous_config)
+ inherited = True
+ else:
+ config = copy.deepcopy(raw)
+ inherited = False
run_dir = Path(str(trainer.get("trainer_output_dir") or trainer.get("output_dir") or "./outputs")).resolve()
version_id = str(trainer.get("trainer_version_id") or run_dir.name)
train_value = trainer.get("train_input_dataset_path")
@@ -99,6 +113,7 @@ def generate_verl_grpo_config(state: Dict[str, Any], template_path: str) -> Dict
loopai_reward = config.setdefault("loopai_reward", {})
loopai_reward["mode"] = reward_mode
+ loopai_reward["origin"] = str(trainer.get("verl_reward_origin") or "user")
loopai_reward["preset"] = reward_preset or None
loopai_reward["function_path"] = resolved_reward_path
loopai_reward["function_name"] = reward_function_name
@@ -108,6 +123,30 @@ def generate_verl_grpo_config(state: Dict[str, Any], template_path: str) -> Dict
trainer.get("verl_selection_metric") or "val-core/*/acc/mean@*"
)
result["selection_mode"] = str(trainer.get("verl_selection_mode") or "max")
+ if trainer.get("verl_multi_round_enabled", False):
+ # A later round can only consume an exported Hugging Face model. Smoke
+ # templates used to disable both checkpointing and export, so make the
+ # multi-round contract explicit in the generated YAML shown to the user.
+ result["export_huggingface"] = True
+ try:
+ save_freq = int(overrides.get("trainer.save_freq", -1))
+ except (TypeError, ValueError):
+ save_freq = -1
+ if save_freq <= 0:
+ try:
+ test_freq = int(overrides.get("trainer.test_freq", 1))
+ except (TypeError, ValueError):
+ test_freq = 1
+ overrides["trainer.save_freq"] = max(1, test_freq)
+
+ config["loopai_round"] = {
+ "round_index": int(trainer.get("trainer_round_index") or 1),
+ "version_id": version_id,
+ "parent_version_id": trainer.get("trainer_parent_version_id") or None,
+ "inherited_previous_config": inherited,
+ "data_manifest_path": trainer.get("verl_data_manifest_path") or None,
+ "model_inheritance": copy.deepcopy(trainer.get("trainer_model_inheritance") or {}),
+ }
config = strip_retired_tracking_config(config)
config.setdefault("overrides", {})["trainer.logger"] = ["console", "file"]
assert_no_retired_tracking(config)
@@ -128,6 +167,7 @@ def generate_verl_config_explanation(config: Dict[str, Any]) -> str:
overrides = config.get("overrides") or {}
result = config.get("result") or {}
loopai_reward = config.get("loopai_reward") or {}
+ loopai_round = config.get("loopai_round") or {}
lines = [
"Verl GRPO configuration",
f"- entrypoint: {config.get('entrypoint')}",
@@ -142,5 +182,8 @@ def generate_verl_config_explanation(config: Dict[str, Any]) -> str:
f"- reward preset: {loopai_reward.get('preset') or 'custom'}",
f"- reward function: {overrides.get('reward.custom_reward_function.path')}",
f"- selection metric: {result.get('selection_metric')} ({result.get('selection_mode')})",
+ f"- round: {loopai_round.get('round_index')} (parent={loopai_round.get('parent_version_id')})",
+ f"- inherited previous config: {loopai_round.get('inherited_previous_config')}",
+ f"- data manifest: {loopai_round.get('data_manifest_path')}",
]
return "\n".join(lines) + "\n"
diff --git a/loopai/skills/Trainer/utils/verl_dataset_builder.py b/loopai/skills/Trainer/utils/verl_dataset_builder.py
new file mode 100644
index 0000000..fce305d
--- /dev/null
+++ b/loopai/skills/Trainer/utils/verl_dataset_builder.py
@@ -0,0 +1,1028 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import math
+import random
+import re
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Sequence, Tuple
+
+from loopai.skills.Trainer.rewards import (
+ is_verl_builtin_data_source,
+ normalize_reward_preset,
+)
+
+
+_REQUIRED_COLUMNS = {"prompt", "data_source", "reward_model"}
+_SUPPORTED_SOURCE_SUFFIXES = {".json", ".jsonl", ".parquet"}
+_ADAPTERS = {"auto", "native", "messages", "alpaca", "qa"}
+_ANSWER_FIELDS = (
+ "ground_truth",
+ "answer",
+ "target",
+ "label",
+ "solution",
+ "reference_answer",
+ "reference",
+ "output",
+ "response",
+ "completion",
+)
+_RESPONSE_DERIVED_FIELDS = {"output", "response", "completion", "assistant"}
+_ROLE_ALIASES = {
+ "human": "user",
+ "user": "user",
+ "gpt": "assistant",
+ "assistant": "assistant",
+ "bot": "assistant",
+ "system": "system",
+ "tool": "tool",
+}
+_BOXED_START = re.compile(r"\\boxed\s*\{")
+_GSM8K_ANSWER = re.compile(r"####\s*([^\n]+)")
+_ANSWER_TAG = re.compile(r"\s*(.*?)\s*", re.IGNORECASE | re.DOTALL)
+_FINAL_ANSWER = re.compile(
+ r"(?:final\s+answer|answer|最终答案|答案)\s*[::]\s*([^\n]+)",
+ re.IGNORECASE,
+)
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _json_text(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+
+
+def _write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_name(f".{path.name}.tmp")
+ temp_path.write_text(
+ json.dumps(value, ensure_ascii=False, indent=2, default=str),
+ encoding="utf-8",
+ )
+ temp_path.replace(path)
+
+
+def _write_rejections(path: Path, rejected: Sequence[Dict[str, Any]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_name(f".{path.name}.tmp")
+ with temp_path.open("w", encoding="utf-8") as stream:
+ for item in rejected:
+ stream.write(json.dumps(item, ensure_ascii=False, default=str) + "\n")
+ temp_path.replace(path)
+
+
+def _require_pyarrow():
+ try:
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+ except ImportError as exc: # pragma: no cover - exercised in minimal deployments.
+ raise RuntimeError(
+ "Trainer needs pyarrow to convert generated JSON/JSONL data into Verl Parquet"
+ ) from exc
+ return pa, pq
+
+
+def _is_native_verl_parquet(path_value: Any) -> bool:
+ if not path_value:
+ return False
+ path = Path(str(path_value)).expanduser().resolve()
+ if not path.is_file() or path.suffix.lower() != ".parquet":
+ return False
+ try:
+ _, pq = _require_pyarrow()
+ return _REQUIRED_COLUMNS.issubset(pq.ParquetFile(path).schema_arrow.names)
+ except Exception:
+ return False
+
+
+def _source_files(path_value: str) -> List[Path]:
+ path = Path(path_value).expanduser().resolve()
+ if path.is_file():
+ if path.suffix.lower() not in _SUPPORTED_SOURCE_SUFFIXES:
+ raise ValueError(
+ f"unsupported Verl source file: {path}; expected JSON, JSONL, or Parquet"
+ )
+ return [path]
+ if path.is_dir():
+ files = sorted(
+ item
+ for item in path.rglob("*")
+ if item.is_file() and item.suffix.lower() in _SUPPORTED_SOURCE_SUFFIXES
+ )
+ if files:
+ return files
+ raise ValueError(f"no JSON, JSONL, or Parquet files found under: {path}")
+ raise FileNotFoundError(f"Verl source dataset does not exist: {path}")
+
+
+def _record_item(record: Dict[str, Any], path: Path, index: int) -> Dict[str, Any]:
+ return {
+ "record": record,
+ "source_path": str(path),
+ "source_index": index,
+ }
+
+
+def _load_source_records(path_value: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
+ records: List[Dict[str, Any]] = []
+ rejected: List[Dict[str, Any]] = []
+ files = _source_files(path_value)
+ _, pq = _require_pyarrow()
+
+ for path in files:
+ suffix = path.suffix.lower()
+ if suffix == ".parquet":
+ try:
+ rows = pq.read_table(path).to_pylist()
+ except Exception as exc:
+ raise ValueError(f"unable to read source Parquet {path}: {exc}") from exc
+ for index, row in enumerate(rows):
+ if isinstance(row, dict):
+ records.append(_record_item(row, path, index))
+ else:
+ rejected.append({
+ "source_path": str(path),
+ "source_index": index,
+ "error": "record is not a mapping",
+ })
+ continue
+
+ if suffix == ".jsonl":
+ with path.open("r", encoding="utf-8") as stream:
+ for index, line in enumerate(stream):
+ if not line.strip():
+ continue
+ try:
+ row = json.loads(line)
+ except json.JSONDecodeError as exc:
+ rejected.append({
+ "source_path": str(path),
+ "source_index": index,
+ "error": f"invalid JSON: {exc}",
+ })
+ continue
+ if isinstance(row, dict):
+ records.append(_record_item(row, path, index))
+ else:
+ rejected.append({
+ "source_path": str(path),
+ "source_index": index,
+ "error": "record is not a mapping",
+ })
+ continue
+
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid JSON source {path}: {exc}") from exc
+ if isinstance(payload, list):
+ rows = payload
+ elif isinstance(payload, dict):
+ rows = next(
+ (
+ payload[key]
+ for key in ("data", "records", "items", "examples")
+ if isinstance(payload.get(key), list)
+ ),
+ [payload],
+ )
+ else:
+ rows = []
+ rejected.append({
+ "source_path": str(path),
+ "source_index": 0,
+ "error": "JSON root must be a mapping or list",
+ })
+ for index, row in enumerate(rows):
+ if isinstance(row, dict):
+ records.append(_record_item(row, path, index))
+ else:
+ rejected.append({
+ "source_path": str(path),
+ "source_index": index,
+ "error": "record is not a mapping",
+ })
+
+ return records, rejected, [str(path) for path in files]
+
+
+def _normalize_message(message: Any) -> Dict[str, str] | None:
+ if not isinstance(message, dict):
+ return None
+ raw_role = message.get("role", message.get("from"))
+ raw_content = message.get("content", message.get("value"))
+ role = _ROLE_ALIASES.get(str(raw_role or "").strip().lower())
+ if role is None or raw_content is None:
+ return None
+ if isinstance(raw_content, list):
+ content = "\n".join(str(item) for item in raw_content if item is not None)
+ else:
+ content = str(raw_content)
+ content = content.strip()
+ if not content:
+ return None
+ return {"role": role, "content": content}
+
+
+def _normalize_messages(value: Any) -> List[Dict[str, str]]:
+ if not isinstance(value, list):
+ return []
+ return [item for item in (_normalize_message(message) for message in value) if item]
+
+
+def _last_boxed_value(text: str) -> str | None:
+ matches = list(_BOXED_START.finditer(text))
+ for match in reversed(matches):
+ start = match.end()
+ depth = 1
+ for index in range(start, len(text)):
+ if text[index] == "{":
+ depth += 1
+ elif text[index] == "}":
+ depth -= 1
+ if depth == 0:
+ value = text[start:index].strip()
+ return value or None
+ return None
+
+
+def _concise_answer(text: str) -> str | None:
+ value = text.strip()
+ if not value or "\n" in value or len(value) > 128:
+ return None
+ if len(value.split()) > 20:
+ return None
+ return value
+
+
+def _unwrap_answer(value: Any) -> Any:
+ if isinstance(value, dict):
+ for key in ("target", "answer", "ground_truth", "value"):
+ if key in value:
+ return value[key]
+ return value
+
+
+def _normalize_ground_truth(value: Any, preset: str, *, response_derived: bool) -> Any:
+ value = _unwrap_answer(value)
+ if value is None:
+ raise ValueError("missing reference answer/ground truth")
+
+ if preset == "qa_exact_match":
+ targets = value if isinstance(value, (list, tuple, set)) else [value]
+ normalized = [str(item).strip() for item in targets if str(item).strip()]
+ if not normalized:
+ raise ValueError("QA ground truth is empty")
+ return {"target": normalized}
+
+ if isinstance(value, (list, tuple, set)):
+ values = [item for item in value if item is not None and str(item).strip()]
+ if len(values) != 1:
+ raise ValueError("generated math data requires exactly one reference answer")
+ value = values[0]
+ if isinstance(value, dict):
+ if response_derived:
+ raise ValueError("assistant response cannot be converted to a scalar ground truth")
+ return copy.deepcopy(value)
+
+ text = str(value).strip()
+ if not text:
+ raise ValueError("reference answer/ground truth is empty")
+
+ boxed = _last_boxed_value(text)
+ if boxed is not None:
+ return boxed
+ gsm_match = _GSM8K_ANSWER.search(text)
+ if gsm_match:
+ return gsm_match.group(1).strip()
+ answer_match = _ANSWER_TAG.search(text)
+ if answer_match:
+ return answer_match.group(1).strip()
+ final_matches = list(_FINAL_ANSWER.finditer(text))
+ if final_matches:
+ return final_matches[-1].group(1).strip()
+ if "" in text.lower():
+ tail = re.split(r"", text, flags=re.IGNORECASE)[-1].strip()
+ concise_tail = _concise_answer(tail)
+ if concise_tail:
+ return concise_tail
+
+ if response_derived and preset in {
+ "gsm8k_exact",
+ "math_boxed",
+ "math_dapo",
+ "prime_math",
+ "geometry",
+ }:
+ concise = _concise_answer(text)
+ if concise is None:
+ raise ValueError(
+ "assistant response contains reasoning but no reliable final-answer marker "
+ "(\\boxed{...}, ####, or ...)"
+ )
+ return concise
+ return text
+
+
+def _answer_from_record(record: Dict[str, Any]) -> Tuple[Any, str]:
+ reward_model = record.get("reward_model")
+ if isinstance(reward_model, dict) and reward_model.get("ground_truth") not in (None, ""):
+ return reward_model["ground_truth"], "reward_model.ground_truth"
+ for field in _ANSWER_FIELDS:
+ if record.get(field) not in (None, ""):
+ return record[field], field
+ raise ValueError("record has no answer/target/ground_truth/output field")
+
+
+def _detect_adapter(record: Dict[str, Any], requested: str) -> str:
+ if requested != "auto":
+ return requested
+ if (
+ isinstance(record.get("prompt"), list)
+ and record.get("data_source")
+ and isinstance(record.get("reward_model"), dict)
+ ):
+ return "native"
+ if any(
+ isinstance(record.get(field), list)
+ for field in ("messages", "conversation", "conversations")
+ ):
+ return "messages"
+ if "instruction" in record and any(field in record for field in _ANSWER_FIELDS):
+ return "alpaca"
+ if any(key in record for key in ("question", "query", "problem", "prompt")):
+ return "qa"
+ raise ValueError("unable to detect data adapter; set verl_data_adapter explicitly")
+
+
+def _prompt_suffix(preset: str) -> str:
+ if preset == "gsm8k_exact":
+ return 'End the response with "#### ".'
+ if preset == "qa_exact_match":
+ return "Put only the final answer inside ...."
+ if preset in {"math_boxed", "math_dapo", "prime_math", "geometry"}:
+ return "Put the final answer inside \\boxed{...}."
+ return ""
+
+
+def _ensure_reward_format(prompt: List[Dict[str, str]], preset: str) -> List[Dict[str, str]]:
+ suffix = _prompt_suffix(preset)
+ if not suffix:
+ return prompt
+ result = copy.deepcopy(prompt)
+ for index in range(len(result) - 1, -1, -1):
+ if result[index].get("role") != "user":
+ continue
+ content = str(result[index].get("content") or "")
+ markers = ("\\boxed", "####", "")
+ if not any(marker.lower() in content.lower() for marker in markers):
+ result[index]["content"] = content.rstrip() + "\n\n" + suffix
+ break
+ return result
+
+
+def _metadata_json(record: Dict[str, Any]) -> str:
+ metadata: Dict[str, Any] = {}
+ for field in (
+ "meta",
+ "metadata",
+ "extra_info",
+ "id",
+ "uuid",
+ "category",
+ "dataset",
+ "data_source",
+ "ability",
+ ):
+ if field in record:
+ metadata[field] = record[field]
+ return _json_text(metadata)
+
+
+def _adapt_record(
+ item: Dict[str, Any],
+ *,
+ requested_adapter: str,
+ reward_preset: str,
+ reward_mode: str,
+ configured_data_source: str,
+) -> Tuple[Dict[str, Any], str]:
+ record = item["record"]
+ adapter = _detect_adapter(record, requested_adapter)
+ answer_value: Any
+ answer_field: str
+
+ if adapter == "native":
+ prompt = _normalize_messages(record.get("prompt"))
+ if not prompt:
+ raise ValueError("native prompt must be a non-empty chat message list")
+ answer_value, answer_field = _answer_from_record(record)
+ elif adapter == "messages":
+ messages = _normalize_messages(
+ record.get("messages")
+ or record.get("conversation")
+ or record.get("conversations")
+ )
+ assistant_index = next(
+ (index for index in range(len(messages) - 1, -1, -1) if messages[index]["role"] == "assistant"),
+ -1,
+ )
+ if assistant_index <= 0:
+ raise ValueError("messages data requires a final assistant reference after a prompt")
+ prompt = messages[:assistant_index]
+ if not any(message["role"] == "user" for message in prompt):
+ raise ValueError("messages prompt has no user message")
+ try:
+ answer_value, answer_field = _answer_from_record(record)
+ except ValueError:
+ answer_value = messages[assistant_index]["content"]
+ answer_field = "assistant"
+ elif adapter == "alpaca":
+ instruction = str(record.get("instruction") or "").strip()
+ context = str(record.get("input") or "").strip()
+ if not instruction:
+ raise ValueError("Alpaca record requires a non-empty instruction")
+ content = instruction
+ if context:
+ content += "\n\nInput:\n" + context
+ prompt = [{"role": "user", "content": content}]
+ answer_value, answer_field = _answer_from_record(record)
+ else:
+ raw_prompt = next(
+ (record.get(key) for key in ("question", "query", "problem", "prompt") if record.get(key)),
+ None,
+ )
+ if isinstance(raw_prompt, list):
+ prompt = _normalize_messages(raw_prompt)
+ else:
+ prompt = [{"role": "user", "content": str(raw_prompt or "").strip()}]
+ if not prompt or not prompt[-1].get("content"):
+ raise ValueError("QA record requires a non-empty question/query/problem/prompt")
+ answer_value, answer_field = _answer_from_record(record)
+
+ ground_truth = _normalize_ground_truth(
+ answer_value,
+ reward_preset,
+ response_derived=answer_field in _RESPONSE_DERIVED_FIELDS,
+ )
+ prompt = _ensure_reward_format(prompt, reward_preset)
+ if not prompt:
+ raise ValueError("adapted prompt is empty")
+
+ original_source = str(record.get("data_source") or "").strip()
+ if configured_data_source:
+ data_source = configured_data_source
+ elif reward_mode == "auto" and original_source:
+ data_source = original_source
+ elif reward_mode == "custom":
+ data_source = original_source or "loopai/custom"
+ elif reward_preset not in {"auto", "verl_builtin", "custom"}:
+ data_source = f"loopai/{reward_preset}"
+ else:
+ data_source = original_source
+ if not data_source:
+ raise ValueError("adapted record has no data_source; choose a named reward preset")
+
+ source_record_id = str(
+ record.get("id")
+ or record.get("uuid")
+ or f"{Path(item['source_path']).name}:{item['source_index']}"
+ )
+ reward_model = {"style": "rule", "ground_truth": ground_truth}
+ if adapter == "native" and isinstance(record.get("reward_model"), dict):
+ reward_model.update({
+ key: copy.deepcopy(value)
+ for key, value in record["reward_model"].items()
+ if key != "ground_truth"
+ })
+ reward_model["ground_truth"] = ground_truth
+ result = {
+ "prompt": prompt,
+ "data_source": data_source,
+ "reward_model": reward_model,
+ "extra_info": {
+ "split": "",
+ "index": 0,
+ "source_path": item["source_path"],
+ "source_record_id": source_record_id,
+ "metadata_json": _metadata_json(record),
+ },
+ }
+ return result, adapter
+
+
+def _candidate_text(
+ state: Dict[str, Any],
+ records: Sequence[Dict[str, Any]],
+ source_paths: Iterable[str],
+) -> str:
+ trainer = state.get("trainer") or {}
+ parts: List[str] = [
+ str(trainer.get("train_input_task_description") or ""),
+ *(str(path) for path in source_paths),
+ ]
+ for item in records[:50]:
+ record = item.get("record") or {}
+ for key in ("data_source", "dataset", "source", "category", "task", "domain"):
+ if record.get(key):
+ parts.append(str(record[key]))
+ metadata = record.get("meta") or record.get("metadata")
+ if isinstance(metadata, dict):
+ parts.extend(str(value) for value in metadata.values())
+ return " ".join(parts).lower()
+
+
+def _infer_reward_preset(
+ state: Dict[str, Any],
+ records: Sequence[Dict[str, Any]],
+ source_paths: Sequence[str],
+) -> Tuple[str, str]:
+ text = _candidate_text(state, records, source_paths)
+ rules = (
+ ("gsm8k_exact", ("gsm8k",)),
+ ("geometry", ("geometry3k", "geo3k", "geometry", "几何")),
+ ("math_dapo", ("math_dapo", "dapo", "aime")),
+ ("prime_math", ("numina", "prime_math", "prime math")),
+ (
+ "qa_exact_match",
+ (
+ "searchr1",
+ "search_r1",
+ "triviaqa",
+ "hotpotqa",
+ "natural questions",
+ "exact match qa",
+ "问答",
+ ),
+ ),
+ (
+ "math_boxed",
+ ("math-500", "lighteval/math", "mathematics", "math", "algebra", "arithmetic", "数学", "代数", "算术"),
+ ),
+ )
+ for preset, keywords in rules:
+ matched = next((keyword for keyword in keywords if keyword in text), None)
+ if matched:
+ return preset, f"matched dataset/task keyword: {matched}"
+
+ sampled_outputs: List[str] = []
+ for item in records[:100]:
+ record = item.get("record") or {}
+ for field in ("output", "response", "completion", "answer", "solution"):
+ if record.get(field) is not None:
+ sampled_outputs.append(str(record[field]))
+ break
+ if any(_GSM8K_ANSWER.search(value) for value in sampled_outputs):
+ return "gsm8k_exact", "detected #### final-answer markers"
+ if any(_last_boxed_value(value) is not None for value in sampled_outputs):
+ return "math_boxed", "detected \\boxed{...} final-answer markers"
+ if any(_ANSWER_TAG.search(value) for value in sampled_outputs):
+ return "qa_exact_match", "detected ... markers"
+
+ raise ValueError(
+ "Trainer cannot safely infer a reward for generated Verl data. "
+ "Set verl_reward_mode=preset with verl_reward_preset, or use custom reward mode."
+ )
+
+
+def _all_builtin_sources(records: Sequence[Dict[str, Any]]) -> bool:
+ sources = {
+ str((item.get("record") or {}).get("data_source") or "").strip()
+ for item in records
+ }
+ return bool(sources) and "" not in sources and all(
+ is_verl_builtin_data_source(source) for source in sources
+ )
+
+
+def _resolve_reward(
+ state: Dict[str, Any],
+ records: Sequence[Dict[str, Any]],
+ source_paths: Sequence[str],
+ *,
+ source_is_native: bool,
+) -> Tuple[str, str, Dict[str, Any]]:
+ trainer = state.setdefault("trainer", {})
+ mode = str(trainer.get("verl_reward_mode") or "auto").strip().lower()
+ origin = str(trainer.get("verl_reward_origin") or "").strip().lower()
+ if origin not in {"auto", "user"}:
+ origin = "auto" if mode == "auto" else "user"
+ # ``preset`` with origin=auto is a recommendation produced for an older
+ # generated dataset. Re-enter auto mode so the current records determine
+ # the reward contract.
+ if origin == "auto" and mode == "preset":
+ mode = "auto"
+ trainer["verl_reward_mode"] = "auto"
+ trainer["verl_reward_preset"] = "auto"
+ trainer["verl_reward_origin"] = origin
+ if mode == "custom":
+ trainer["verl_reward_origin"] = "user"
+ return "custom", "custom", {
+ "applied": False,
+ "mode": "custom",
+ "reason": "user supplied a custom reward",
+ }
+ if mode == "preset":
+ preset = normalize_reward_preset(trainer.get("verl_reward_preset"))
+ if preset in {"auto", "verl_builtin"} and not source_is_native and not _all_builtin_sources(records):
+ raise ValueError(
+ "Generated data has no Verl-supported data_source for the built-in reward router; "
+ "choose a named verl_reward_preset or a custom reward."
+ )
+ trainer["verl_reward_origin"] = "user"
+ return mode, preset, {
+ "applied": False,
+ "mode": mode,
+ "preset": preset,
+ "reason": "user-selected reward preset",
+ }
+ if mode != "auto":
+ raise ValueError("verl_reward_mode must be auto, preset, or custom")
+
+ if source_is_native or _all_builtin_sources(records):
+ trainer["verl_reward_mode"] = "auto"
+ trainer["verl_reward_preset"] = "auto"
+ trainer["verl_reward_origin"] = "auto"
+ return "auto", "auto", {
+ "applied": False,
+ "mode": "auto",
+ "preset": "auto",
+ "reason": "native Verl data_source routing",
+ }
+
+ preset, reason = _infer_reward_preset(state, records, source_paths)
+ trainer["verl_reward_mode"] = "preset"
+ trainer["verl_reward_preset"] = preset
+ trainer["verl_reward_origin"] = "auto"
+ recommendation = {
+ "applied": True,
+ "mode": "preset",
+ "preset": preset,
+ "reason": reason,
+ "source": "trainer_generated_data_inference",
+ }
+ trainer["verl_reward_recommendation"] = recommendation
+ return "preset", preset, recommendation
+
+
+def _reward_contract_signature(reward: Dict[str, Any]) -> Tuple[str, str, str, str]:
+ mode = str(reward.get("mode") or "auto").strip().lower()
+ return (
+ mode,
+ str(reward.get("preset") or ""),
+ str(reward.get("function_path") or "") if mode == "custom" else "",
+ str(reward.get("function_name") or "compute_score") if mode == "custom" else "",
+ )
+
+
+def _record_fingerprint(record: Dict[str, Any]) -> str:
+ return hashlib.sha256(
+ _json_text({
+ "prompt": record.get("prompt"),
+ "data_source": record.get("data_source"),
+ "ground_truth": (record.get("reward_model") or {}).get("ground_truth"),
+ }).encode("utf-8")
+ ).hexdigest()
+
+
+def _deduplicate(records: Sequence[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
+ seen = set()
+ result: List[Dict[str, Any]] = []
+ duplicates = 0
+ for record in records:
+ key = _record_fingerprint(record)
+ if key in seen:
+ duplicates += 1
+ continue
+ seen.add(key)
+ result.append(record)
+ return result, duplicates
+
+
+def _assign_split(records: Sequence[Dict[str, Any]], split: str) -> List[Dict[str, Any]]:
+ result: List[Dict[str, Any]] = []
+ for index, record in enumerate(records):
+ item = copy.deepcopy(record)
+ item.setdefault("extra_info", {})["split"] = split
+ item["extra_info"]["index"] = index
+ result.append(item)
+ return result
+
+
+def _split_records(
+ records: Sequence[Dict[str, Any]],
+ *,
+ ratio: float,
+ seed: int,
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+ if len(records) < 2:
+ raise ValueError(
+ "At least two valid generated records are required when no validation dataset is supplied"
+ )
+ if not math.isfinite(ratio) or not 0.0 < ratio < 1.0:
+ raise ValueError("verl_validation_ratio must be between 0 and 1")
+ indices = list(range(len(records)))
+ random.Random(seed).shuffle(indices)
+ validation_count = max(1, min(len(records) - 1, int(round(len(records) * ratio))))
+ validation_indices = set(indices[:validation_count])
+ train = [record for index, record in enumerate(records) if index not in validation_indices]
+ validation = [record for index, record in enumerate(records) if index in validation_indices]
+ return train, validation
+
+
+def _write_parquet(path: Path, records: Sequence[Dict[str, Any]]) -> None:
+ pa, pq = _require_pyarrow()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_name(f".{path.name}.tmp")
+ try:
+ table = pa.Table.from_pylist(list(records))
+ pq.write_table(table, temp_path)
+ temp_path.replace(path)
+ except Exception as exc:
+ try:
+ temp_path.unlink()
+ except OSError:
+ pass
+ raise ValueError(f"unable to write Verl Parquet {path}: {exc}") from exc
+
+
+def _adapt_items(
+ items: Sequence[Dict[str, Any]],
+ *,
+ requested_adapter: str,
+ reward_preset: str,
+ reward_mode: str,
+ configured_data_source: str,
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]:
+ adapted: List[Dict[str, Any]] = []
+ rejected: List[Dict[str, Any]] = []
+ adapter_counts: Dict[str, int] = {}
+ for item in items:
+ try:
+ record, adapter = _adapt_record(
+ item,
+ requested_adapter=requested_adapter,
+ reward_preset=reward_preset,
+ reward_mode=reward_mode,
+ configured_data_source=configured_data_source,
+ )
+ adapted.append(record)
+ adapter_counts[adapter] = adapter_counts.get(adapter, 0) + 1
+ except Exception as exc:
+ rejected.append({
+ "source_path": item.get("source_path"),
+ "source_index": item.get("source_index"),
+ "error": str(exc),
+ })
+ return adapted, rejected, adapter_counts
+
+
+def prepare_verl_grpo_datasets(state: Dict[str, Any]) -> Dict[str, Any]:
+ """Prepare task-generated data for Verl and update Trainer's current paths.
+
+ Native train/validation Parquet remains untouched. JSON, JSONL, non-native
+ Parquet, or a native Parquet without validation is normalized into a
+ version-scoped train/validation pair plus an auditable manifest.
+ """
+ trainer = state.setdefault("trainer", {})
+ source_value = trainer.get("verl_source_dataset_path") or trainer.get(
+ "train_input_dataset_path"
+ )
+ if not source_value:
+ raise ValueError("Verl requires verl_source_dataset_path or train_input_dataset_path")
+ source_path = str(Path(str(source_value)).expanduser().resolve())
+
+ eval_value = trainer.get("verl_source_eval_dataset_path") or trainer.get(
+ "train_input_eval_dataset_path"
+ )
+ eval_path = str(Path(str(eval_value)).expanduser().resolve()) if eval_value else ""
+ source_native = _is_native_verl_parquet(source_path)
+ eval_native = _is_native_verl_parquet(eval_path) if eval_path else False
+
+ output_dir = Path(
+ str(trainer.get("trainer_output_dir") or trainer.get("output_dir") or "./outputs")
+ ).expanduser().resolve()
+ raw_validation_ratio = trainer.get("verl_validation_ratio")
+ validation_ratio = float(
+ 0.05 if raw_validation_ratio in (None, "") else raw_validation_ratio
+ )
+ raw_split_seed = trainer.get("verl_split_seed")
+ split_seed = int(42 if raw_split_seed in (None, "") else raw_split_seed)
+ prepared_dir = output_dir / "prepared_data"
+ manifest_path = prepared_dir / "dataset_manifest.json"
+ rejection_path = prepared_dir / "rejected_rows.jsonl"
+ prepared_dir.mkdir(parents=True, exist_ok=True)
+
+ if source_native and eval_native:
+ reward_mode = str(trainer.get("verl_reward_mode") or "auto").strip().lower()
+ reward_origin = str(trainer.get("verl_reward_origin") or "").strip().lower()
+ if reward_origin not in {"auto", "user"}:
+ reward_origin = "auto" if reward_mode == "auto" else "user"
+ if reward_origin == "auto":
+ reward_mode = "auto"
+ reward_preset = "auto"
+ trainer["verl_reward_mode"] = reward_mode
+ trainer["verl_reward_preset"] = reward_preset
+ recommendation = {
+ "applied": False,
+ "mode": "auto",
+ "preset": "auto",
+ "reason": "native Verl data_source routing",
+ }
+ else:
+ reward_preset = (
+ "custom"
+ if reward_mode == "custom"
+ else normalize_reward_preset(
+ "auto" if reward_mode == "auto" else trainer.get("verl_reward_preset")
+ )
+ )
+ recommendation = {
+ "applied": False,
+ "mode": reward_mode,
+ "preset": reward_preset,
+ "reason": "user-selected reward contract",
+ }
+ trainer["verl_reward_origin"] = reward_origin
+ manifest = {
+ "version": 1,
+ "status": "reused_native_parquet",
+ "source_path": source_path,
+ "train_path": source_path,
+ "validation_path": eval_path,
+ "source_paths": [source_path, eval_path],
+ "train_sha256": _sha256_file(Path(source_path)),
+ "validation_sha256": _sha256_file(Path(eval_path)),
+ "reward": {
+ "mode": reward_mode,
+ "origin": reward_origin,
+ "preset": reward_preset,
+ "recommendation": recommendation,
+ },
+ }
+ _write_json(manifest_path, manifest)
+ _write_rejections(rejection_path, [])
+ trainer["train_input_dataset_path"] = source_path
+ trainer["train_input_eval_dataset_path"] = eval_path
+ trainer["verl_data_manifest_path"] = str(manifest_path)
+ trainer["verl_data_prepare_result"] = manifest
+ trainer["verl_reward_recommendation"] = recommendation
+ return manifest
+
+ train_items, load_rejected, source_files = _load_source_records(source_path)
+ if not train_items:
+ raise ValueError(f"no readable records found in Verl source dataset: {source_path}")
+
+ reward_mode, reward_preset, recommendation = _resolve_reward(
+ state,
+ train_items,
+ source_files,
+ source_is_native=source_native,
+ )
+ previous_reward = trainer.get("_trainer_previous_reward")
+ eval_is_explicit = bool(
+ trainer.get("_trainer_eval_explicit")
+ or trainer.get("verl_source_eval_dataset_path")
+ )
+ current_reward = {
+ "mode": reward_mode,
+ "preset": reward_preset,
+ "function_path": trainer.get("verl_reward_function_path"),
+ "function_name": trainer.get("verl_reward_function_name"),
+ }
+ if (
+ eval_path
+ and not eval_is_explicit
+ and isinstance(previous_reward, dict)
+ and _reward_contract_signature(previous_reward)
+ != _reward_contract_signature(current_reward)
+ ):
+ # A validation set prepared for another preset/custom function cannot
+ # validate the current policy reliably. Split the new source instead.
+ eval_path = ""
+ eval_native = False
+ trainer["train_input_eval_dataset_path"] = ""
+ requested_adapter = str(trainer.get("verl_data_adapter") or "auto").strip().lower()
+ if requested_adapter not in _ADAPTERS:
+ raise ValueError(f"verl_data_adapter must be one of: {', '.join(sorted(_ADAPTERS))}")
+ configured_data_source = str(trainer.get("verl_data_source") or "").strip()
+
+ train_records, train_rejected, adapter_counts = _adapt_items(
+ train_items,
+ requested_adapter=requested_adapter,
+ reward_preset=reward_preset,
+ reward_mode=reward_mode,
+ configured_data_source=configured_data_source,
+ )
+ train_records, train_duplicates = _deduplicate(train_records)
+ if not train_records:
+ first_error = (train_rejected or load_rejected or [{}])[0].get("error")
+ raise ValueError(f"all generated Verl records were rejected: {first_error or 'unknown error'}")
+
+ rejected = [*load_rejected, *train_rejected]
+ reused_validation = False
+ validation_duplicates = 0
+ cross_split_duplicates = 0
+ if eval_path and eval_native:
+ validation_records: List[Dict[str, Any]] = []
+ final_eval_path = eval_path
+ final_train_records = _assign_split(train_records, "train")
+ reused_validation = True
+ elif eval_path:
+ eval_items, eval_load_rejected, eval_source_files = _load_source_records(eval_path)
+ validation_records, validation_rejected, eval_adapter_counts = _adapt_items(
+ eval_items,
+ requested_adapter=requested_adapter,
+ reward_preset=reward_preset,
+ reward_mode=reward_mode,
+ configured_data_source=configured_data_source,
+ )
+ validation_records, validation_duplicates = _deduplicate(validation_records)
+ rejected.extend(eval_load_rejected)
+ rejected.extend(validation_rejected)
+ for adapter, count in eval_adapter_counts.items():
+ adapter_counts[adapter] = adapter_counts.get(adapter, 0) + count
+ source_files.extend(eval_source_files)
+ if not validation_records:
+ raise ValueError("all generated Verl validation records were rejected")
+ validation_keys = {_record_fingerprint(record) for record in validation_records}
+ filtered_train_records = [
+ record
+ for record in train_records
+ if _record_fingerprint(record) not in validation_keys
+ ]
+ cross_split_duplicates = len(train_records) - len(filtered_train_records)
+ train_records = filtered_train_records
+ if not train_records:
+ raise ValueError("all generated training records overlap the validation dataset")
+ final_train_records = _assign_split(train_records, "train")
+ validation_records = _assign_split(validation_records, "validation")
+ final_eval_path = str(prepared_dir / "validation.parquet")
+ _write_parquet(Path(final_eval_path), validation_records)
+ else:
+ split_train, split_validation = _split_records(
+ train_records,
+ ratio=validation_ratio,
+ seed=split_seed,
+ )
+ final_train_records = _assign_split(split_train, "train")
+ validation_records = _assign_split(split_validation, "validation")
+ final_eval_path = str(prepared_dir / "validation.parquet")
+ _write_parquet(Path(final_eval_path), validation_records)
+
+ final_train_path = str(prepared_dir / "train.parquet")
+ _write_parquet(Path(final_train_path), final_train_records)
+ _write_rejections(rejection_path, rejected)
+
+ manifest = {
+ "version": 1,
+ "status": "prepared",
+ "source_path": source_path,
+ "source_paths": sorted(set(source_files)),
+ "train_path": final_train_path,
+ "validation_path": final_eval_path,
+ "reused_validation": reused_validation,
+ "input_records": len(train_items),
+ "train_records": len(final_train_records),
+ "validation_records": (
+ None if reused_validation else len(validation_records)
+ ),
+ "rejected_records": len(rejected),
+ "duplicate_records": train_duplicates + validation_duplicates + cross_split_duplicates,
+ "cross_split_duplicates": cross_split_duplicates,
+ "adapter_counts": adapter_counts,
+ "requested_adapter": requested_adapter,
+ "reward": {
+ "mode": reward_mode,
+ "origin": trainer.get("verl_reward_origin") or "user",
+ "preset": reward_preset,
+ "recommendation": recommendation,
+ },
+ "split": {
+ "ratio": validation_ratio,
+ "seed": split_seed,
+ },
+ "rejection_path": str(rejection_path),
+ "train_sha256": _sha256_file(Path(final_train_path)),
+ "validation_sha256": _sha256_file(Path(final_eval_path)),
+ }
+ _write_json(manifest_path, manifest)
+
+ trainer["train_input_dataset_path"] = final_train_path
+ trainer["train_input_eval_dataset_path"] = final_eval_path
+ trainer["verl_data_manifest_path"] = str(manifest_path)
+ trainer["verl_data_prepare_result"] = manifest
+ trainer["verl_reward_recommendation"] = recommendation
+ return manifest
+
+
+__all__ = ["prepare_verl_grpo_datasets"]
diff --git a/skills/Trainer/SKILL.md b/skills/Trainer/SKILL.md
index e180042..88f64bd 100644
--- a/skills/Trainer/SKILL.md
+++ b/skills/Trainer/SKILL.md
@@ -116,12 +116,13 @@ prepared = prepare(
### Verl GRPO State
-Use both training and validation Parquet files. When `pyarrow` is available,
-Trainer checks the schema, reads the distinct `data_source` values, and
-semantically validates up to the first 100 rows of each file. Every sampled row
-must contain a non-empty chat-message-list `prompt`, a supported `data_source`,
-and `reward_model.ground_truth`. Without `pyarrow`, Trainer checks the file and
-Parquet magic bytes, emits a warning, and defers schema validation to Verl.
+Native Verl inputs may supply both training and validation Parquet files. When
+the current round instead has Constructor-generated JSON/JSONL, leave
+`verl_source_dataset_path` empty to use `constructor.mapping_results.output_file`,
+or set it explicitly. Trainer converts native/messages/Alpaca/QA records into a
+version-scoped Parquet pair before generating YAML. `pyarrow` is required for
+conversion. Every output row contains a non-empty chat-message-list `prompt`, a
+`data_source`, and `reward_model.ground_truth`.
```python
prepared = prepare(
@@ -146,6 +147,61 @@ prepared = prepare(
)
```
+### Generated Data and Multi-Round Verl
+
+Keep the boundary between components explicit:
+
+- Constructor generates and cleans task data.
+- Trainer owns Verl-only adaptation, deterministic train/validation splitting,
+ reward contract selection, and executable GRPO config generation.
+- Do not add Verl-specific output formats to Constructor and do not change the
+ existing SFT Constructor path.
+
+On every fresh Verl `prepare()` round:
+
+1. Prefer an explicitly supplied `verl_source_dataset_path`; otherwise use the
+ current Constructor output before any persisted older source.
+2. Reuse native train/validation Parquet unchanged. Convert JSON, JSONL, or
+ non-native Parquet under
+ `{trainer_output_dir}/prepared_data/{train,validation}.parquet`.
+3. Write `dataset_manifest.json` and `rejected_rows.jsonl`. Reject records that
+ lack a reliable prompt or reference answer; never infer ground truth with an
+ LLM or copy the assistant answer into the prompt.
+4. If validation data is absent, split deterministically using
+ `verl_validation_ratio` (default `0.05`) and `verl_split_seed` (default `42`).
+ With `verl_reuse_previous_validation=true`, keep the previous validation
+ Parquet stable only while the resolved reward contract remains compatible;
+ otherwise split validation from the new source.
+5. With `verl_inherit_previous_config=true`, use the preceding successful
+ round's approved `train_config` as the hyperparameter baseline. Always
+ replace train/validation/model paths, reward fields, devices, experiment and
+ checkpoint directories, selection settings, and version metadata.
+6. With `verl_use_previous_best_model=true`, promote `update_model_path` only
+ when the preceding round completed, export did not fail, and the directory
+ contains a loadable Hugging Face config plus weights. Never pass raw FSDP
+ shards into the next round. To deliberately restart from another model,
+ pass a current-call `train_input_model_name`/`model_path` override or set
+ `verl_use_previous_best_model=false` together with the desired model path.
+7. With `verl_multi_round_enabled=true`, the prepared YAML enables Hugging Face
+ export and a positive checkpoint save cadence, including when the selected
+ smoke template originally disabled them.
+8. Show and approve the complete newly generated YAML again. Previous-round
+ approval never authorizes a new round.
+
+Minimal generated-data fields:
+
+```python
+"trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": "/path/to/verl",
+ "train_input_model_name": "/path/to/base-model",
+ "train_input_task_description": "Mathematics GRPO",
+ "verl_data_adapter": "auto",
+ "verl_reward_mode": "auto",
+}
+```
+
Use exactly one reward mode:
- `auto`: route by Parquet `data_source` through Verl's built-in router. When
@@ -158,6 +214,13 @@ The preset router imports reward implementations lazily from the configured Verl
environment. Do not copy Verl reward source into LoopAI and do not silently fall
back from an unknown `data_source` or preset.
+For generated non-native data in `auto` mode, Trainer may recommend an existing
+preset only when task/dataset metadata or an explicit answer marker makes the
+mapping reliable (for example GSM8K, MATH/boxed, DAPO/AIME, Numina/PRIME,
+Geometry3K, or Search-R1-style QA). A user-specified named preset or custom
+reward always wins. If the mapping is ambiguous, stop preparation and ask the
+user to select a preset or custom reward; do not guess.
+
## Runtime Configuration
Priority:
@@ -174,6 +237,11 @@ from loopai.skills.Configer import get_configer_task_state_config
cfg = get_configer_task_state_config("trainer", task_id=TASK_ID)
```
+For Verl data handoff it also reads only `mapping_results` from the task-scoped
+`constructor` and `obtainer` sections as optional, read-only upstream state so
+a Trainer-only invocation can locate the latest output without pulling
+unrelated section configuration or credentials into the worker state.
+
`DB_PATH` must also be set for task-scoped loading. After the run completes or fails, Trainer Skill writes structured Trainer result fields back through Configer:
```python
@@ -193,6 +261,8 @@ Useful environment variables:
- `TRAIN_STAGE`
- `TRAIN_DATASET_PATH`
- `TRAIN_EVAL_DATASET_PATH`
+- `VERL_SOURCE_DATASET_PATH` and `VERL_SOURCE_EVAL_DATASET_PATH`
+- `VERL_DATA_ADAPTER`, `VERL_DATA_SOURCE`, `VERL_VALIDATION_RATIO`, and `VERL_SPLIT_SEED`
- `TRAIN_MODEL_PATH`
- `TRAIN_TASK_DESCRIPTION`
- `TRAIN_CONFIG_TEMPLATE_PATH`
@@ -202,6 +272,7 @@ Useful environment variables:
- `VERL_ENV_PATH` or `VERL_CONDA_ENV`
- `VERL_REWARD_MODE`, `VERL_REWARD_PRESET`, and `VERL_REWARD_KWARGS`
- `VERL_REWARD_FUNCTION_PATH` and `VERL_REWARD_FUNCTION_NAME` for custom reward mode
+- `VERL_INHERIT_PREVIOUS_CONFIG`, `VERL_USE_PREVIOUS_BEST_MODEL`, and `VERL_MULTI_ROUND_ENABLED`
- `TRAINER_PERSISTENT_WORKER`
- `CUDA_VISIBLE_DEVICES`
@@ -218,7 +289,7 @@ Required Trainer fields:
- `train_input_config_template_path`
- `train_input_model_name`
- for SFT: `train_framework=llamafactory`, `train_stage=sft`, and `llamafactory_dir`
-- for GRPO: `train_framework=verl`, `train_stage=grpo`, `verl_dir`, `train_input_eval_dataset_path`, and a valid reward mode
+- for GRPO: `train_framework=verl`, `train_stage=grpo`, `verl_dir`, either a native train Parquet or generated source/Constructor output, and a valid reward mode. Validation may be supplied, reused, or deterministically split.
## Versioned Runtime
@@ -286,7 +357,7 @@ Fields that usually require user or task-specific input:
- `train_input_dataset_path`
- `train_input_model_name`
- `train_input_task_description`
-- `llamafactory_dir` for SFT, or `verl_dir` and `train_input_eval_dataset_path` for GRPO
+- `llamafactory_dir` for SFT, or `verl_dir` for GRPO. A separate validation path is optional when Trainer can split generated data.
Fields that Trainer can usually prefill:
@@ -294,6 +365,9 @@ Fields that Trainer can usually prefill:
- `train_input_config_template_path`: selects the bundled SFT or GRPO YAML template
- `verl_env_path`: defaults to Conda environment `verl`
- `verl_reward_mode`: defaults to `auto`
+- `verl_data_adapter`: defaults to `auto`
+- `verl_validation_ratio` / `verl_split_seed`: default to `0.05` / `42`
+- `verl_inherit_previous_config`, `verl_use_previous_best_model`, `verl_multi_round_enabled`: default to `true`
- `trainer_persistent_worker`: defaults to `true`
- `CUDA_VISIBLE_DEVICES`: defaults to `0`
diff --git a/tests/test_trainer_verl_multiround.py b/tests/test_trainer_verl_multiround.py
new file mode 100644
index 0000000..0515381
--- /dev/null
+++ b/tests/test_trainer_verl_multiround.py
@@ -0,0 +1,730 @@
+import json
+import sqlite3
+from pathlib import Path
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from loopai.common.db_tool.task import update_task_state_section_config_sync
+from loopai.skills.Trainer import prepare
+from loopai.skills.Trainer.runner import (
+ _hydrate_state_from_approved_config,
+ _prepare_fresh_trainer_round,
+)
+from loopai.skills.Trainer.runtime_config import resolve_trainer_runtime_config
+from loopai.skills.Trainer.utils.verl_config_generator import generate_verl_grpo_config
+from loopai.skills.Trainer.utils.verl_dataset_builder import prepare_verl_grpo_datasets
+
+
+_TEMPLATE = (
+ Path(__file__).resolve().parents[1]
+ / "loopai"
+ / "skills"
+ / "Trainer"
+ / "templates"
+ / "verl_grpo_smoke.yaml"
+)
+
+
+def _write_jsonl(path: Path, rows: list[dict]) -> None:
+ path.write_text(
+ "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n",
+ encoding="utf-8",
+ )
+
+
+def _write_native_verl_parquet(path: Path, rows: list[dict]) -> None:
+ pq.write_table(pa.Table.from_pylist(rows), path)
+
+
+def _generated_state(tmp_path: Path, source: Path) -> dict:
+ return {
+ "task_id": "round-task",
+ "output_dir": str(tmp_path / "outputs"),
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "trainer_output_dir": str(tmp_path / "run"),
+ "verl_source_dataset_path": str(source),
+ "train_input_task_description": "Optimize mathematical reasoning with GRPO.",
+ "verl_reward_mode": "auto",
+ "verl_reward_preset": "auto",
+ "verl_data_adapter": "auto",
+ "verl_validation_ratio": 0.34,
+ "verl_split_seed": 7,
+ },
+ }
+
+
+def test_configer_preserves_direct_and_wrapped_mapping_values(tmp_path: Path) -> None:
+ db_path = tmp_path / "state.db"
+ with sqlite3.connect(db_path) as connection:
+ connection.execute(
+ """
+ create table taskmodel (
+ id integer primary key,
+ task_id text not null,
+ name text not null,
+ config text,
+ state text
+ )
+ """
+ )
+ connection.execute(
+ "insert into taskmodel(task_id, name, config, state) values (?, ?, ?, ?)",
+ ("task", "task", "{}", '{"constructor": {}}'),
+ )
+ connection.commit()
+
+ first = {"output_file": "/tmp/round-1.jsonl", "mapped_records": 2}
+ update_task_state_section_config_sync(
+ db_path,
+ "task",
+ "constructor",
+ {"mapping_results": first},
+ )
+ with sqlite3.connect(db_path) as connection:
+ state = json.loads(connection.execute("select state from taskmodel").fetchone()[0])
+ assert state["constructor"]["mapping_results"] == first
+
+ second = {"output_file": "/tmp/round-2.jsonl", "mapped_records": 3}
+ update_task_state_section_config_sync(
+ db_path,
+ "task",
+ "constructor",
+ {"mapping_results": {"value": second}},
+ )
+ with sqlite3.connect(db_path) as connection:
+ state = json.loads(connection.execute("select state from taskmodel").fetchone()[0])
+ assert state["constructor"]["mapping_results"] == second
+
+
+def test_generated_alpaca_is_converted_to_versioned_verl_parquet(tmp_path: Path) -> None:
+ source = tmp_path / "generated.jsonl"
+ _write_jsonl(
+ source,
+ [
+ {"instruction": "2+2?", "input": "", "output": "4"},
+ {"instruction": "3+5?", "input": "", "output": "8"},
+ {"instruction": "5+7?", "input": "", "output": "12"},
+ ],
+ )
+ state = _generated_state(tmp_path, source)
+
+ result = prepare_verl_grpo_datasets(state)
+
+ trainer = state["trainer"]
+ assert trainer["verl_reward_mode"] == "preset"
+ assert trainer["verl_reward_preset"] == "math_boxed"
+ assert Path(result["train_path"]).parent == tmp_path / "run" / "prepared_data"
+ assert result["train_records"] == 2
+ assert result["validation_records"] == 1
+ assert result["rejected_records"] == 0
+ assert Path(trainer["verl_data_manifest_path"]).is_file()
+
+ train_rows = pq.read_table(result["train_path"]).to_pylist()
+ assert train_rows[0]["data_source"] == "loopai/math_boxed"
+ assert train_rows[0]["reward_model"]["ground_truth"]
+ assert "\\boxed" in train_rows[0]["prompt"][-1]["content"]
+ assert all(row["extra_info"]["split"] == "train" for row in train_rows)
+
+
+def test_generated_data_does_not_guess_ambiguous_reward(tmp_path: Path) -> None:
+ source = tmp_path / "classification.jsonl"
+ _write_jsonl(
+ source,
+ [
+ {"instruction": "Classify A", "output": "yes"},
+ {"instruction": "Classify B", "output": "no"},
+ ],
+ )
+ state = _generated_state(tmp_path, source)
+ state["trainer"]["train_input_task_description"] = "Binary classification"
+
+ with pytest.raises(ValueError, match="cannot safely infer a reward"):
+ prepare_verl_grpo_datasets(state)
+
+
+def test_prepare_surfaces_ambiguous_reward_error_without_masking_it(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ monkeypatch.delenv("TASK_ID", raising=False)
+ monkeypatch.delenv("DB_PATH", raising=False)
+ source = tmp_path / "classification.jsonl"
+ _write_jsonl(
+ source,
+ [
+ {"instruction": "Classify A", "output": "yes"},
+ {"instruction": "Classify B", "output": "no"},
+ ],
+ )
+ state = _generated_state(tmp_path, source)
+ state["trainer"].update({
+ "verl_dir": str(tmp_path),
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "Binary classification",
+ })
+
+ with pytest.raises(ValueError, match="cannot safely infer a reward"):
+ prepare(state=state, thread_id=f"ambiguous-{tmp_path.name}")
+
+
+def test_user_reward_preset_wins_for_generated_data(tmp_path: Path) -> None:
+ source = tmp_path / "qa.jsonl"
+ _write_jsonl(
+ source,
+ [
+ {"question": "Capital of France?", "answer": "Paris"},
+ {"question": "Capital of Japan?", "answer": "Tokyo"},
+ ],
+ )
+ state = _generated_state(tmp_path, source)
+ state["trainer"].update({
+ "train_input_task_description": "general task",
+ "verl_reward_mode": "preset",
+ "verl_reward_preset": "qa_exact_match",
+ })
+
+ result = prepare_verl_grpo_datasets(state)
+ rows = pq.read_table(result["train_path"]).to_pylist() + pq.read_table(
+ result["validation_path"]
+ ).to_pylist()
+
+ assert state["trainer"]["verl_reward_preset"] == "qa_exact_match"
+ assert rows[0]["reward_model"]["ground_truth"]["target"]
+ assert "" in rows[0]["prompt"][-1]["content"]
+
+
+def test_custom_reward_generated_data_gets_stable_default_data_source(tmp_path: Path) -> None:
+ source = tmp_path / "custom.jsonl"
+ _write_jsonl(
+ source,
+ [
+ {"instruction": "Task A", "output": "reference A"},
+ {"instruction": "Task B", "output": "reference B"},
+ ],
+ )
+ state = _generated_state(tmp_path, source)
+ state["trainer"].update({
+ "train_input_task_description": "custom task",
+ "verl_reward_mode": "custom",
+ "verl_reward_function_path": str(tmp_path / "reward.py"),
+ })
+
+ result = prepare_verl_grpo_datasets(state)
+ rows = pq.read_table(result["train_path"]).to_pylist() + pq.read_table(
+ result["validation_path"]
+ ).to_pylist()
+
+ assert {row["data_source"] for row in rows} == {"loopai/custom"}
+
+
+def test_next_round_inherits_hyperparameters_but_replaces_dynamic_fields(tmp_path: Path) -> None:
+ previous = {
+ "framework": "verl",
+ "stage": "grpo",
+ "entrypoint": "verl.trainer.main_ppo",
+ "environment": {"verl_dir": "/old/verl", "cuda_visible_devices": "0"},
+ "overrides": {
+ "data.train_files": ["/old/train.parquet"],
+ "data.val_files": ["/old/validation.parquet"],
+ "actor_rollout_ref.model.path": "/old/model",
+ "actor_rollout_ref.actor.optim.lr": 3.0e-7,
+ "actor_rollout_ref.actor.ppo_mini_batch_size": 3,
+ "actor_rollout_ref.rollout.n": 7,
+ "trainer.experiment_name": "old-version",
+ "trainer.default_local_dir": "/old/checkpoints",
+ "trainer.save_freq": 4,
+ "trainer.test_freq": 4,
+ },
+ "result": {"selection_metric": "old", "selection_mode": "min", "export_huggingface": True},
+ }
+ state = {
+ "trainer": {
+ "trainer_output_dir": str(tmp_path / "new-run"),
+ "trainer_version_id": "new-version",
+ "trainer_parent_version_id": "old-version",
+ "trainer_round_index": 2,
+ "_trainer_previous_config": previous,
+ "verl_inherit_previous_config": True,
+ "verl_multi_round_enabled": True,
+ "train_input_dataset_path": str(tmp_path / "new-train.parquet"),
+ "train_input_eval_dataset_path": str(tmp_path / "new-validation.parquet"),
+ "train_input_model_name": "/new/model/",
+ "verl_dir": "/new/verl",
+ "verl_env_path": "verl",
+ "CUDA_VISIBLE_DEVICES": "0,1",
+ "verl_reward_mode": "preset",
+ "verl_reward_preset": "math_boxed",
+ "verl_reward_kwargs": {},
+ "verl_rollout_backend": "vllm",
+ "verl_model_backend": "fsdp",
+ "verl_selection_metric": "val/reward",
+ "verl_selection_mode": "max",
+ "verl_max_actor_ckpt_to_keep": 10,
+ }
+ }
+
+ config = generate_verl_grpo_config(state, str(_TEMPLATE))
+ overrides = config["overrides"]
+
+ assert overrides["actor_rollout_ref.actor.optim.lr"] == 3.0e-7
+ assert overrides["actor_rollout_ref.actor.ppo_mini_batch_size"] == 3
+ assert overrides["actor_rollout_ref.rollout.n"] == 7
+ assert overrides["data.train_files"] == [str((tmp_path / "new-train.parquet").resolve())]
+ assert overrides["actor_rollout_ref.model.path"] == "/new/model"
+ assert overrides["trainer.experiment_name"] == "new-version"
+ assert overrides["trainer.default_local_dir"] == str((tmp_path / "new-run" / "checkpoints").resolve())
+ assert config["result"]["selection_metric"] == "val/reward"
+ assert config["loopai_round"]["inherited_previous_config"] is True
+
+
+def test_new_round_promotes_previous_exported_hf_model_and_clears_outputs(tmp_path: Path) -> None:
+ model = tmp_path / "exported-model"
+ model.mkdir()
+ (model / "config.json").write_text("{}", encoding="utf-8")
+ (model / "model.safetensors").write_bytes(b"weights")
+ previous_config = {
+ "framework": "verl",
+ "stage": "grpo",
+ "overrides": {"actor_rollout_ref.actor.optim.lr": 1.0e-6},
+ }
+ state = {
+ "trainer": {
+ "train_framework": "verl",
+ "trainer_version_id": "round-1",
+ "trainer_round_index": 1,
+ "trainer_training_success": True,
+ "trainer_training_final_status": {"status": "completed"},
+ "train_config": previous_config,
+ "update_model_path": str(model),
+ "train_input_model_name": "/models/base",
+ "verl_inherit_previous_config": True,
+ "verl_use_previous_best_model": True,
+ "verl_reuse_previous_validation": True,
+ "trainer_result": {"ok": True},
+ }
+ }
+
+ _prepare_fresh_trainer_round(state, kwargs={})
+ trainer = state["trainer"]
+
+ assert trainer["trainer_parent_version_id"] == "round-1"
+ assert trainer["trainer_round_index"] == 2
+ assert trainer["train_input_model_name"] == str(model.resolve())
+ assert trainer["trainer_model_inheritance"]["applied"] is True
+ assert trainer["_trainer_previous_config"] == previous_config
+ assert "train_config" not in trainer
+ assert "update_model_path" not in trainer
+ assert "trainer_result" not in trainer
+
+
+def test_current_round_model_override_wins_over_previous_best(tmp_path: Path) -> None:
+ exported_model = tmp_path / "exported-model"
+ exported_model.mkdir()
+ (exported_model / "config.json").write_text("{}", encoding="utf-8")
+ (exported_model / "model.safetensors").write_bytes(b"weights")
+ state = {
+ "trainer": {
+ "train_framework": "verl",
+ "trainer_version_id": "round-1",
+ "trainer_training_success": True,
+ "train_config": {
+ "framework": "verl",
+ "stage": "grpo",
+ "overrides": {"actor_rollout_ref.model.path": "/models/round-1-input"},
+ },
+ "update_model_path": str(exported_model),
+ "train_input_model_name": "/models/user-override",
+ "verl_use_previous_best_model": True,
+ }
+ }
+
+ _prepare_fresh_trainer_round(
+ state,
+ kwargs={"train_input_model_name": "/models/user-override"},
+ )
+
+ assert state["trainer"]["train_input_model_name"] == "/models/user-override"
+ assert state["trainer"]["trainer_model_inheritance"]["applied"] is False
+ assert "explicit model override" in state["trainer"]["trainer_model_inheritance"]["reason"]
+
+
+def test_runtime_prefers_current_constructor_output_over_persisted_user_seed(tmp_path: Path) -> None:
+ current = tmp_path / "current.jsonl"
+ current.write_text("{}\n", encoding="utf-8")
+ state = {
+ "task_id": "task",
+ "output_dir": str(tmp_path),
+ "constructor": {"mapping_results": {"output_file": str(current)}},
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(tmp_path),
+ "verl_source_dataset_path": "/old/generated.jsonl",
+ "verl_source_dataset_origin": "user",
+ "verl_data_prepare_result": {"source_path": "/old/generated.jsonl"},
+ "train_input_dataset_path": "/old/prepared/train.parquet",
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "math",
+ },
+ }
+
+ runtime = resolve_trainer_runtime_config(state=state)
+
+ assert runtime["state"]["trainer"]["verl_source_dataset_path"] == str(current)
+ assert runtime["state"]["trainer"]["train_input_dataset_path"] == str(current)
+ assert runtime["state"]["trainer"]["_verl_source_dataset_explicit"] is False
+ assert runtime["state"]["trainer"]["verl_source_dataset_origin"] == "constructor"
+
+
+def test_runtime_recovers_legacy_serialized_constructor_mapping(tmp_path: Path) -> None:
+ current = tmp_path / "current.jsonl"
+ current.write_text("{}\n", encoding="utf-8")
+ state = {
+ "task_id": "task",
+ "output_dir": str(tmp_path),
+ "constructor": {
+ "mapping_results": str({"output_file": str(current), "mapped_records": 1})
+ },
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(tmp_path),
+ "verl_source_dataset_path": "/old/train.parquet",
+ "verl_source_dataset_origin": "user",
+ "train_input_dataset_path": "/old/train.parquet",
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "math",
+ },
+ }
+
+ runtime = resolve_trainer_runtime_config(state=state)
+
+ assert runtime["state"]["constructor"]["mapping_results"]["output_file"] == str(current)
+ assert runtime["state"]["trainer"]["verl_source_dataset_path"] == str(current)
+ assert runtime["state"]["trainer"]["verl_source_dataset_origin"] == "constructor"
+
+
+def test_verl_round_source_replacement_does_not_clear_sft_runtime_input(tmp_path: Path) -> None:
+ generated = tmp_path / "generated.jsonl"
+ configured = tmp_path / "configured.jsonl"
+ generated.write_text("{}\n", encoding="utf-8")
+ configured.write_text("{}\n", encoding="utf-8")
+ state = {
+ "task_id": "sft-task",
+ "output_dir": str(tmp_path),
+ "constructor": {"mapping_results": {"output_file": str(generated)}},
+ "trainer": {
+ "train_framework": "llamafactory",
+ "train_stage": "sft",
+ "train_input_dataset_path": str(configured),
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "SFT",
+ "llamafactory_dir": str(tmp_path),
+ },
+ }
+
+ runtime = resolve_trainer_runtime_config(state=state)
+
+ assert runtime["state"]["trainer"]["train_input_dataset_path"] == str(configured)
+ assert runtime["state"]["trainer"]["_verl_source_dataset_replaced"] is False
+
+
+def test_runtime_respects_user_source_override_even_when_constructor_has_output(tmp_path: Path) -> None:
+ current = tmp_path / "current.jsonl"
+ explicit = tmp_path / "explicit.jsonl"
+ current.write_text("{}\n", encoding="utf-8")
+ explicit.write_text("{}\n", encoding="utf-8")
+ state = {
+ "task_id": "task",
+ "output_dir": str(tmp_path),
+ "constructor": {"mapping_results": {"output_file": str(current)}},
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(tmp_path),
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "math",
+ },
+ }
+
+ runtime = resolve_trainer_runtime_config(
+ state=state,
+ verl_source_dataset_path=str(explicit),
+ )
+
+ trainer = runtime["state"]["trainer"]
+ assert trainer["verl_source_dataset_path"] == str(explicit)
+ assert trainer["_verl_source_dataset_explicit"] is True
+ assert trainer["verl_source_dataset_origin"] == "user"
+
+
+def test_task_scoped_runtime_loads_constructor_output_for_trainer_only_call(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ generated = tmp_path / "generated.jsonl"
+ generated.write_text("{}\n", encoding="utf-8")
+
+ def fake_section(section_name, task_id, db_path):
+ assert task_id == "task-from-db"
+ if section_name == "trainer":
+ return {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(tmp_path),
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "math",
+ }
+ if section_name == "constructor":
+ return {"mapping_results": {"output_file": str(generated)}}
+ return {}
+
+ monkeypatch.setattr(
+ "loopai.skills.Trainer.runtime_config._load_task_section_config",
+ fake_section,
+ )
+
+ runtime = resolve_trainer_runtime_config(
+ state={"task_id": "task-from-db", "output_dir": str(tmp_path)},
+ thread_id="task-from-db",
+ db_path=str(tmp_path / "state.db"),
+ )
+
+ trainer = runtime["state"]["trainer"]
+ assert runtime["task_state_loaded"] is True
+ assert trainer["verl_source_dataset_path"] == str(generated)
+ assert trainer["verl_source_dataset_origin"] == "constructor"
+
+
+def test_approved_verl_yaml_hydrates_preflight_paths_and_reward() -> None:
+ trainer = {
+ "train_input_dataset_path": "/stale/train.parquet",
+ "verl_reward_mode": "auto",
+ }
+ config = {
+ "framework": "verl",
+ "stage": "grpo",
+ "environment": {
+ "verl_dir": "/approved/verl",
+ "verl_env_path": "verl",
+ "cuda_visible_devices": "0,1",
+ },
+ "overrides": {
+ "data.train_files": ["/approved/train.parquet"],
+ "data.val_files": ["/approved/validation.parquet"],
+ "actor_rollout_ref.model.path": "/approved/model",
+ "actor_rollout_ref.rollout.name": "sglang",
+ "+reward.custom_reward_function.reward_kwargs": {
+ "preset": "math_boxed",
+ "strict": True,
+ },
+ },
+ "loopai_reward": {"mode": "preset", "preset": "math_boxed"},
+ "result": {"selection_metric": "val/reward", "selection_mode": "max"},
+ }
+
+ _hydrate_state_from_approved_config(trainer, config)
+
+ assert trainer["train_input_dataset_path"] == "/approved/train.parquet"
+ assert trainer["train_input_eval_dataset_path"] == "/approved/validation.parquet"
+ assert trainer["train_input_model_name"] == "/approved/model"
+ assert trainer["verl_reward_mode"] == "preset"
+ assert trainer["verl_reward_preset"] == "math_boxed"
+ assert trainer["verl_reward_kwargs"] == {"strict": True}
+
+
+def test_two_prepare_rounds_convert_new_data_and_inherit_model_and_yaml(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ monkeypatch.delenv("TASK_ID", raising=False)
+ monkeypatch.delenv("DB_PATH", raising=False)
+ monkeypatch.setattr(
+ "loopai.skills.Trainer.utils.verl_data_checker._smoke_test_preset",
+ lambda *args, **kwargs: {"status": "skipped", "reason": "unit test"},
+ )
+ first_source = tmp_path / "round-1.jsonl"
+ second_source = tmp_path / "round-2.jsonl"
+ _write_jsonl(
+ first_source,
+ [
+ {"instruction": "1+1?", "output": "2"},
+ {"instruction": "2+3?", "output": "5"},
+ ],
+ )
+ _write_jsonl(
+ second_source,
+ [
+ {"instruction": "3+4?", "output": "7"},
+ {"instruction": "6+7?", "output": "13"},
+ ],
+ )
+ verl_dir = tmp_path / "verl"
+ verl_dir.mkdir()
+ task_id = f"two-round-{tmp_path.name}"
+ state = {
+ "task_id": task_id,
+ "output_dir": str(tmp_path / "outputs"),
+ "constructor": {"mapping_results": {"output_file": str(first_source)}},
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(verl_dir),
+ "verl_env_path": "verl",
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "Mathematics GRPO",
+ "verl_reward_mode": "auto",
+ "CUDA_VISIBLE_DEVICES": "0,1",
+ },
+ }
+
+ first = prepare(state=state, thread_id=task_id)
+ first_trainer = first["trainer"]
+ first_approval = first_trainer["trainer_result"]["data"]
+ first_validation = first_trainer["train_input_eval_dataset_path"]
+
+ exported_model = tmp_path / "round-1-model"
+ exported_model.mkdir()
+ (exported_model / "config.json").write_text("{}", encoding="utf-8")
+ (exported_model / "model.safetensors").write_bytes(b"weights")
+ first_trainer["trainer_training_success"] = True
+ first_trainer["trainer_training_final_status"] = {"status": "completed"}
+ first_trainer["update_model_path"] = str(exported_model)
+ first["constructor"] = {"mapping_results": {"output_file": str(second_source)}}
+
+ second = prepare(state=first, thread_id=task_id)
+ second_trainer = second["trainer"]
+ second_approval = second_trainer["trainer_result"]["data"]
+ second_config = second_approval["config"]
+
+ assert second_approval["trainer_version_id"] != first_approval["trainer_version_id"]
+ assert second_trainer["trainer_parent_version_id"] == first_approval["trainer_version_id"]
+ assert second_trainer["trainer_round_index"] == 2
+ assert second_trainer["train_input_model_name"] == str(exported_model.resolve())
+ assert second_config["overrides"]["actor_rollout_ref.model.path"] == str(exported_model.resolve())
+ assert second_config["loopai_round"]["inherited_previous_config"] is True
+ assert second_config["loopai_round"]["parent_version_id"] == first_approval["trainer_version_id"]
+ assert second_trainer["verl_source_dataset_path"] == str(second_source)
+ assert Path(second_trainer["train_input_dataset_path"]).parent.parent.name == second_approval[
+ "trainer_version_id"
+ ]
+ assert second_trainer["train_input_eval_dataset_path"] == first_validation
+
+
+def test_second_round_replaces_user_seed_data_and_rematches_reward(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ monkeypatch.delenv("TASK_ID", raising=False)
+ monkeypatch.delenv("DB_PATH", raising=False)
+ monkeypatch.setattr(
+ "loopai.skills.Trainer.utils.verl_data_checker._smoke_test_preset",
+ lambda *args, **kwargs: {"status": "skipped", "reason": "unit test"},
+ )
+ old_train = tmp_path / "math-train.parquet"
+ old_validation = tmp_path / "math-validation.parquet"
+ _write_native_verl_parquet(
+ old_train,
+ [
+ {
+ "prompt": [{"role": "user", "content": "1+1?"}],
+ "data_source": "DigitalLearningGmbH/MATH-lighteval",
+ "reward_model": {"style": "rule", "ground_truth": "2"},
+ },
+ {
+ "prompt": [{"role": "user", "content": "2+3?"}],
+ "data_source": "DigitalLearningGmbH/MATH-lighteval",
+ "reward_model": {"style": "rule", "ground_truth": "5"},
+ },
+ ],
+ )
+ _write_native_verl_parquet(
+ old_validation,
+ [
+ {
+ "prompt": [{"role": "user", "content": "3+4?"}],
+ "data_source": "DigitalLearningGmbH/MATH-lighteval",
+ "reward_model": {"style": "rule", "ground_truth": "7"},
+ }
+ ],
+ )
+ new_source = tmp_path / "searchr1-round-2.jsonl"
+ _write_jsonl(
+ new_source,
+ [
+ {"question": "Capital of France?", "answer": "Paris"},
+ {"question": "Capital of Japan?", "answer": "Tokyo"},
+ {"question": "Capital of China?", "answer": "Beijing"},
+ ],
+ )
+
+ task_id = f"seed-to-generated-{tmp_path.name}"
+ state = {
+ "task_id": task_id,
+ "output_dir": str(tmp_path / "outputs"),
+ "trainer": {
+ "train_framework": "verl",
+ "train_stage": "grpo",
+ "verl_dir": str(tmp_path / "verl"),
+ "verl_env_path": "verl",
+ "verl_source_dataset_path": str(old_train),
+ "verl_source_dataset_origin": "user",
+ "train_input_dataset_path": str(old_train),
+ "train_input_eval_dataset_path": str(old_validation),
+ "train_input_model_name": "/models/base",
+ "train_input_task_description": "Mathematics GRPO",
+ "verl_reward_mode": "auto",
+ "CUDA_VISIBLE_DEVICES": "0,1",
+ },
+ }
+ (tmp_path / "verl").mkdir()
+
+ first = prepare(state=state, thread_id=task_id)
+ first_trainer = first["trainer"]
+ first_config = first_trainer["trainer_result"]["data"]["config"]
+ assert first_config["overrides"]["data.train_files"] == [str(old_train.resolve())]
+ assert first_config["loopai_reward"]["mode"] == "auto"
+ assert first_config["loopai_reward"]["origin"] == "auto"
+
+ exported_model = tmp_path / "round-1-model"
+ exported_model.mkdir()
+ (exported_model / "config.json").write_text("{}", encoding="utf-8")
+ (exported_model / "model.safetensors").write_bytes(b"weights")
+ first_trainer["trainer_training_success"] = True
+ first_trainer["trainer_training_final_status"] = {"status": "completed"}
+ first_trainer["update_model_path"] = str(exported_model)
+ first_trainer["train_input_task_description"] = "SearchR1 exact match QA"
+ first["constructor"] = {"mapping_results": {"output_file": str(new_source)}}
+
+ second = prepare(state=first, thread_id=task_id)
+ trainer = second["trainer"]
+ approval = trainer["trainer_result"]["data"]
+ config = approval["config"]
+ manifest = trainer["verl_data_prepare_result"]
+
+ assert trainer["trainer_round_index"] == 2
+ assert trainer["verl_source_dataset_path"] == str(new_source)
+ assert trainer["verl_source_dataset_origin"] == "constructor"
+ assert manifest["source_path"] == str(new_source.resolve())
+ assert manifest["status"] == "prepared"
+ assert manifest["reused_validation"] is False
+ assert manifest["reward"]["mode"] == "preset"
+ assert manifest["reward"]["origin"] == "auto"
+ assert manifest["reward"]["preset"] == "qa_exact_match"
+ assert config["loopai_reward"]["mode"] == "preset"
+ assert config["loopai_reward"]["origin"] == "auto"
+ assert config["loopai_reward"]["preset"] == "qa_exact_match"
+ assert config["overrides"]["data.train_files"] == [trainer["train_input_dataset_path"]]
+ assert str(old_train.resolve()) not in config["overrides"]["data.train_files"]
+ assert str(old_validation.resolve()) not in config["overrides"]["data.val_files"]
+
+ rows = pq.read_table(trainer["train_input_dataset_path"]).to_pylist()
+ rows += pq.read_table(trainer["train_input_eval_dataset_path"]).to_pylist()
+ assert len(rows) == 3
+ assert {row["data_source"] for row in rows} == {"loopai/qa_exact_match"}
+ assert all(row["reward_model"]["ground_truth"]["target"] for row in rows)
+ assert all("" in row["prompt"][-1]["content"] for row in rows)