From 0761ffa7cda81954f5348e7dcf9b4f7e2fcb4064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E7=BF=9B=E7=8E=84?= <1516017826@qq.com> Date: Thu, 28 May 2026 17:01:00 +0800 Subject: [PATCH] feat(trainer): add new train_agentic_rl_async.py --- trainer/train_agentic_rl_async.py | 3652 +++++++++++++++++++++++++++++ 1 file changed, 3652 insertions(+) create mode 100644 trainer/train_agentic_rl_async.py diff --git a/trainer/train_agentic_rl_async.py b/trainer/train_agentic_rl_async.py new file mode 100644 index 0000000..3f73cdb --- /dev/null +++ b/trainer/train_agentic_rl_async.py @@ -0,0 +1,3652 @@ +import os +import sys + +__package__ = "trainer" +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import argparse +import ast +import gc +import json +import math +import queue +import random +import re +import signal +import threading +import time +import warnings +from collections import deque +from contextlib import nullcontext +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import torch +import torch.distributed as dist +import torch.nn.functional as F +from torch import optim +from torch.nn.parallel import DistributedDataParallel +from torch.optim.lr_scheduler import CosineAnnealingLR +from torch.utils.data import Dataset + +from model.model_minimind import MiniMindConfig +from trainer.rollout_engine import create_rollout_engine, RolloutResult +from trainer.trainer_utils import ( + LMForRewardModel, + Logger, + init_model, + is_main_process, + lm_checkpoint, + setup_seed, +) + +warnings.filterwarnings("ignore") + + +MATH_AGENT_SYSTEM_INSTRUCTION = ( + "你是一个简洁、严格的工具调用助手。遇到算术表达式时,必须先调用 calculate_math 工具;" + "不要用其它工具解算术题,不要自己心算。工具调用格式必须严格为:" + '{"name":"calculate_math","arguments":{"expression":"1+1"}}' + "。每个独立算式调用一次工具,按用户问题顺序调用。拿到全部工具结果后,最终只输出 " + "结果1, 结果2;多个结果按问题顺序用英文逗号分隔,不要输出额外解释。" +) + +CALCULATE_MATH_TOOL_SPEC = { + "type": "function", + "function": { + "name": "calculate_math", + "description": "Evaluate an arithmetic expression. 用于计算加减乘除、幂、sqrt/log/sin/cos 等简单数学表达式。", + "parameters": { + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Arithmetic expression, e.g. 123+456 or 2**10."} + }, + "required": ["expression"], + }, + }, +} + +UNIT_CONVERTER_TOOL_SPEC = { + "type": "function", + "function": { + "name": "unit_converter", + "description": "Convert common units. 仅在用户明确要求单位换算时使用。", + "parameters": { + "type": "object", + "properties": { + "value": {"type": "number"}, + "from_unit": {"type": "string"}, + "to_unit": {"type": "string"}, + }, + "required": ["value", "from_unit", "to_unit"], + }, + }, +} + +TIME_TOOL_SPEC = { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Return a fixed or live current time for a timezone. 仅在用户明确询问时间时使用。", + "parameters": { + "type": "object", + "properties": {"timezone": {"type": "string"}}, + "required": [], + }, + }, +} + +CALCULATOR_ALIAS_TOOL_SPEC = { + "type": "function", + "function": { + "name": "calculator", + "description": "Backward-compatible alias for calculate_math. Prefer calculate_math.", + "parameters": { + "type": "object", + "properties": {"expression": {"type": "string"}}, + "required": ["expression"], + }, + }, +} + + +# -------------------------- generic helpers -------------------------- + +def str2bool(v): + if isinstance(v, bool): + return v + return str(v).lower() in ("true", "1", "yes", "y", "on") + + +def unwrap_model(model): + raw = model.module if isinstance(model, DistributedDataParallel) else model + return getattr(raw, "_orig_mod", raw) + + +def finite_float(x: Any, default: float = 0.0) -> float: + try: + y = float(x) + return y if math.isfinite(y) else default + except Exception: + return default + + +def safe_mean(xs: Iterable[float], default: float = 0.0) -> float: + vals = [finite_float(x, default=float("nan")) for x in xs] + vals = [x for x in vals if math.isfinite(x)] + return sum(vals) / max(len(vals), 1) if vals else default + + +def percentile(xs: Iterable[float], q: float, default: float = 0.0) -> float: + vals = sorted(finite_float(x, default=float("nan")) for x in xs) + vals = [x for x in vals if math.isfinite(x)] + if not vals: + return default + if len(vals) == 1: + return float(vals[0]) + q = min(max(float(q), 0.0), 1.0) + pos = q * (len(vals) - 1) + lo = int(math.floor(pos)) + hi = int(math.ceil(pos)) + if lo == hi: + return float(vals[lo]) + frac = pos - lo + return float(vals[lo] * (1.0 - frac) + vals[hi] * frac) + + +def ddp_world_size() -> int: + return dist.get_world_size() if dist.is_initialized() else 1 + + +def trainer_rank() -> int: + return 0 + + +def is_trainer_rollout_mode(args) -> bool: + return bool(getattr(args, "multi_gpu_mode", "trainer_rollout") == "trainer_rollout") + + +def is_trainer_process(args) -> bool: + rank = dist.get_rank() if dist.is_initialized() else 0 + return rank == trainer_rank() if is_trainer_rollout_mode(args) else True + + +def ddp_all_true(flag: bool, device: str) -> bool: + if not dist.is_initialized(): + return bool(flag) + x = torch.tensor([1 if flag else 0], device=device, dtype=torch.int64) + dist.all_reduce(x, op=dist.ReduceOp.MIN) + return bool(x.item() > 0) + + +def ddp_mean_scalar(value: float, device: str) -> float: + if not dist.is_initialized(): + return float(value) + x = torch.tensor([finite_float(value)], device=device, dtype=torch.float32) + dist.all_reduce(x, op=dist.ReduceOp.SUM) + x /= max(ddp_world_size(), 1) + return float(x.item()) + + +def ddp_sum_scalar(value: float, device: str) -> float: + if not dist.is_initialized(): + return float(value) + x = torch.tensor([finite_float(value)], device=device, dtype=torch.float32) + dist.all_reduce(x, op=dist.ReduceOp.SUM) + return float(x.item()) + + +def ddp_average_metrics(metrics: Dict[str, Any], device: str) -> Dict[str, Any]: + if not dist.is_initialized(): + return metrics + keys = [k for k, v in metrics.items() if isinstance(v, (int, float)) and math.isfinite(float(v))] + if not keys: + return metrics + values = torch.tensor([float(metrics[k]) for k in keys], device=device, dtype=torch.float32) + dist.all_reduce(values, op=dist.ReduceOp.SUM) + values /= max(ddp_world_size(), 1) + out = dict(metrics) + for key, value in zip(keys, values.tolist()): + out[key] = float(value) + return out + + +def assert_rollout_alignment(response_ids, response_mask, response_old_logps, response_versions): + n = len(response_ids) + if not (n == len(response_mask) == len(response_old_logps) == len(response_versions)): + raise AssertionError( + f"rollout alignment mismatch: ids={len(response_ids)}, mask={len(response_mask)}, " + f"logps={len(response_old_logps)}, versions={len(response_versions)}" + ) + + +def init_distributed_mode_compat(backend: str = "auto"): + if int(os.environ.get("RANK", -1)) == -1: + return 0 + + local_rank = int(os.environ["LOCAL_RANK"]) + if torch.cuda.is_available(): + torch.cuda.set_device(local_rank) + + def _init_pg(pg_backend: str): + dist.init_process_group(backend=pg_backend, timeout=timedelta(minutes=30)) + if pg_backend == "nccl": + probe = torch.ones(1, device=f"cuda:{local_rank}") + dist.all_reduce(probe) + torch.cuda.synchronize(local_rank) + + backend = str(backend or "auto").lower() + if backend == "auto": + preferred = "nccl" if torch.cuda.is_available() else "gloo" + candidates = [preferred] if preferred == "gloo" else ["nccl", "gloo"] + else: + candidates = [backend] + + for idx, candidate in enumerate(candidates): + try: + _init_pg(candidate) + if candidate != "nccl" and int(os.environ.get("RANK", "0")) == 0: + Logger(f"[WARN] distributed backend fallback to {candidate}") + return local_rank + except Exception as exc: + if dist.is_initialized(): + try: + dist.destroy_process_group() + except Exception: + pass + if idx + 1 >= len(candidates): + raise + if int(os.environ.get("RANK", "0")) == 0: + Logger(f"[WARN] distributed backend {candidate} unavailable: {repr(exc)}; fallback to {candidates[idx + 1]}") + return local_rank + + +def ensure_tokenizer_ids(tokenizer, lm_config=None): + if tokenizer.eos_token_id is None: + tokenizer.eos_token_id = getattr(lm_config, "eos_token_id", 2) + if tokenizer.pad_token_id is None: + tokenizer.pad_token_id = tokenizer.eos_token_id + return tokenizer.pad_token_id, tokenizer.eos_token_id + + +@torch.no_grad() +def model_has_nonfinite(model: torch.nn.Module, max_checks: int = 32) -> bool: + raw = unwrap_model(model) + checked = 0 + for p in raw.parameters(): + if p is None: + continue + if not torch.isfinite(p.detach()).all(): + return True + checked += 1 + if checked >= max_checks: + break + return False + + +@torch.no_grad() +def copy_policy_weights(dst_model: torch.nn.Module, src_model: torch.nn.Module) -> bool: + if dst_model is None: + return False + if model_has_nonfinite(src_model): + Logger("[WARN] train model has non-finite weights; skip rollout policy sync") + return False + src = unwrap_model(src_model) + dst = unwrap_model(dst_model) + dst.load_state_dict(src.state_dict(), strict=False) + dst.to(next(src.parameters()).device) + dst.eval().requires_grad_(False) + return True + + +def save_checkpoint(args, lm_config, model, optimizer, scheduler, epoch: int, step: int, opt_step: int, version: int, wandb=None): + if not is_main_process(): + return + if model_has_nonfinite(model): + Logger("[WARN] checkpoint skipped because model has non-finite weights") + return + os.makedirs(args.save_dir, exist_ok=True) + moe_suffix = "_moe" if lm_config.use_moe else "" + ckp = f"{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth" + raw_model = unwrap_model(model) + state_dict = raw_model.state_dict() + tmp = ckp + ".tmp" + torch.save({k: v.half().cpu() for k, v in state_dict.items()}, tmp) + os.replace(tmp, ckp) + lm_checkpoint( + lm_config, + weight=args.save_weight, + model=model, + optimizer=optimizer, + epoch=epoch, + step=step, + wandb=wandb, + save_dir="../checkpoints", + scheduler=scheduler, + opt_step=opt_step, + policy_version=version, + reward_baseline=float(getattr(args, "reward_baseline", 0.0)), + ) + Logger(f"checkpoint saved: {ckp}") + del state_dict + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + +def apply_chat_template_compat(tokenizer, messages, tools=None, add_generation_prompt=True, open_thinking=False) -> str: + if hasattr(tokenizer, "apply_chat_template"): + call_orders = [ + dict(tokenize=False, add_generation_prompt=add_generation_prompt, tools=tools, open_thinking=open_thinking), + dict(tokenize=False, add_generation_prompt=add_generation_prompt, tools=tools), + dict(tokenize=False, add_generation_prompt=add_generation_prompt), + ] + for kwargs in call_orders: + try: + return tokenizer.apply_chat_template(messages, **kwargs) + except TypeError: + continue + except Exception: + continue + parts = [] + if tools: + parts.append("<|tools|>" + json.dumps(tools, ensure_ascii=False, separators=(",", ":")) + "<|/tools|>") + for msg in messages: + parts.append(f"<|im_start|>{msg.get('role', 'user')}\n{msg.get('content', '')}<|im_end|>") + if add_generation_prompt: + parts.append("<|im_start|>assistant\n") + return "\n".join(parts) + + +def tokenize_context(tokenizer, text: str, device: str, max_context_len: int = 0): + inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False) + input_ids = inputs["input_ids"] + attention_mask = inputs["attention_mask"] + if max_context_len and input_ids.size(1) > max_context_len: + input_ids = input_ids[:, -max_context_len:] + attention_mask = attention_mask[:, -max_context_len:] + return {"input_ids": input_ids.to(device), "attention_mask": attention_mask.to(device)} + + +# -------------------------- rollout engine -------------------------- + +class StableTorchRolloutEngine: + """Safe torch rollout. + + PPO fix: old_logps are computed under the raw policy distribution. Sampling may use + temperature/top-p/top-k/repetition penalty, but PPO behavior logprobs are taken from + the unmodified policy distribution. + """ + + def __init__( + self, + policy_model, + tokenizer, + device="cuda", + autocast_factory=None, + top_p=0.90, + top_k=50, + repetition_penalty=1.03, + use_cache=True, + ): + self.policy_model = policy_model + self.tokenizer = tokenizer + self.device = device + self.autocast_factory = autocast_factory or (lambda: nullcontext()) + self.top_p = float(top_p) + self.top_k = int(top_k) + self.repetition_penalty = float(repetition_penalty) + self.use_cache = bool(use_cache) + self.nan_batches = 0 + self.bad_prob_batches = 0 + self.cache_prefill_tokens = 0 + self.cache_decode_tokens = 0 + self.cache_fallbacks = 0 + + def update_policy(self, model): + self.policy_model = model + return True + + @staticmethod + def _sanitize_logits(logits: torch.Tensor) -> torch.Tensor: + return torch.nan_to_num(logits.float(), nan=0.0, posinf=30.0, neginf=-30.0).clamp(-80.0, 80.0) + + def _apply_repetition_penalty_for_sampling(self, sample_logits, input_ids): + if not self.repetition_penalty or self.repetition_penalty == 1.0: + return sample_logits + sample_logits = sample_logits.clone() + for i in range(input_ids.size(0)): + uniq = torch.unique(input_ids[i]) + uniq = uniq[(uniq >= 0) & (uniq < sample_logits.size(-1))] + if uniq.numel() > 0: + positive = sample_logits[i, uniq] > 0 + sample_logits[i, uniq] = torch.where( + positive, + sample_logits[i, uniq] / self.repetition_penalty, + sample_logits[i, uniq] * self.repetition_penalty, + ) + return sample_logits + + def _sample_next(self, logits, input_ids, temperature, do_sample, eos_token_id): + had_bad = (~torch.isfinite(logits)).any().item() + raw_logits = self._sanitize_logits(logits) + raw_log_probs = F.log_softmax(raw_logits, dim=-1) + sample_logits = self._apply_repetition_penalty_for_sampling(raw_logits, input_ids) + + if (not do_sample) or temperature <= 1e-6: + next_token = torch.argmax(sample_logits, dim=-1, keepdim=True) + next_logp = raw_log_probs.gather(1, next_token).squeeze(1) + return next_token, next_logp, bool(had_bad), False + + sample_logits = sample_logits / max(float(temperature), 1e-6) + vocab_size = sample_logits.size(-1) + top_k = min(max(self.top_k, 0), vocab_size) + if 0 < top_k < vocab_size: + threshold = torch.topk(sample_logits, top_k, dim=-1).values[..., -1, None] + sample_logits = sample_logits.masked_fill(sample_logits < threshold, -1e9) + + if 0.0 < self.top_p < 1.0: + sorted_logits, sorted_indices = torch.sort(sample_logits, descending=True, dim=-1) + sorted_probs = torch.softmax(sorted_logits, dim=-1) + sorted_probs = torch.nan_to_num(sorted_probs, nan=0.0, posinf=0.0, neginf=0.0) + sorted_remove = torch.cumsum(sorted_probs, dim=-1) > self.top_p + sorted_remove[..., 1:] = sorted_remove[..., :-1].clone() + sorted_remove[..., 0] = False + remove = torch.zeros_like(sample_logits, dtype=torch.bool) + remove.scatter_(1, sorted_indices, sorted_remove) + sample_logits = sample_logits.masked_fill(remove, -1e9) + + probs = torch.softmax(sample_logits, dim=-1) + probs = torch.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0).clamp(min=0.0) + denom = probs.sum(dim=-1, keepdim=True) + bad_rows = (~torch.isfinite(denom)) | (denom <= 0) + if bad_rows.any(): + probs = torch.where(bad_rows, torch.zeros_like(probs), probs) + fallback_id = int(eos_token_id) if eos_token_id is not None else 0 + fallback_id = min(max(fallback_id, 0), probs.size(-1) - 1) + probs[bad_rows.squeeze(1), fallback_id] = 1.0 + denom = probs.sum(dim=-1, keepdim=True) + probs = probs / denom.clamp(min=1e-12) + next_token = torch.multinomial(probs, num_samples=1) + next_logp = raw_log_probs.gather(1, next_token).squeeze(1) + next_logp = torch.nan_to_num(next_logp, nan=0.0, posinf=0.0, neginf=0.0) + return next_token, next_logp, bool(had_bad), bool(bad_rows.any().item()) + + @torch.no_grad() + def rollout(self, prompt_ids, attention_mask, num_generations, max_new_tokens, temperature=0.8): + model = unwrap_model(self.policy_model).eval() + eos_id = self.tokenizer.eos_token_id if self.tokenizer.eos_token_id is not None else 2 + pad_id = self.tokenizer.pad_token_id if self.tokenizer.pad_token_id is not None else eos_id + input_ids = prompt_ids.repeat_interleave(num_generations, dim=0).to(self.device) + attn = attention_mask.repeat_interleave(num_generations, dim=0).to(self.device) if attention_mask is not None else None + self.cache_prefill_tokens += int(input_ids.numel()) + vocab_size = getattr(getattr(model, "config", None), "vocab_size", None) + if vocab_size is not None: + bad_ids = (input_ids < 0) | (input_ids >= int(vocab_size)) + if bad_ids.any(): + input_ids = input_ids.clone() + input_ids[bad_ids] = eos_id + + generated_tokens, generated_logps = [], [] + past_key_values = None + finished = torch.zeros(input_ids.size(0), dtype=torch.bool, device=input_ids.device) + do_sample = temperature > 1e-6 + use_cache = bool(self.use_cache) + + for _ in range(int(max_new_tokens)): + step_input = input_ids[:, -1:] if (use_cache and past_key_values is not None) else input_ids + try: + with self.autocast_factory(): + outputs = model( + input_ids=step_input, + attention_mask=attn, + past_key_values=past_key_values if use_cache else None, + use_cache=use_cache, + ) + except RuntimeError as exc: + msg = str(exc) + if use_cache and ("attention_mask" in msg or "size of tensor" in msg or "must match" in msg): + self.cache_fallbacks += 1 + use_cache = False + past_key_values = None + with self.autocast_factory(): + outputs = model(input_ids=input_ids, attention_mask=attn, past_key_values=None, use_cache=False) + else: + raise + + if use_cache and past_key_values is not None: + self.cache_decode_tokens += int(step_input.numel()) + + next_token, next_logp, had_bad, bad_prob = self._sample_next( + outputs.logits[:, -1, :], input_ids, temperature, do_sample, eos_id + ) + self.nan_batches += int(had_bad) + self.bad_prob_batches += int(bad_prob) + forced_eos = torch.full_like(next_token, int(eos_id)) + next_token = torch.where(finished.unsqueeze(-1), forced_eos, next_token) + next_logp = torch.where(finished, torch.zeros_like(next_logp), next_logp) + input_ids = torch.cat([input_ids, next_token], dim=-1) + generated_tokens.append(next_token) + generated_logps.append(next_logp.unsqueeze(1)) + if attn is not None: + attn = torch.cat([attn, attn.new_ones(attn.size(0), 1)], dim=-1) + past_key_values = getattr(outputs, "past_key_values", None) if use_cache else None + finished |= next_token.squeeze(-1).eq(int(eos_id)) + if finished.all(): + break + + if generated_tokens: + completion_ids = torch.cat(generated_tokens, dim=1) + per_token_logps = torch.cat(generated_logps, dim=1) + else: + completion_ids = input_ids.new_full((input_ids.size(0), 0), int(pad_id)) + per_token_logps = input_ids.new_empty((input_ids.size(0), 0), dtype=torch.float32) + output_ids = torch.cat([prompt_ids.repeat_interleave(num_generations, dim=0).to(self.device), completion_ids], dim=1) + completions = self.tokenizer.batch_decode(completion_ids, skip_special_tokens=True) + return RolloutResult(output_ids=output_ids, completion_ids=completion_ids, per_token_logps=per_token_logps, completions=completions) + + +# -------------------------- logprob / entropy helpers -------------------------- + + +def compute_per_token_logps_safe(model, input_ids, attention_mask, autocast_factory=None): + raw = unwrap_model(model) + ctx_factory = autocast_factory or (lambda: nullcontext()) + with ctx_factory(): + res = raw(input_ids=input_ids, attention_mask=attention_mask) + logits = res.logits[:, :-1, :] + logits = torch.nan_to_num(logits.float(), nan=0.0, posinf=30.0, neginf=-30.0).clamp(-80.0, 80.0) + token_ids = input_ids[:, 1:].clamp(min=0, max=logits.size(-1) - 1) + return F.log_softmax(logits, dim=-1).gather(2, token_ids.unsqueeze(-1)).squeeze(-1) + + +def logits_entropy_safe(logits, mask): + with torch.no_grad(): + logits = torch.nan_to_num(logits.float(), nan=0.0, posinf=30.0, neginf=-30.0).clamp(-80.0, 80.0) + logp = F.log_softmax(logits, dim=-1) + ent = -(torch.exp(logp) * logp).sum(dim=-1) + return float(((ent * mask).sum() / mask.sum().clamp(min=1.0)).item()) + + +# -------------------------- safe math and robust extraction -------------------------- + +class SafeMathEvaluator(ast.NodeVisitor): + ALLOWED_BINOPS = { + ast.Add: lambda a, b: a + b, + ast.Sub: lambda a, b: a - b, + ast.Mult: lambda a, b: a * b, + ast.Div: lambda a, b: a / b, + ast.FloorDiv: lambda a, b: a // b, + ast.Mod: lambda a, b: a % b, + ast.Pow: lambda a, b: a ** b, + } + ALLOWED_UNARY = {ast.UAdd: lambda a: +a, ast.USub: lambda a: -a} + ALLOWED_NAMES = {"pi": math.pi, "e": math.e} + ALLOWED_FUNCS = { + "sqrt": math.sqrt, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "log": math.log, + "exp": math.exp, + "abs": abs, + "round": round, + } + + def visit_Expression(self, node): + return self.visit(node.body) + + def visit_Constant(self, node): + if isinstance(node.value, (int, float)): + return float(node.value) + raise ValueError("invalid constant") + + def visit_Num(self, node): + return float(node.n) + + def visit_Name(self, node): + if node.id in self.ALLOWED_NAMES: + return float(self.ALLOWED_NAMES[node.id]) + raise ValueError(f"invalid name: {node.id}") + + def visit_Call(self, node): + if not isinstance(node.func, ast.Name) or node.func.id not in self.ALLOWED_FUNCS: + raise ValueError("invalid function") + if node.keywords: + raise ValueError("keyword args not allowed") + if len(node.args) > 3: + raise ValueError("too many args") + return float(self.ALLOWED_FUNCS[node.func.id](*[self.visit(arg) for arg in node.args])) + + def visit_BinOp(self, node): + op = self.ALLOWED_BINOPS.get(type(node.op)) + if op is None: + raise ValueError("invalid operator") + return float(op(self.visit(node.left), self.visit(node.right))) + + def visit_UnaryOp(self, node): + op = self.ALLOWED_UNARY.get(type(node.op)) + if op is None: + raise ValueError("invalid unary operator") + return float(op(self.visit(node.operand))) + + def generic_visit(self, node): + raise ValueError(f"unsupported node: {type(node).__name__}") + + +def _build_fullwidth_translation() -> Dict[int, str]: + mapping = {ord(src): dst for src, dst in { + "+": "+", + "-": "-", + "−": "-", + "–": "-", + "—": "-", + "﹣": "-", + "×": "*", + "✕": "*", + "*": "*", + "÷": "/", + "/": "/", + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "{": "{", + "}": "}", + ",": ",", + "。": ".", + ".": ".", + ":": ":", + ";": ";", + "%": "%", + "^": "^", + " ": " ", + }.items()} + for src, dst in zip("0123456789", "0123456789"): + mapping[ord(src)] = dst + return mapping + + +_FULLWIDTH_TRANS = _build_fullwidth_translation() + + +def canonicalize_expression(expr: str) -> str: + s = str(expr).translate(_FULLWIDTH_TRANS) + s = s.replace("^", "**") + s = re.sub(r"\s+", "", s) + return s + + +def _strip_expr_edges(expr: str) -> str: + s = str(expr).translate(_FULLWIDTH_TRANS).strip() + s = s.strip(" \t\r\n:;,.??!!、,。;:") + # Trim unmatched right parens that appear from casual text while preserving valid groups. + while s and s.count(")") > s.count("("): + s = s[:-1].rstrip() + return s + + +def safe_eval_math(expr: str) -> float: + expr = canonicalize_expression(expr) + if len(expr) > 256: + raise ValueError("expression too long") + # Avoid interpreting dates as subtraction chains. + if re.fullmatch(r"\d{4}-\d{1,2}-\d{1,2}", expr): + raise ValueError("date-like string is not an arithmetic expression") + return SafeMathEvaluator().visit(ast.parse(expr, mode="eval")) + + +def format_number(value: float) -> str: + value = float(value) + if abs(value - round(value)) < 1e-9: + return str(int(round(value))) + return f"{value:.10f}".rstrip("0").rstrip(".") + + +_NUM = r"[-+]?\d+(?:\.\d+)?" +_FUNC = r"(?:sqrt|sin|cos|tan|log|exp|abs|round)" +# Non-nested parentheses are enough for the synthetic data and most simple tool-use data. +_ATOM = rf"(?:{_FUNC}\s*\([^()]+\)|\([^()]+\)|{_NUM})" +_OP = r"(?:\*\*|//|[+\-*/×÷^%])" +# ASCII boundary only. Do not use \w boundary: in Python, Chinese characters are \w and would hide expressions after “计算”. +ARITH_EXPR_RE = re.compile(rf"(? bool: + s = canonicalize_expression(expr) + # Remove leading unary sign before checking for a binary operator. + s2 = re.sub(r"(?<=^)[+-]", "", s) + return any(op in s2 for op in ("**", "//", "+", "-", "*", "/", "%")) + + +def extract_arithmetic_expressions(text: str, max_exprs: int = 8) -> List[str]: + """Extract simple arithmetic expressions from mixed Chinese/English text. + + v3 used a boundary based on \\w and therefore missed expressions immediately after + Chinese words, e.g. “请计算884+645*18” -> only “645*18”. It also truncated + parenthesized expressions, e.g. “(289+532)*11” -> “(289+532)”. Both errors poison + BC targets and reward diagnostics. This extractor keeps complete arithmetic spans + and validates every candidate with SafeMathEvaluator. + """ + raw = str(text).translate(_FULLWIDTH_TRANS) + candidates: List[Tuple[int, int, str]] = [] + for m in ARITH_EXPR_RE.finditer(raw): + expr = _strip_expr_edges(m.group(0)) + if not expr or not _candidate_has_real_operator(expr): + continue + try: + safe_eval_math(expr) + except Exception: + continue + candidates.append((m.start(), m.end(), expr)) + + # Prefer longer overlapping spans to avoid keeping “289+532” when “(289+532)*11” exists. + candidates.sort(key=lambda x: (x[0], -(x[1] - x[0]))) + selected: List[Tuple[int, int, str]] = [] + for start, end, expr in candidates: + overlaps = [i for i, (s, e, _) in enumerate(selected) if not (end <= s or start >= e)] + if overlaps: + # Keep the candidate covering more characters. + keep = True + for i in overlaps: + s, e, _ = selected[i] + if (e - s) >= (end - start): + keep = False + break + if not keep: + continue + selected = [x for j, x in enumerate(selected) if j not in overlaps] + selected.append((start, end, expr)) + selected.sort(key=lambda x: x[0]) + if len(selected) >= max_exprs: + break + + out: List[str] = [] + seen = set() + for _, _, expr in selected: + key = canonicalize_expression(expr) + if key not in seen: + out.append(expr) + seen.add(key) + if len(out) >= max_exprs: + break + return out + + +# -------------------------- tool env -------------------------- + +class RealToolEnv: + def __init__(self, time_mode="fixed", active_tools: Optional[List[str]] = None, allow_calculator_alias: bool = False): + self.time_mode = time_mode + self.active_tools = active_tools or ["calculate_math", "unit_converter", "get_current_time"] + self.allow_calculator_alias = bool(allow_calculator_alias) + self.time_data = { + "Asia/Shanghai": "2025-03-07 14:30:00", + "America/New_York": "2025-03-07 01:30:00", + "Europe/London": "2025-03-07 06:30:00", + "Asia/Tokyo": "2025-03-07 15:30:00", + "Europe/Paris": "2025-03-07 07:30:00", + "Australia/Sydney": "2025-03-07 17:30:00", + } + self.unit_data = { + "km_miles": 0.621371, + "miles_km": 1.60934, + "kg_pounds": 2.20462, + "pounds_kg": 0.453592, + "meters_feet": 3.28084, + "feet_meters": 0.3048, + } + + def tool_specs(self): + spec_map = { + "calculate_math": CALCULATE_MATH_TOOL_SPEC, + "unit_converter": UNIT_CONVERTER_TOOL_SPEC, + "get_current_time": TIME_TOOL_SPEC, + } + out = [] + for name in self.active_tools: + if name in spec_map: + out.append(clone_tool_spec(spec_map[name])) + if self.allow_calculator_alias and "calculate_math" in self.active_tools: + out.append(clone_tool_spec(CALCULATOR_ALIAS_TOOL_SPEC)) + return out + + def supported_names(self): + names = set(supported_tool_names(self.tool_specs())) | {"python_math"} + if self.allow_calculator_alias: + names.add("calculator") + return names + + def execute(self, name, args): + if name in ("calculate_math", "calculator", "python_math"): + expr = str(args.get("expression", args.get("query", ""))).strip() + if not expr: + raise ValueError("missing expression") + value = safe_eval_math(expr) + return {"value": value, "result": format_number(value)} + if name == "unit_converter": + value = float(args["value"]) + from_unit = str(args["from_unit"]).lower().strip() + to_unit = str(args["to_unit"]).lower().strip() + key = f"{from_unit}_{to_unit}" + if key == "celsius_fahrenheit": + result = value * 1.8 + 32.0 + elif key == "fahrenheit_celsius": + result = (value - 32.0) / 1.8 + elif key in self.unit_data: + result = value * self.unit_data[key] + else: + raise ValueError(f"unsupported conversion: {key}") + return {"value": value, "from_unit": from_unit, "to_unit": to_unit, "result": format_number(round(result, 6))} + if name == "get_current_time": + timezone = str(args.get("timezone", "Asia/Shanghai") or "Asia/Shanghai") + if self.time_mode == "live": + return {"timezone": timezone, "datetime": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), "mode": "live_utc"} + return {"timezone": timezone, "datetime": self.time_data.get(timezone, self.time_data["Asia/Shanghai"]), "mode": "fixed"} + raise ValueError(f"unknown tool: {name}") + + +# -------------------------- schema/data helpers -------------------------- + +def normalize_tool_schema(raw_tools): + if raw_tools is None or raw_tools == "": + return None + if isinstance(raw_tools, str): + try: + raw_tools = json.loads(raw_tools) + except Exception: + return None + if isinstance(raw_tools, dict): + raw_tools = [raw_tools] + if not isinstance(raw_tools, list): + return None + tools = [] + for item in raw_tools: + if not isinstance(item, dict): + continue + if item.get("type") == "function" and isinstance(item.get("function"), dict): + fn = dict(item["function"]) + elif isinstance(item.get("function"), dict): + fn = dict(item["function"]) + elif "name" in item: + fn = { + "name": item.get("name"), + "description": item.get("description", ""), + "parameters": item.get("parameters", {"type": "object", "properties": {}}), + } + else: + continue + if isinstance(fn.get("parameters"), str): + try: + fn["parameters"] = json.loads(fn["parameters"]) + except Exception: + fn["parameters"] = {"type": "object", "properties": {}} + if fn.get("name"): + tools.append({"type": "function", "function": fn}) + return tools or None + + +def supported_tool_names(tool_specs): + if not tool_specs: + return [] + out = [] + for tool in tool_specs: + try: + name = tool.get("function", {}).get("name") + if name: + out.append(name) + except Exception: + pass + return out + + +def clone_tool_spec(tool): + return json.loads(json.dumps(tool, ensure_ascii=False)) + + +def tool_name_set(tool_specs): + return set(supported_tool_names(tool_specs)) + + +def merge_tool_specs(primary, extra, keep_alias=False, allowed_names: Optional[set] = None): + merged = [] + seen = set() + for src in (primary or []), (extra or []): + for tool in src: + name = tool.get("function", {}).get("name") if isinstance(tool, dict) else None + if not name or name in seen: + continue + if name == "calculator" and not keep_alias: + continue + if allowed_names is not None and name not in allowed_names: + continue + merged.append(clone_tool_spec(tool)) + seen.add(name) + return merged or None + + +def resolve_tool_specs(sample_tools, default_tools, force_all_tools=False, keep_alias=False, allowed_names: Optional[set] = None): + sample_only = merge_tool_specs(sample_tools, None, keep_alias=keep_alias, allowed_names=allowed_names) + default_only = merge_tool_specs(default_tools, None, keep_alias=keep_alias, allowed_names=allowed_names) + if force_all_tools: + return merge_tool_specs(sample_only, default_only, keep_alias=keep_alias, allowed_names=allowed_names) + return sample_only or default_only + + +def append_calculator_alias(tools): + if not tools: + return tools + names = tool_name_set(tools) + if "calculate_math" in names and "calculator" not in names: + tools = list(tools) + [clone_tool_spec(CALCULATOR_ALIAS_TOOL_SPEC)] + return tools + + +def prompt_text_from_messages(messages: List[Dict[str, Any]], roles: Tuple[str, ...] = ("system", "user")) -> str: + role_set = set(roles) + return "\n".join(str(m.get("content", "")) for m in messages if m.get("role") in role_set) + + +def task_text_from_messages(messages: Optional[List[Dict[str, Any]]]) -> str: + if not messages: + return "" + user_text = prompt_text_from_messages(messages, roles=("user",)) + if user_text.strip(): + return user_text + return prompt_text_from_messages(messages, roles=("system", "user")) + + +def task_text_from_prompt(prompt: str) -> str: + raw = re.sub(r"<\|tools\|>.*?<\|/tools\|>", "", str(prompt), flags=re.DOTALL) + matches = re.findall(r"<\|im_start\|>(system|user|assistant|tool)\n(.*?)<\|im_end\|>", raw, flags=re.DOTALL) + if matches: + user_parts = [content.strip() for role, content in matches if role == "user" and content.strip()] + if user_parts: + return "\n".join(user_parts) + su_parts = [content.strip() for role, content in matches if role in ("system", "user") and content.strip()] + if su_parts: + return "\n".join(su_parts) + return raw.strip() + + +def parse_tool_calls(text): + calls = [] + raw_text = str(text) + + for match in re.finditer(r"(.*?)", raw_text, flags=re.DOTALL): + raw = match.group(1).strip() + try: + obj = json.loads(raw) + name = obj.get("name") or obj.get("tool_name") or obj.get("function", {}).get("name") + args = obj.get("arguments", obj.get("args", {})) + if isinstance(args, str): + try: + args = json.loads(args) + except Exception: + args = {"expression": args} + calls.append({"name": name or "", "arguments": args if isinstance(args, dict) else {}, "canonical": True}) + except Exception: + calls.append({"name": "", "arguments": {}, "parse_error": raw[:512], "canonical": False}) + + if calls: + return calls + + # Tolerant exploration parser: calculate_math("1+1") or calculator(expression="(1+2)*3"). + for name, body in extract_function_style_tool_calls(raw_text, ("calculate_math", "calculator", "python_math")): + expr = "" + q = re.search(r"""expression\s*=\s*(['"])(.*?)\1""", body, flags=re.DOTALL) + if q: + expr = q.group(2) + else: + q = re.search(r"""(['"])(.*?)\1""", body, flags=re.DOTALL) + if q: + expr = q.group(2) + expr = str(expr).strip() + if expr: + calls.append({"name": name, "arguments": {"expression": expr}, "canonical": False}) + return calls + + +def extract_function_style_tool_calls(text: str, names: Tuple[str, ...]) -> List[Tuple[str, str]]: + out: List[Tuple[str, str]] = [] + raw = str(text) + cursor = 0 + while cursor < len(raw): + match = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", raw[cursor:]) + if not match: + break + name = match.group(1) + if name not in names: + cursor += match.end() + continue + open_idx = cursor + match.end() - 1 + close_idx = find_matching_paren(raw, open_idx) + if close_idx < 0: + cursor = open_idx + 1 + continue + out.append((name, raw[open_idx + 1:close_idx])) + cursor = close_idx + 1 + return out + + +def find_matching_paren(text: str, open_idx: int) -> int: + if open_idx < 0 or open_idx >= len(text) or text[open_idx] != "(": + return -1 + depth = 0 + quote = "" + escaped = False + for idx in range(open_idx, len(text)): + ch = text[idx] + if quote: + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if ch == quote: + quote = "" + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "(": + depth += 1 + continue + if ch == ")": + depth -= 1 + if depth == 0: + return idx + return -1 + + +class PromptPoolDataset(Dataset): + def __init__( + self, + jsonl_path, + max_samples=0, + filter_mode="all", + require_gt=True, + allowed_tools=None, + inject_answer_instruction=True, + augment_calculator_alias=False, + default_tools=None, + force_all_tools=True, + ): + self.jsonl_path = jsonl_path + self.max_samples = int(max_samples) + self.filter_mode = filter_mode + self.require_gt = bool(require_gt) + self.allowed_tools = set(allowed_tools or []) + self.inject_answer_instruction = bool(inject_answer_instruction) + self.augment_calculator_alias = bool(augment_calculator_alias) + self.default_tools = normalize_tool_schema(default_tools) or [] + self.force_all_tools = bool(force_all_tools) + self.samples = [] + self.stats = { + "loaded": 0, + "kept": 0, + "bad_json": 0, + "dropped": 0, + "skip_no_gt": 0, + "skip_unsupported_tools": 0, + "skip_filter": 0, + "augmented_calculator_alias": 0, + "forced_default_tools": 0, + "defaulted_tools": 0, + } + self._load() + + def _maybe_inject_instruction(self, messages, tool_names): + if not self.inject_answer_instruction or "calculate_math" not in tool_names: + return messages + messages = [dict(m) for m in messages] + for msg in messages: + if msg.get("role") == "system": + content = str(msg.get("content", "")).strip() + if "calculate_math" not in content or "" not in content: + msg["content"] = (content + "\n" + MATH_AGENT_SYSTEM_INSTRUCTION).strip() + return messages + return [{"role": "system", "content": MATH_AGENT_SYSTEM_INSTRUCTION}] + messages + + def _normalize(self, raw): + tools = normalize_tool_schema(raw.get("tools")) or normalize_tool_schema(raw.get("functions")) + gt = raw.get("gt", raw.get("answer", raw.get("answers", raw.get("label", [])))) + if gt is None: + gt = [] + if not isinstance(gt, list): + gt = [gt] + gt = [x for x in gt if str(x).strip()] + + messages = raw.get("messages") + if not messages and isinstance(raw.get("conversations"), list): + messages = [] + for msg in raw["conversations"]: + if not isinstance(msg, dict): + continue + role = msg.get("role", msg.get("from", "")) + role = {"human": "user", "gpt": "assistant"}.get(role, role) + content = msg.get("content", msg.get("value", "")) + if msg.get("tools") is not None: + tools = normalize_tool_schema(msg.get("tools")) or tools + if msg.get("functions") is not None: + tools = normalize_tool_schema(msg.get("functions")) or tools + messages.append({"role": role, "content": str(content)}) + if messages and messages[-1].get("role") == "assistant" and not str(messages[-1].get("content", "")).strip(): + messages = messages[:-1] + + if not messages: + prompt = raw.get("prompt") or raw.get("question") or raw.get("query") + if prompt: + messages = [{"role": "user", "content": str(prompt)}] + if not isinstance(messages, list) or not messages: + return None + + norm_messages = [] + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get("role", "user") + role = {"human": "user", "gpt": "assistant"}.get(role, role) + if role not in ("system", "user", "assistant", "tool"): + role = "user" + norm_messages.append({"role": role, "content": str(msg.get("content", msg.get("value", "")))}) + if not norm_messages: + return None + + allowed_names = set(self.allowed_tools) if self.allowed_tools else None + sample_tool_count = len(tool_name_set(merge_tool_specs(tools, None, keep_alias=self.augment_calculator_alias, allowed_names=allowed_names))) + default_tool_count = len(tool_name_set(merge_tool_specs(self.default_tools, None, keep_alias=self.augment_calculator_alias, allowed_names=allowed_names))) + tools = resolve_tool_specs( + tools, + self.default_tools, + force_all_tools=self.force_all_tools, + keep_alias=self.augment_calculator_alias, + allowed_names=allowed_names, + ) + after_n = len(tool_name_set(tools)) + if sample_tool_count <= 0 and default_tool_count > 0 and after_n > 0: + self.stats["defaulted_tools"] += 1 + if self.force_all_tools and after_n > sample_tool_count: + self.stats["forced_default_tools"] += 1 + + if self.augment_calculator_alias: + before_names = tool_name_set(tools) + tools = append_calculator_alias(tools) + after_names = tool_name_set(tools) + if "calculator" not in before_names and "calculator" in after_names: + self.stats["augmented_calculator_alias"] += 1 + + if self.require_gt and not gt: + self.stats["skip_no_gt"] += 1 + return None + tool_names = supported_tool_names(tools) + if self.allowed_tools and any(name not in self.allowed_tools for name in tool_names): + self.stats["skip_unsupported_tools"] += 1 + return None + if self.filter_mode == "math_only": + text = task_text_from_messages(norm_messages) + has_math_expr = bool(extract_arithmetic_expressions(text, max_exprs=1)) + if not has_math_expr: + self.stats["skip_filter"] += 1 + return None + norm_messages = self._maybe_inject_instruction(norm_messages, tool_names) + return {"messages": norm_messages, "tools": tools, "gt": gt, "raw": raw} + + def _load(self): + with open(self.jsonl_path, "r", encoding="utf-8") as f: + for line in f: + if self.max_samples > 0 and len(self.samples) >= self.max_samples: + break + line = line.strip() + if not line: + continue + self.stats["loaded"] += 1 + try: + raw = json.loads(line) + except Exception: + self.stats["bad_json"] += 1 + continue + sample = self._normalize(raw) + if sample is None: + self.stats["dropped"] += 1 + continue + self.samples.append(sample) + self.stats["kept"] += 1 + if not self.samples: + raise ValueError(f"No valid samples found in {self.jsonl_path}; stats={json.dumps(self.stats, ensure_ascii=False)}") + + def __len__(self): + return len(self.samples) + + def __getitem__(self, idx): + return self.samples[idx] + + +class PromptPoolSubset(Dataset): + def __init__(self, samples): + self.samples = samples + self.stats = {"kept": len(samples)} + + def __len__(self): + return len(self.samples) + + def __getitem__(self, idx): + return self.samples[idx] + + +def split_prompt_pool(dataset, eval_ratio, eval_max_samples, seed=42): + if eval_ratio <= 0 or len(dataset) < 10: + return dataset, PromptPoolSubset([]) + indices = list(range(len(dataset))) + random.Random(seed).shuffle(indices) + eval_n = min(max(1, int(len(dataset) * eval_ratio)), int(eval_max_samples), len(dataset) - 1) + eval_ids = set(indices[:eval_n]) + train_samples = [s for i, s in enumerate(dataset.samples) if i not in eval_ids] + eval_samples = [s for i, s in enumerate(dataset.samples) if i in eval_ids] + dataset.samples = train_samples + dataset.stats["kept"] = len(train_samples) + return dataset, PromptPoolSubset(eval_samples) + + +def build_fixed_eval_indices(eval_ds, eval_samples, seed=1234): + n = min(int(eval_samples), len(eval_ds)) + if n <= 0: + return [] + indices = list(range(len(eval_ds))) + random.Random(seed).shuffle(indices) + return indices[:n] + + +def shard_prompt_pool(dataset, world_size, rank): + if world_size <= 1 or dataset is None or len(dataset) == 0: + return dataset + samples = [sample for idx, sample in enumerate(dataset.samples) if idx % world_size == rank] + if not samples: + samples = [dataset.samples[rank % len(dataset.samples)]] + return PromptPoolSubset(samples) + + +# -------------------------- answer and reward helpers -------------------------- + +def strip_answer(text): + text = str(text) + text = re.sub(r"<\|im_start\|>[^\n]*\n?", "", text) + text = re.sub(r"<\|im_end\|>", "", text) + if "" in text: + text = text.split("", 1)[-1] + return text.strip() + + +def remove_tool_blocks(text): + cleaned = re.sub(r".*?", "", str(text), flags=re.DOTALL) + cleaned = re.sub(r".*?", "", cleaned, flags=re.DOTALL) + return strip_answer(cleaned).strip() + + +def answer_tag_stats(text): + clean = remove_tool_blocks(text) + open_n = len(re.findall(r"", clean, flags=re.IGNORECASE)) + close_n = len(re.findall(r"", clean, flags=re.IGNORECASE)) + matches = re.findall(r"(.*?)", clean, flags=re.DOTALL | re.IGNORECASE) + has = 1.0 if matches else 0.0 + exactly_one = 1.0 if len(matches) == 1 and open_n == 1 and close_n == 1 else 0.0 + content = strip_answer(matches[-1]) if matches else clean + nonempty = 1.0 if content.strip() else 0.0 + malformed = abs(open_n - close_n) + max(0, open_n - len(matches)) + max(0, close_n - len(matches)) + return { + "has_answer_tag": has, + "exactly_one_answer_tag": exactly_one, + "answer_tag_nonempty": nonempty, + "malformed_answer_tags": float(malformed), + "answer_content": content, + "clean_text": clean, + } + + +def extract_answer_region(text): + return answer_tag_stats(text)["answer_content"] + + +def numeric_candidates(text): + nums = [] + for match in re.finditer(r"[-+]?\d+(?:,\d{3})*(?:\.\d+)?|[-+]?\d+(?:\.\d+)?", str(text)): + try: + nums.append(float(match.group(0).replace(",", ""))) + except Exception: + pass + return nums + + +def normalize_text(x): + return re.sub(r"\s+", " ", str(x).lower().strip().replace(",", "")) + + +def _as_float_or_none(x): + try: + s = str(x).strip().replace(",", "") + if not re.fullmatch(r"[-+]?\d+(?:\.\d+)?", s): + return None + return float(s) + except Exception: + return None + + +def numeric_gt_items(gt_items): + vals = [] + for gt in gt_items or []: + f = _as_float_or_none(gt) + if f is None: + return [] + vals.append(f) + return vals + + +def num_close(a, b): + return abs(float(a) - float(b)) <= max(1e-6, abs(float(b)) * 1e-6) + + +def ordered_numeric_metrics(answer_region, gt_items): + pred_nums = numeric_candidates(answer_region) + gt_nums = numeric_gt_items(gt_items) + if not gt_nums: + return { + "pred_nums": pred_nums, + "gt_nums": gt_nums, + "ordered_match_count": 0.0, + "ordered_cov": 0.0, + "strict_exact_match": 0.0, + "order_ok": 0.0, + "extra_numbers": float(len(pred_nums)), + "missing_numbers": 0.0, + } + ordered_match = 0 + for i, gt in enumerate(gt_nums): + if i < len(pred_nums) and num_close(pred_nums[i], gt): + ordered_match += 1 + strict_exact = 1.0 if len(pred_nums) == len(gt_nums) and ordered_match == len(gt_nums) else 0.0 + order_ok = 1.0 if ordered_match == min(len(pred_nums), len(gt_nums)) and len(pred_nums) >= len(gt_nums) else 0.0 + return { + "pred_nums": pred_nums, + "gt_nums": gt_nums, + "ordered_match_count": float(ordered_match), + "ordered_cov": float(ordered_match / max(len(gt_nums), 1)), + "strict_exact_match": float(strict_exact), + "order_ok": float(order_ok), + "extra_numbers": float(max(0, len(pred_nums) - len(gt_nums))), + "missing_numbers": float(max(0, len(gt_nums) - len(pred_nums))), + } + + +def gt_matches_text(text, gt_items): + answer_text = extract_answer_region(text) + pred_nums = numeric_candidates(answer_text) + used = [False] * len(pred_nums) + norm = normalize_text(answer_text) + matched = [] + for gt in gt_items: + gt_s = normalize_text(gt) + if not gt_s: + continue + gt_f = _as_float_or_none(gt) + ok = False + if gt_f is not None: + for i, pred in enumerate(pred_nums): + if used[i]: + continue + if num_close(pred, gt_f): + used[i] = True + ok = True + break + else: + ok = gt_s in norm + if ok: + matched.append(gt) + return len(matched), matched + + +def rep_penalty(text): + toks = re.findall(r"\w+|[^\w\s]", str(text).lower()) + if len(toks) < 16: + return 0.0 + tri = [tuple(toks[i:i + 3]) for i in range(len(toks) - 2)] + repeat_rate = 1.0 - len(set(tri)) / max(len(tri), 1) + return min(0.8, max(0.0, repeat_rate - 0.25) * 2.0) + + +def flatten_json_scalars(obj): + out = [] + if isinstance(obj, dict): + for v in obj.values(): + out.extend(flatten_json_scalars(v)) + elif isinstance(obj, list): + for v in obj: + out.extend(flatten_json_scalars(v)) + elif obj is not None: + s = str(obj).strip() + if s: + out.append(s) + return out + + +def final_refs_tool_result(final_answer, tool_events): + answer_region = extract_answer_region(final_answer) + lowered = answer_region.lower() + nums = numeric_candidates(answer_region) + for event in tool_events: + if not event.get("ok"): + continue + for value in flatten_json_scalars(event.get("result", {})): + if value.lower() in lowered: + return True + v = value.replace(",", "") + if re.fullmatch(r"[-+]?\d+(?:\.\d+)?", v): + try: + target = float(v) + if any(num_close(n, target) for n in nums): + return True + except Exception: + pass + return False + + +def repeated_tool_call_penalty(tool_events): + keys = [(e.get("name", ""), json.dumps(e.get("args", {}), sort_keys=True, ensure_ascii=False)) for e in tool_events] + return min(0.8, 0.2 * (len(keys) - len(set(keys)))) if keys else 0.0 + + +def math_task_like(task_text, tools=None): + return bool(extract_arithmetic_expressions(task_text, max_exprs=1)) + + +def expected_math_call_count(task_text, gt_items): + expr_n = len(extract_arithmetic_expressions(task_text, max_exprs=16)) + gt_n = len(gt_items or []) + return max(expr_n, gt_n, 1) + + +def tool_result_float(event: Dict[str, Any]) -> Optional[float]: + result = event.get("result", {}) or {} + for key in ("value", "result"): + if isinstance(result, dict) and key in result: + f = _as_float_or_none(result.get(key)) + if f is not None: + return f + args = event.get("args", {}) or {} + expr = args.get("expression", args.get("query", "")) if isinstance(args, dict) else "" + if expr: + try: + return float(safe_eval_math(expr)) + except Exception: + return None + return None + + +def math_tool_value_metrics(task_text: str, gt_items: List[Any], tool_events: List[Dict[str, Any]]): + exprs = extract_arithmetic_expressions(task_text, max_exprs=16) + expr_vals: List[float] = [] + for expr in exprs: + try: + expr_vals.append(float(safe_eval_math(expr))) + except Exception: + pass + gt_nums = numeric_gt_items(gt_items) + # If GT length matches expression length, use GT for comparison to tolerate formatting/rounding policies. + expected_vals = gt_nums if gt_nums and len(gt_nums) == len(expr_vals) else expr_vals + expected_n = len(expected_vals) + math_events = [e for e in tool_events if e.get("name") in ("calculate_math", "calculator", "python_math")] + ok_events = [e for e in math_events if e.get("ok")] + call_vals = [tool_result_float(e) for e in ok_events] + call_vals = [v for v in call_vals if v is not None] + + ordered_match = 0 + for i, exp_v in enumerate(expected_vals): + if i < len(call_vals) and num_close(call_vals[i], exp_v): + ordered_match += 1 + used = [False] * len(call_vals) + set_match = 0 + for exp_v in expected_vals: + for j, got_v in enumerate(call_vals): + if used[j]: + continue + if num_close(got_v, exp_v): + used[j] = True + set_match += 1 + break + over_calls = max(0, len(math_events) - expected_n) if expected_n else len(math_events) + under_calls = max(0, expected_n - len(math_events)) + return { + "expected_exprs": exprs, + "expected_values": expected_vals, + "tool_values": call_vals, + "tool_value_order_match_count": float(ordered_match), + "tool_value_order_cov": float(ordered_match / max(expected_n, 1)) if expected_n else 0.0, + "tool_value_set_cov": float(set_match / max(expected_n, 1)) if expected_n else 0.0, + "tool_value_exact": float(expected_n > 0 and len(call_vals) == expected_n and ordered_match == expected_n), + "tool_over_calls": float(over_calls), + "tool_under_calls": float(under_calls), + } + + +def math_tool_expr_metrics(task_text: str, tool_events: List[Dict[str, Any]]): + expected = [canonicalize_expression(x) for x in extract_arithmetic_expressions(task_text, max_exprs=16)] + called = [] + for event in tool_events: + if event.get("name") not in ("calculate_math", "calculator", "python_math"): + continue + args = event.get("args", {}) or {} + expr = args.get("expression", args.get("query", "")) if isinstance(args, dict) else "" + expr = canonicalize_expression(expr) if expr else "" + if expr: + called.append(expr) + + ordered_match = 0 + for i, exp in enumerate(expected): + if i < len(called) and called[i] == exp: + ordered_match += 1 + used = [False] * len(called) + set_match = 0 + for exp in expected: + for j, got in enumerate(called): + if used[j]: + continue + if got == exp: + used[j] = True + set_match += 1 + break + expected_n = len(expected) + return { + "expected_expr_canon": expected, + "tool_exprs": called, + "tool_expr_order_match_count": float(ordered_match), + "tool_expr_order_cov": float(ordered_match / max(expected_n, 1)) if expected_n else 0.0, + "tool_expr_set_cov": float(set_match / max(expected_n, 1)) if expected_n else 0.0, + "tool_expr_exact": float(expected_n > 0 and len(called) == expected_n and ordered_match == expected_n), + } + + +def format_strength(args): + warmup = max(int(getattr(args, "format_warmup_steps", 0)), 0) + step = max(int(getattr(args, "current_step", 0)), 0) + if warmup <= 0: + return 1.0 + return min(1.0, step / max(warmup, 1)) + + +def scheduled_reward_cap(args, initial_attr: str, final_attr: str) -> float: + final_value = float(getattr(args, final_attr, 0.0)) + initial_value = float(getattr(args, initial_attr, final_value)) + warmup = max(int(getattr(args, "reward_cap_warmup_steps", 0)), 0) + step = max(int(getattr(args, "current_step", 0)), 0) + if warmup <= 0: + return final_value + strength = min(1.0, step / max(warmup, 1)) + return initial_value + strength * (final_value - initial_value) + + +def score_one_trajectory(args, prompt, completion, gt_items, tools, tool_events, unfinished, reward_model=None, messages=None): + task_text = task_text_from_messages(messages) if messages else task_text_from_prompt(prompt) + tag = answer_tag_stats(completion) + final_answer = remove_tool_blocks(completion) + answer_region = tag["answer_content"] + matched_count, _ = gt_matches_text(completion, gt_items) + gt_total = max(len(gt_items), 1) + unordered_cov = matched_count / gt_total if gt_items else 0.0 + + nmet = ordered_numeric_metrics(answer_region, gt_items) + has_numeric_gt = len(nmet["gt_nums"]) > 0 + answer_cov = nmet["ordered_cov"] if has_numeric_gt and bool(getattr(args, "strict_order_reward", 1)) else unordered_cov + exact_match = nmet["strict_exact_match"] if has_numeric_gt and bool(getattr(args, "strict_exact_reward", 1)) else (1.0 if gt_items and unordered_cov >= 1.0 else 0.0) + + total_calls = len(tool_events) + success_calls = sum(1 for x in tool_events if x.get("ok")) + failed_calls = total_calls - success_calls + tool_success_rate = success_calls / max(total_calls, 1) if total_calls else 0.0 + refs_tool = 1.0 if final_refs_tool_result(completion, tool_events) else 0.0 + answer_tag = tag["has_answer_tag"] + exact_answer_tag = tag["exactly_one_answer_tag"] + malformed_tags = float(abs(str(completion).count("") - str(completion).count(""))) + tag["malformed_answer_tags"] + is_math = math_task_like(task_text, tools) + expected_math_calls = expected_math_call_count(task_text, gt_items) if is_math else 0 + + preferred_math_tool_calls = sum(1 for x in tool_events if x.get("name") == "calculate_math") + alias_math_tool_calls = sum(1 for x in tool_events if x.get("name") in ("calculator", "python_math")) + math_tool_calls = preferred_math_tool_calls + alias_math_tool_calls + non_math_tool_calls = max(0, total_calls - math_tool_calls) if is_math else 0 + successful_math_calls = sum(1 for x in tool_events if x.get("ok") and x.get("name") in ("calculate_math", "calculator", "python_math")) + successful_preferred_math_calls = sum(1 for x in tool_events if x.get("ok") and x.get("name") == "calculate_math") + math_call_coverage = min(1.0, successful_math_calls / max(expected_math_calls, 1)) if is_math else 0.0 + preferred_math_rate = successful_preferred_math_calls / max(successful_math_calls, 1) if is_math and successful_math_calls else 0.0 + correct_tool_choice = 1.0 if (is_math and total_calls > 0 and non_math_tool_calls == 0 and preferred_math_tool_calls > 0) else (1.0 if not is_math else 0.0) + tool_count_exact = 1.0 if (is_math and math_tool_calls == expected_math_calls and non_math_tool_calls == 0) else (1.0 if not is_math else 0.0) + canonical_tool_rate = safe_mean([1.0 if e.get("canonical", False) else 0.0 for e in tool_events], 0.0) if tool_events else 0.0 + tvm = math_tool_value_metrics(task_text, gt_items, tool_events) if is_math else { + "tool_value_order_cov": 0.0, + "tool_value_set_cov": 0.0, + "tool_value_exact": 0.0, + "tool_over_calls": 0.0, + "tool_under_calls": 0.0, + "tool_value_order_match_count": 0.0, + } + tem = math_tool_expr_metrics(task_text, tool_events) if is_math else { + "tool_expr_order_cov": 0.0, + "tool_expr_set_cov": 0.0, + "tool_expr_exact": 0.0, + "tool_expr_order_match_count": 0.0, + } + answer_len = len(answer_region) + rep = rep_penalty(final_answer) + repeat_tool_pen = repeated_tool_call_penalty(tool_events) + + reward = float(getattr(args, "reward_base", -0.10)) + reward += float(getattr(args, "reward_answer_cov_weight", 8.0)) * answer_cov + reward += float(getattr(args, "reward_exact_match_weight", 8.0)) * exact_match + + if is_math: + if total_calls > 0: + reward += float(getattr(args, "reward_tool_success_weight", 0.60)) * tool_success_rate + reward += float(getattr(args, "reward_math_call_cov_weight", 0.60)) * math_call_coverage + reward += float(getattr(args, "reward_tool_expr_order_weight", 1.40)) * float(tem["tool_expr_order_cov"]) + reward += float(getattr(args, "reward_tool_expr_set_weight", 0.50)) * float(tem["tool_expr_set_cov"]) + reward += float(getattr(args, "reward_tool_expr_exact_weight", 0.60)) * float(tem["tool_expr_exact"]) + reward += float(getattr(args, "reward_tool_value_order_weight", 2.40)) * float(tvm["tool_value_order_cov"]) + reward += float(getattr(args, "reward_tool_value_set_weight", 0.80)) * float(tvm["tool_value_set_cov"]) + reward += float(getattr(args, "reward_tool_value_exact_weight", 0.80)) * float(tvm["tool_value_exact"]) + reward += float(getattr(args, "reward_preferred_math_tool_weight", 0.70)) * preferred_math_rate + reward += float(getattr(args, "reward_correct_tool_choice_weight", 0.70)) * correct_tool_choice + reward += float(getattr(args, "reward_tool_count_exact_weight", 0.30)) * tool_count_exact + reward += float(getattr(args, "reward_canonical_tool_weight", 0.25)) * canonical_tool_rate + if answer_cov > 0.0: + reward += float(getattr(args, "reward_final_refs_tool_weight", 0.45)) * refs_tool + reward -= float(getattr(args, "reward_tool_fail_penalty", 0.55)) * min(failed_calls, 4) + reward -= float(getattr(args, "reward_wrong_tool_penalty", 0.55)) * min(non_math_tool_calls, 4) + reward -= float(getattr(args, "reward_tool_over_call_penalty", 0.25)) * min(float(tvm["tool_over_calls"]), 6.0) + reward -= float(getattr(args, "reward_tool_under_call_penalty", 0.35)) * min(float(tvm["tool_under_calls"]), 6.0) + else: + reward -= float(getattr(args, "reward_no_tool_penalty", 1.50)) + if alias_math_tool_calls > 0 and preferred_math_tool_calls <= 0: + reward -= float(getattr(args, "reward_alias_only_penalty", 0.35)) + + strength = format_strength(args) + missing_format_penalty = float(getattr(args, "format_missing_penalty_initial", 0.30)) + strength * ( + float(getattr(args, "format_missing_penalty_final", 1.50)) - float(getattr(args, "format_missing_penalty_initial", 0.30)) + ) + reward += float(getattr(args, "reward_answer_tag_weight", 0.90)) * answer_tag + reward += float(getattr(args, "reward_exact_answer_tag_weight", 0.80)) * exact_answer_tag + if answer_tag <= 0.0 and is_math: + reward -= missing_format_penalty + if getattr(args, "format_enforce_mode", "semi_hard") in ("semi_hard", "hard"): + reward = min(reward, float(getattr(args, "no_answer_tag_reward_cap", 2.0))) + if getattr(args, "format_enforce_mode", "semi_hard") == "hard" and strength >= 1.0: + answer_cov = 0.0 + exact_match = 0.0 + reward = min(reward, 0.0) + reward += 0.10 if malformed_tags == 0 else -0.50 * malformed_tags + reward += 0.10 if 1 <= answer_len <= int(getattr(args, "max_reward_answer_len", 96)) else -0.30 + + extra_numbers = nmet["extra_numbers"] if has_numeric_gt else 0.0 + missing_numbers = nmet["missing_numbers"] if has_numeric_gt else 0.0 + order_ok = nmet["order_ok"] if has_numeric_gt else exact_match + if has_numeric_gt: + reward -= float(getattr(args, "extra_number_penalty", 0.55)) * min(extra_numbers, 6.0) + reward -= float(getattr(args, "missing_number_penalty", 0.70)) * min(missing_numbers, 6.0) + if order_ok <= 0.0 and unordered_cov > 0.0: + reward -= float(getattr(args, "order_mismatch_penalty", 1.20)) + + reward -= repeat_tool_pen + reward -= 0.80 if unfinished else 0.0 + reward -= rep + + if gt_items and exact_match < 1.0: + reward = min(reward, scheduled_reward_cap(args, "non_exact_reward_cap_initial", "non_exact_reward_cap")) + if gt_items and answer_cov <= 0.0: + reward = min(reward, scheduled_reward_cap(args, "zero_answer_cov_reward_cap_initial", "zero_answer_cov_reward_cap")) + if reward_model is not None: + try: + pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>" + messages = [{"role": role, "content": content.strip()} for role, content in re.findall(pattern, prompt, re.DOTALL)] + reward += max(min(float(reward_model.get_score(messages, final_answer)), 1.0), -1.0) + except Exception: + pass + raw_reward = float(reward) + reward = max(min(raw_reward, 12.0), -12.0) + + return reward, { + "reward": reward, + "raw_reward": raw_reward, + "clipped": float(abs(raw_reward - reward) > 1e-6), + "answer_cov": float(answer_cov), + "unordered_answer_cov": float(unordered_cov), + "exact_match": float(exact_match), + "strict_exact_match": float(nmet["strict_exact_match"] if has_numeric_gt else exact_match), + "matched_count": float(matched_count), + "ordered_match_count": float(nmet["ordered_match_count"]), + "extra_numbers": float(extra_numbers), + "missing_numbers": float(missing_numbers), + "order_ok": float(order_ok), + "tool_calls": float(total_calls), + "expected_math_calls": float(expected_math_calls), + "math_tool_calls": float(math_tool_calls), + "preferred_math_tool_calls": float(preferred_math_tool_calls), + "alias_math_tool_calls": float(alias_math_tool_calls), + "non_math_tool_calls": float(non_math_tool_calls), + "tool_success_calls": float(success_calls), + "tool_failed_calls": float(failed_calls), + "tool_success_rate": float(tool_success_rate), + "math_call_coverage": float(math_call_coverage), + "tool_expr_order_cov": float(tem["tool_expr_order_cov"]), + "tool_expr_set_cov": float(tem["tool_expr_set_cov"]), + "tool_expr_exact": float(tem["tool_expr_exact"]), + "tool_expr_order_match_count": float(tem["tool_expr_order_match_count"]), + "tool_value_order_cov": float(tvm["tool_value_order_cov"]), + "tool_value_set_cov": float(tvm["tool_value_set_cov"]), + "tool_value_exact": float(tvm["tool_value_exact"]), + "tool_value_order_match_count": float(tvm["tool_value_order_match_count"]), + "tool_over_calls": float(tvm["tool_over_calls"]), + "tool_under_calls": float(tvm["tool_under_calls"]), + "preferred_math_rate": float(preferred_math_rate), + "correct_tool_choice": float(correct_tool_choice), + "tool_count_exact": float(tool_count_exact), + "canonical_tool_rate": float(canonical_tool_rate), + "final_refs_tool": float(refs_tool), + "answer_tag": float(answer_tag), + "exact_answer_tag": float(exact_answer_tag), + "answer_tag_nonempty": float(tag["answer_tag_nonempty"]), + "malformed_tags": float(malformed_tags), + "answer_len": float(answer_len), + "unfinished": float(bool(unfinished)), + "rep_penalty": float(rep), + "repeat_tool_penalty": float(repeat_tool_pen), + "format_strength": float(strength), + } + + +def calculate_rewards(args, prompts, completions, gt_batch, tools_batch, tool_events_batch, unfinished_batch, num_generations, device, reward_model=None, return_details=False, messages_batch=None): + rewards_list, details = [], [] + for idx, completion in enumerate(completions): + sample_idx = idx // max(num_generations, 1) + reward, detail = score_one_trajectory( + args, + prompts[sample_idx], + completion, + gt_batch[sample_idx], + tools_batch[sample_idx], + tool_events_batch[idx], + unfinished_batch[idx], + reward_model, + messages_batch[sample_idx] if messages_batch is not None else None, + ) + rewards_list.append(reward) + details.append(detail) + rewards = torch.tensor(rewards_list, dtype=torch.float32, device=device) + return (rewards, details) if return_details else rewards + + +# -------------------------- rollout data structures -------------------------- + +@dataclass +class Trajectory: + prompt: str + messages: List[Dict[str, Any]] + tools: Optional[List[Dict[str, Any]]] + gt: List[Any] + completion: str + context: str + prompt_ids: List[int] + response_ids: List[int] + response_mask: List[int] + response_old_logps: List[float] + response_versions: List[int] + turn_outputs: List[str] + tool_events: List[Dict[str, Any]] + unfinished: bool + policy_version: int + data_index: int + rollout_time: float + + +@dataclass +class RolloutGroup: + prompt: str + data_index: int + policy_version: int + trajectories: List[Trajectory] + created_at: float + + +class PolicyVersion: + def __init__(self, initial=0): + self._version = int(initial) + self._lock = threading.Lock() + + def get(self): + with self._lock: + return self._version + + def bump(self): + with self._lock: + self._version += 1 + return self._version + + def set(self, value): + with self._lock: + self._version = int(value) + + +def execute_tool_call(env, valid_names, tool_call): + name = tool_call.get("name", "") or "" + raw_args = tool_call.get("arguments", tool_call.get("args", {})) + if isinstance(raw_args, str): + try: + raw_args = json.loads(raw_args) + except Exception: + raw_args = {"expression": raw_args} + if not isinstance(raw_args, dict): + raw_args = {} + if not name: + return {"ok": False, "name": name, "args": raw_args, "result": {"error": "bad tool call"}, "canonical": bool(tool_call.get("canonical", False))} + if valid_names and name not in valid_names: + return { + "ok": False, + "name": name, + "args": raw_args, + "result": {"error": f"tool not allowed: {name}"}, + "canonical": bool(tool_call.get("canonical", False)), + } + try: + return {"ok": True, "name": name, "args": raw_args, "result": env.execute(name, raw_args), "canonical": bool(tool_call.get("canonical", False))} + except Exception as exc: + return {"ok": False, "name": name, "args": raw_args, "result": {"error": str(exc)[:200]}, "canonical": bool(tool_call.get("canonical", False))} + + +def suffix_delta(old_ids, new_ids): + if len(new_ids) >= len(old_ids) and new_ids[:len(old_ids)] == old_ids: + return new_ids[len(old_ids):] + common = 0 + max_common = min(len(old_ids), len(new_ids)) + while common < max_common and old_ids[common] == new_ids[common]: + common += 1 + return new_ids[common:] + + +def rollout_single_online( + rollout_engine, + tokenizer, + env, + sample, + max_turns, + max_new_tokens, + max_context_len, + thinking_ratio, + device, + policy_version, + temperature, +): + messages = [dict(m) for m in sample["messages"]] + tools = sample["tools"] or env.tool_specs() + valid_names = set(supported_tool_names(tools)) or env.supported_names() + prompt = apply_chat_template_compat(tokenizer, messages, tools=tools, add_generation_prompt=True) + all_outputs, tool_events = [], [] + prompt_ids = None + response_ids, response_mask, response_old_logps, response_versions = [], [], [], [] + + # Full untruncated rendered context used only for observation-delta tracking. + # The packed training sequence still uses prompt_ids + response_ids as before. + current_ids_full = None + final_context = prompt + unfinished = False + open_thinking = random.random() < thinking_ratio + rollout_start = time.time() + + for turn in range(max_turns): + context = apply_chat_template_compat(tokenizer, messages, tools=tools, add_generation_prompt=True, open_thinking=open_thinking) + inputs = tokenize_context(tokenizer, context, device=device, max_context_len=max_context_len) + context_ids = inputs["input_ids"][0].tolist() + context_ids_full = tokenize_context(tokenizer, context, device="cpu", max_context_len=0)["input_ids"][0].tolist() + if prompt_ids is None: + prompt_ids = context_ids + # Source of truth for full rendered dialogue before the current assistant generation. + if current_ids_full is None: + current_ids_full = context_ids_full + elif current_ids_full != context_ids_full: + # This should rarely happen. If the chat template re-renders differently, + # use the freshly rendered context to avoid a broken suffix_delta. + current_ids_full = context_ids_full + rr = rollout_engine.rollout( + inputs["input_ids"], + inputs["attention_mask"], + num_generations=1, + max_new_tokens=max_new_tokens, + temperature=temperature, + ) + gen_ids_raw = rr.completion_ids[0].tolist() + gen_logps_raw = rr.per_token_logps[0].tolist() + pairs = [(int(tok), finite_float(logp)) for tok, logp in zip(gen_ids_raw, gen_logps_raw) if tok != tokenizer.pad_token_id and tok != tokenizer.eos_token_id] + gen_ids = [x for x, _ in pairs] + gen_logps = [x for _, x in pairs] + gen_text = rr.completions[0] + all_outputs.append(gen_text) + response_ids.extend(gen_ids) + response_mask.extend([1] * len(gen_ids)) + response_old_logps.extend(gen_logps) + response_versions.extend([policy_version] * len(gen_ids)) + assert_rollout_alignment(response_ids, response_mask, response_old_logps, response_versions) + + # Critical fix: + # current_ids_full must include the assistant generation before computing + # the observation delta. Otherwise gen_ids will be duplicated as mask=0 obs. + current_ids_after_gen_full = list(current_ids_full) + list(gen_ids) + + final_context = context + gen_text + calls = parse_tool_calls(gen_text) + if not calls: + break + + unfinished = turn == max_turns - 1 + messages.append({"role": "assistant", "content": gen_text}) + for tool_call in calls: + event = execute_tool_call(env, valid_names, tool_call) + tool_events.append(event) + messages.append({"role": "tool", "content": json.dumps(event["result"], ensure_ascii=False, separators=(",", ":"))[:1024]}) + observe_context = apply_chat_template_compat(tokenizer, messages, tools=tools, add_generation_prompt=not unfinished, open_thinking=open_thinking) + observe_ids_full = tokenize_context(tokenizer, observe_context, device="cpu", max_context_len=0)["input_ids"][0].tolist() + + obs_delta = suffix_delta(current_ids_after_gen_full, observe_ids_full) + + # Extra safety: if suffix tracking ever fails and the generated text appears + # again at the front of obs_delta, strip it instead of corrupting PPO context. + if gen_ids and len(obs_delta) >= len(gen_ids) and obs_delta[:len(gen_ids)] == gen_ids: + obs_delta = obs_delta[len(gen_ids):] + + current_ids_full = observe_ids_full + if obs_delta: + response_ids.extend(obs_delta) + response_mask.extend([0] * len(obs_delta)) + response_old_logps.extend([0.0] * len(obs_delta)) + response_versions.extend([-1] * len(obs_delta)) + assert_rollout_alignment(response_ids, response_mask, response_old_logps, response_versions) + final_context = observe_context + if unfinished: + break + + # Score the whole assistant trajectory. v3 only scored all_outputs[-1], which can lose + # an answer or format signal in multi-turn/tool-call trajectories. + completion = "\n".join(x for x in all_outputs if x is not None) + assert_rollout_alignment(response_ids, response_mask, response_old_logps, response_versions) + return Trajectory( + prompt, + sample["messages"], + tools, + sample["gt"], + completion, + final_context, + prompt_ids or [], + response_ids, + response_mask, + response_old_logps, + response_versions, + list(all_outputs), + tool_events, + unfinished, + policy_version, + -1, + time.time() - rollout_start, + ) + + +def rollout_group_online( + rollout_engine, + tokenizer, + env, + sample, + data_index, + num_generations, + max_turns, + max_new_tokens, + max_context_len, + thinking_ratio, + device, + policy_version, + temperature, +): + trajectories = [] + for _ in range(num_generations): + traj = rollout_single_online( + rollout_engine, + tokenizer, + env, + sample, + max_turns, + max_new_tokens, + max_context_len, + thinking_ratio, + device, + policy_version, + temperature, + ) + traj.data_index = data_index + trajectories.append(traj) + return RolloutGroup(trajectories[0].prompt if trajectories else "", data_index, policy_version, trajectories, time.time()) + + +class AsyncRolloutManager: + def __init__( + self, + dataset, + tokenizer, + rollout_engine, + env, + version, + batch_size, + num_generations, + max_turns, + max_new_tokens, + max_context_len, + thinking_ratio, + temperature, + device, + max_version_gap, + max_queue_groups, + num_workers, + engine_lock, + seed, + ): + self.dataset, self.tokenizer, self.rollout_engine, self.env, self.version = dataset, tokenizer, rollout_engine, env, version + self.batch_size, self.num_generations, self.max_turns, self.max_new_tokens = int(batch_size), int(num_generations), int(max_turns), int(max_new_tokens) + self.max_context_len, self.thinking_ratio, self.temperature, self.device = int(max_context_len), float(thinking_ratio), float(temperature), device + self.max_version_gap = max(0, int(max_version_gap)) + self.max_pending_groups = max(1, int(max_queue_groups)) + self.num_workers = max(0, int(num_workers)) + self.engine_lock, self.seed = engine_lock, int(seed) + self.buffer = queue.Queue(maxsize=self.max_pending_groups) + self.stop_event = threading.Event() + self.threads = [] + self.index_lock = threading.Lock() + self.next_index = 0 + self.stats_lock = threading.Lock() + self.rollout_time_hist = deque(maxlen=512) + self.train_wait_hist = deque(maxlen=512) + self.rollout_lock_wait_hist = deque(maxlen=512) + self.queue_hist = deque(maxlen=512) + self.stats = { + "produced": 0.0, + "accepted": 0.0, + "generated": 0.0, + "stale_drop": 0.0, + "errors": 0.0, + "queue_full": 0.0, + "rollout_time_sum": 0.0, + "rollout_lock_wait_sum": 0.0, + "train_wait_sum": 0.0, + "train_batches": 0.0, + "rollout_turns": 0.0, + "rollout_tool_calls": 0.0, + "rollout_tokens": 0.0, + "last_train_wait": 0.0, + "last_rollout_time": 0.0, + "last_rollout_lock_wait": 0.0, + } + + def _next_sample(self, rng): + with self.index_lock: + if rng.random() < 0.50: + idx = self.next_index % len(self.dataset) + self.next_index += 1 + else: + idx = rng.randrange(len(self.dataset)) + return idx, self.dataset[idx] + + def _inc(self, key, val=1.0): + with self.stats_lock: + self.stats[key] = self.stats.get(key, 0.0) + float(val) + + def _set(self, key, val): + with self.stats_lock: + self.stats[key] = float(val) + + def _push_hist(self, hist: deque, value: float): + with self.stats_lock: + hist.append(float(value)) + + def _producer(self, worker_id): + rank = dist.get_rank() if dist.is_initialized() else 0 + rng = random.Random(self.seed + 1009 * (worker_id + 1) + 9173 * rank) + while not self.stop_event.is_set(): + try: + if self.buffer.qsize() >= self.max_pending_groups: + self._inc("queue_full") + time.sleep(0.05) + continue + data_index, sample = self._next_sample(rng) + policy_version = self.version.get() + lock_wait_start = time.time() + with self.engine_lock: + lock_wait = time.time() - lock_wait_start + t0 = time.time() + group = rollout_group_online( + self.rollout_engine, + self.tokenizer, + self.env, + sample, + data_index, + self.num_generations, + self.max_turns, + self.max_new_tokens, + self.max_context_len, + self.thinking_ratio, + self.device, + policy_version, + self.temperature, + ) + rollout_sec = time.time() - t0 + self._inc("produced") + self._inc("generated", len(group.trajectories)) + self._inc("rollout_time_sum", rollout_sec) + self._inc("rollout_lock_wait_sum", lock_wait) + self._inc("rollout_turns", sum(len(t.turn_outputs) for t in group.trajectories)) + self._inc("rollout_tool_calls", sum(len(t.tool_events) for t in group.trajectories)) + self._inc("rollout_tokens", sum(sum(t.response_mask) for t in group.trajectories)) + self._set("last_rollout_time", rollout_sec) + self._set("last_rollout_lock_wait", lock_wait) + self._push_hist(self.rollout_time_hist, rollout_sec) + self._push_hist(self.rollout_lock_wait_hist, lock_wait) + self._push_hist(self.queue_hist, self.buffer.qsize()) + if self.version.get() - group.policy_version > self.max_version_gap: + self._inc("stale_drop") + continue + self.buffer.put(group, timeout=1.0) + self._inc("accepted") + except queue.Full: + self._inc("queue_full") + except Exception as exc: + self._inc("errors") + if is_main_process(): + Logger(f"[WARN] rollout worker {worker_id} error: {repr(exc)}") + time.sleep(0.2) + + def start(self): + for worker_id in range(self.num_workers): + thread = threading.Thread(target=self._producer, args=(worker_id,), daemon=False) + thread.start() + self.threads.append(thread) + + def get_batch(self, timeout=None): + groups = [] + start = time.time() + while len(groups) < self.batch_size and not self.stop_event.is_set(): + try: + group = self.buffer.get(timeout=timeout if timeout is not None else 60.0) + except queue.Empty: + break + if self.version.get() - group.policy_version > self.max_version_gap: + self._inc("stale_drop") + continue + groups.append(group) + wait_sec = time.time() - start + self._set("last_train_wait", wait_sec) + self._inc("train_wait_sum", wait_sec) + self._inc("train_batches") + self._push_hist(self.train_wait_hist, wait_sec) + self._push_hist(self.queue_hist, self.buffer.qsize()) + return groups + + def get_one(self, timeout=None): + start = time.time() + while not self.stop_event.is_set(): + try: + group = self.buffer.get(timeout=timeout if timeout is not None else 60.0) + except queue.Empty: + return None + if self.version.get() - group.policy_version > self.max_version_gap: + self._inc("stale_drop") + continue + wait_sec = time.time() - start + self._set("last_train_wait", wait_sec) + self._inc("train_wait_sum", wait_sec) + self._inc("train_batches") + self._push_hist(self.train_wait_hist, wait_sec) + self._push_hist(self.queue_hist, self.buffer.qsize()) + return group + return None + + def wait_until_ready(self, min_groups: int, timeout_sec: float = 180.0): + target = min(max(int(min_groups), 0), self.max_pending_groups) + if target <= 0: + return True + start = time.time() + while not self.stop_event.is_set(): + if self.buffer.qsize() >= target: + return True + if (time.time() - start) >= max(float(timeout_sec), 0.0): + return False + time.sleep(0.05) + return False + + def snapshot_stats(self): + with self.stats_lock: + out = dict(self.stats) + rollout_hist = list(self.rollout_time_hist) + train_hist = list(self.train_wait_hist) + lock_hist = list(self.rollout_lock_wait_hist) + queue_hist = list(self.queue_hist) + produced = max(out.get("produced", 0.0), 1.0) + generated = max(out.get("generated", 0.0), 1.0) + train_batches = max(out.get("train_batches", 0.0), 1.0) + out["queued"] = float(self.buffer.qsize()) + out["queue_capacity"] = float(self.max_pending_groups) + out["queue_utilization"] = out["queued"] / max(out["queue_capacity"], 1.0) + out["online_group_accept_rate"] = out.get("accepted", 0.0) / produced + out["stale_drop_rate"] = out.get("stale_drop", 0.0) / produced + out["avg_rollout_sec"] = out.get("rollout_time_sum", 0.0) / produced + out["avg_train_wait_sec"] = out.get("train_wait_sum", 0.0) / train_batches + out["avg_tokens_per_traj"] = out.get("rollout_tokens", 0.0) / generated + out["avg_turns_per_traj"] = out.get("rollout_turns", 0.0) / generated + out["avg_tool_calls_per_traj"] = out.get("rollout_tool_calls", 0.0) / generated + out["rollout_sec_p50"] = percentile(rollout_hist, 0.50) + out["rollout_sec_p90"] = percentile(rollout_hist, 0.90) + out["rollout_sec_p99"] = percentile(rollout_hist, 0.99) + out["train_wait_sec_p50"] = percentile(train_hist, 0.50) + out["train_wait_sec_p90"] = percentile(train_hist, 0.90) + out["train_wait_sec_p99"] = percentile(train_hist, 0.99) + out["rollout_lock_wait_sec_p90"] = percentile(lock_hist, 0.90) + out["queue_size_p50"] = percentile(queue_hist, 0.50) + out["queue_size_p90"] = percentile(queue_hist, 0.90) + return out + + def stop(self): + self.stop_event.set() + for thread in self.threads: + thread.join(timeout=30.0) + + +def sync_rollout_policy(args, rollout_engine, rollout_model, model, engine_lock, version): + with engine_lock: + if args.rollout_engine == "torch": + ok = copy_policy_weights(rollout_model, model) + if ok: + rollout_engine.update_policy(rollout_model) + else: + ok = bool(rollout_engine.update_policy(model)) + return version.bump() if ok else version.get() + + +def manager_pop_one_group(manager, timeout=None): + return manager.get_one(timeout=timeout) + + +def sync_rollout_policy_distributed(args, rollout_engine, rollout_model, model, engine_lock, version, src_rank=0): + raw_model = unwrap_model(model) + state_tensors = [tensor for tensor in raw_model.state_dict().values() if torch.is_tensor(tensor)] + ok = True + if dist.get_rank() == src_rank and model_has_nonfinite(model): + ok = False + ok_tensor = torch.tensor([1 if ok else 0], device=args.device, dtype=torch.int64) + dist.broadcast(ok_tensor, src=src_rank) + ok = bool(ok_tensor.item() > 0) + if ok: + for tensor in state_tensors: + dist.broadcast(tensor.data, src=src_rank) + with engine_lock: + if args.rollout_engine == "torch": + if rollout_model is not raw_model: + ok = ok and copy_policy_weights(rollout_model, raw_model) + if ok: + rollout_engine.update_policy(rollout_model) + else: + ok = ok and bool(rollout_engine.update_policy(raw_model)) + version_value = version.bump() if (dist.get_rank() == src_rank and ok) else version.get() + version_tensor = torch.tensor([int(version_value)], device=args.device, dtype=torch.int64) + dist.broadcast(version_tensor, src=src_rank) + version.set(int(version_tensor.item())) + return version.get() + + +def distributed_fetch_groups(rank, world_size, manager, pending_groups, target_groups, fetch_timeout_sec=180.0, producer_ranks=None, src_rank=0, min_return_groups=None): + start = time.time() + target_groups = max(0, int(target_groups)) + if min_return_groups is None: + min_return_groups = target_groups + min_return_groups = max(1, int(min_return_groups)) if target_groups > 0 else 0 + if target_groups > 0: + min_return_groups = min(min_return_groups, target_groups) + producers = max(1, int(producer_ranks) if producer_ranks is not None else int(world_size)) + while True: + if rank == src_rank: + available = len(pending_groups) + need = max(0, target_groups - available) + if available >= min_return_groups: + rounds = 0 + else: + rounds = 0 if need <= 0 else math.ceil(need / producers) + if need > 0 and available < min_return_groups and (time.time() - start) >= max(float(fetch_timeout_sec), 0.0): + rounds = -1 + ctrl = [int(rounds)] + else: + ctrl = [0] + dist.broadcast_object_list(ctrl, src=src_rank) + rounds = int(ctrl[0]) + if rounds < 0: + return None if rank == src_rank else False + if rounds == 0: + break + + for _ in range(rounds): + remain = max(0.0, float(fetch_timeout_sec) - (time.time() - start)) + local_group = manager_pop_one_group(manager, timeout=min(max(remain, 0.2), 30.0)) if (remain > 0.0 and rank != src_rank) else None + if rank == src_rank: + gathered = [None for _ in range(world_size)] + dist.gather_object(local_group, object_gather_list=gathered, dst=src_rank) + for item in gathered: + if item is not None: + pending_groups.append(item) + else: + dist.gather_object(local_group, dst=src_rank) + + if rank == src_rank: + if len(pending_groups) < min_return_groups: + return None + out = [pending_groups.pop() for _ in range(min(target_groups, len(pending_groups)))] + return out if out else None + return True + + +# -------------------------- packing / PPO loss -------------------------- + +def pack_groups(groups, tokenizer, max_total_len, device, num_generations, strict_group_packing=True): + prompts, messages_batch, gt_batch, tools_batch, versions = [], [], [], [], [] + trajectory_versions = [] + completions, tool_events_batch, unfinished_batch, packed = [], [], [], [] + dropped_groups = 0 + for group in groups: + if len(group.trajectories) != num_generations: + dropped_groups += 1 + continue + group_packed, group_completions, group_tool_events, group_unfinished = [], [], [], [] + for traj in group.trajectories: + ids = traj.prompt_ids + traj.response_ids + mask = [0] * len(traj.prompt_ids) + traj.response_mask + old_logps = [0.0] * max(len(traj.prompt_ids) - 1, 0) + traj.response_old_logps + token_versions = [-1] * max(len(traj.prompt_ids) - 1, 0) + traj.response_versions + if len(ids) > max_total_len: + ids = ids[-max_total_len:] + mask = mask[-max_total_len:] + old_logps = old_logps[-(len(ids) - 1):] + token_versions = token_versions[-(len(ids) - 1):] + if len(ids) < 2 or sum(mask) <= 0 or len(old_logps) != len(ids) - 1 or len(token_versions) != len(ids) - 1: + group_packed = [] + break + group_packed.append((ids, mask, old_logps, token_versions)) + group_completions.append(traj.completion) + group_tool_events.append(traj.tool_events) + group_unfinished.append(traj.unfinished) + if strict_group_packing and len(group_packed) != num_generations: + dropped_groups += 1 + continue + if not group_packed: + dropped_groups += 1 + continue + prompts.append(group.prompt) + messages_batch.append(group.trajectories[0].messages) + gt_batch.append(group.trajectories[0].gt) + tools_batch.append(group.trajectories[0].tools) + versions.append(group.policy_version) + trajectory_versions.extend([group.policy_version] * len(group_packed)) + packed.extend(group_packed) + completions.extend(group_completions) + tool_events_batch.extend(group_tool_events) + unfinished_batch.extend(group_unfinished) + if not packed: + raise RuntimeError("empty packed batch") + if len(packed) % num_generations != 0: + raise RuntimeError(f"packed trajectories not divisible by num_generations: {len(packed)} vs {num_generations}") + seq_lens = torch.tensor([len(ids) for ids, _, _, _ in packed], device=device, dtype=torch.long) + max_len = int(seq_lens.max().item()) + pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id + input_ids = torch.tensor([ids + [pad_id] * (max_len - len(ids)) for ids, _, _, _ in packed], device=device, dtype=torch.long) + full_response_masks = torch.tensor([mask + [0] * (max_len - len(mask)) for _, mask, _, _ in packed], device=device, dtype=torch.float32) + old_per_token_logps = torch.tensor([old_lp + [0.0] * ((max_len - 1) - len(old_lp)) for _, _, old_lp, _ in packed], device=device, dtype=torch.float32) + per_token_versions = torch.tensor([v + [-1] * ((max_len - 1) - len(v)) for _, _, _, v in packed], device=device, dtype=torch.long) + return { + "input_ids": input_ids, + "seq_lens": seq_lens, + "full_response_masks": full_response_masks, + "old_per_token_logps": old_per_token_logps, + "per_token_versions": per_token_versions, + "prompts": prompts, + "messages_batch": messages_batch, + "gt_batch": gt_batch, + "tools_batch": tools_batch, + "versions": versions, + "trajectory_versions": trajectory_versions, + "completions": completions, + "tool_events_batch": tool_events_batch, + "unfinished_batch": unfinished_batch, + "dropped_groups": dropped_groups, + } + + +def resolve_prox_logps(old_logps, current_logps_detached, token_versions, current_version, method="loglinear"): + old_logps = torch.nan_to_num(old_logps, nan=0.0, posinf=0.0, neginf=0.0) + current_logps_detached = torch.nan_to_num(current_logps_detached, nan=0.0, posinf=0.0, neginf=0.0) + if method == "behavior": + return old_logps + valid = token_versions >= 0 + v_behav = token_versions.float() + v_theta = torch.full_like(v_behav, float(current_version)) + v_prox = torch.full_like(v_behav, float(max(current_version - 1, 0))) + alpha = ((v_prox - v_behav) / (v_theta - v_behav).clamp(min=1.0)).clamp(min=0.0, max=1.0) + prox = old_logps + alpha * (current_logps_detached - old_logps) + return torch.where(valid, prox, old_logps) + + +def masked_stats(values, mask, prefix): + with torch.no_grad(): + selected = values[mask.bool()] + if selected.numel() == 0: + return {f"{prefix}_mean": 0.0, f"{prefix}_p05": 0.0, f"{prefix}_p50": 0.0, f"{prefix}_p95": 0.0} + selected = selected.float().detach() + return { + f"{prefix}_mean": float(selected.mean().item()), + f"{prefix}_p05": float(torch.quantile(selected, 0.05).item()), + f"{prefix}_p50": float(torch.quantile(selected, 0.50).item()), + f"{prefix}_p95": float(torch.quantile(selected, 0.95).item()), + } + + +def build_advantages(args, rewards, num_generations): + grouped_rewards = rewards.view(-1, num_generations) + group_mean = grouped_rewards.mean(dim=1).repeat_interleave(num_generations) + group_std_raw = grouped_rewards.std(dim=1, unbiased=False) + group_std = group_std_raw.repeat_interleave(num_generations) + group_adv = (rewards - group_mean) / group_std.clamp(min=max(float(args.adv_min_std), 1e-4)) + baseline = float(getattr(args, "reward_baseline", 0.0)) + scale = max(float(getattr(args, "reward_scale", 3.0)), 1e-3) + baseline_adv = (rewards - baseline) / scale + zero_group = (group_std_raw < args.adv_min_std).repeat_interleave(num_generations) + zero_std_mode = str(getattr(args, "zero_std_advantage_mode", "baseline")) + if zero_std_mode == "zero": + zero_std_adv = torch.zeros_like(baseline_adv) + elif zero_std_mode == "positive_baseline": + zero_std_adv = baseline_adv.clamp(min=0.0) + else: + zero_std_adv = baseline_adv + if args.advantage_mode == "group": + advantages = group_adv + elif args.advantage_mode == "baseline": + advantages = baseline_adv + else: + advantages = torch.where(zero_group, zero_std_adv, group_adv) + if args.adv_clip > 0: + advantages = advantages.clamp(-args.adv_clip, args.adv_clip) + return advantages, { + "reward/group_std": float(group_std_raw.mean().item()), + "reward/zero_std_group_ratio": float((group_std_raw < args.adv_min_std).float().mean().item()), + "reward/nonzero_adv_ratio": float((advantages.abs() > 1e-6).float().mean().item()), + "adv/negative_ratio": float((advantages < -1e-6).float().mean().item()), + "adv/positive_ratio": float((advantages > 1e-6).float().mean().item()), + "adv/mean": float(advantages.mean().item()), + "adv/std": float(advantages.std(unbiased=False).item()), + } + + +# -------------------------- BC auxiliary loss -------------------------- + +def canonical_tool_call(name: str, args: Dict[str, Any]) -> str: + return "" + json.dumps({"name": name, "arguments": args}, ensure_ascii=False, separators=(",", ":")) + "" + + +def build_math_bc_examples(args, tokenizer, env, messages_batch, tools_batch, gt_batch, device): + examples = [] + max_examples = int(getattr(args, "bc_max_examples", 16)) + for messages, tools, gt_items in zip(messages_batch, tools_batch, gt_batch): + if len(examples) >= max_examples: + break + tools = tools or env.tool_specs() + text = task_text_from_messages(messages) + exprs = extract_arithmetic_expressions(text, max_exprs=max(1, int(getattr(args, "bc_max_exprs", 8)))) + if not exprs: + continue + call_texts, tool_results = [], [] + for expr in exprs: + try: + result = env.execute("calculate_math", {"expression": expr}) + except Exception: + continue + call_texts.append(canonical_tool_call("calculate_math", {"expression": expr})) + tool_results.append(result) + if not call_texts: + continue + + gt_clean = [str(x).strip() for x in (gt_items or []) if str(x).strip()] + # If GT count agrees with extracted expression count, final-answer BC uses GT; otherwise use tool results. + if gt_clean and len(gt_clean) == len(tool_results): + answer_values = gt_clean + else: + answer_values = [str(x.get("result", "")) for x in tool_results] + answer_text = "" + ", ".join(answer_values) + "" + + prefix1 = apply_chat_template_compat(tokenizer, messages, tools=tools, add_generation_prompt=True) + examples.append((prefix1, "\n".join(call_texts))) + + hist = [dict(m) for m in messages] + [{"role": "assistant", "content": "\n".join(call_texts)}] + for result in tool_results: + hist.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False, separators=(",", ":"))}) + prefix2 = apply_chat_template_compat(tokenizer, hist, tools=tools, add_generation_prompt=True) + examples.append((prefix2, answer_text)) + return examples[:max_examples] + + +def bc_coef_at_step(args) -> float: + base = float(getattr(args, "bc_coef", 0.15)) + if base <= 0: + return 0.0 + step = int(getattr(args, "current_step", 0)) + hold = int(getattr(args, "bc_hold_steps", 1200)) + decay = int(getattr(args, "bc_decay_steps", 4000)) + min_coef = max(0.0, float(getattr(args, "bc_min_coef", 0.03))) + stop = int(getattr(args, "bc_stop_steps", 0)) + if stop > 0 and step > stop: + return 0.0 + if step <= hold: + return base + if decay <= 0: + return max(min_coef, 0.0) + frac = max(0.0, 1.0 - (step - hold) / max(decay, 1)) + return max(base * frac, min_coef) + + +def compute_bc_aux_loss(args, batch, model, tokenizer, env, autocast_factory): + coef = bc_coef_at_step(args) + if coef <= 0: + zero = torch.tensor(0.0, device=args.device) + return zero, {"bc/loss": 0.0, "bc/coef": 0.0, "bc/examples": 0.0} + examples = build_math_bc_examples(args, tokenizer, env, batch["messages_batch"], batch["tools_batch"], batch["gt_batch"], args.device) + if not examples: + zero = torch.tensor(0.0, device=args.device) + return zero, {"bc/loss": 0.0, "bc/coef": float(coef), "bc/examples": 0.0} + + pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id + eos_id = tokenizer.eos_token_id if tokenizer.eos_token_id is not None else pad_id + max_len = int(getattr(args, "bc_max_len", min(args.max_total_len, 1536))) + all_ids, all_labels = [], [] + for prefix, target in examples: + prefix_ids = tokenizer(prefix, add_special_tokens=False)["input_ids"] + target_ids = tokenizer(target, add_special_tokens=False)["input_ids"] + if eos_id is not None: + target_ids = target_ids + [int(eos_id)] + if len(target_ids) >= max_len: + ids = target_ids[-max_len:] + labels = ids[:] + else: + keep_prefix = max(0, max_len - len(target_ids)) + kept_prefix_ids = prefix_ids[-keep_prefix:] if keep_prefix > 0 else [] + ids = kept_prefix_ids + target_ids + labels = [-100] * len(kept_prefix_ids) + target_ids + if len(ids) < 2 or all(x == -100 for x in labels[1:]): + continue + all_ids.append(ids) + all_labels.append(labels) + + if not all_ids: + zero = torch.tensor(0.0, device=args.device) + return zero, {"bc/loss": 0.0, "bc/coef": float(coef), "bc/examples": 0.0} + + ml = max(len(x) for x in all_ids) + input_ids = torch.tensor([x + [pad_id] * (ml - len(x)) for x in all_ids], device=args.device, dtype=torch.long) + labels = torch.tensor([x + [-100] * (ml - len(x)) for x in all_labels], device=args.device, dtype=torch.long) + attn = (input_ids != pad_id).long() + with autocast_factory(): + res = unwrap_model(model)(input_ids=input_ids, attention_mask=attn) + logits = res.logits[:, :-1, :] + labels_shift = labels[:, 1:] + loss = F.cross_entropy(logits.float().reshape(-1, logits.size(-1)), labels_shift.reshape(-1), ignore_index=-100) + return loss, {"bc/loss": float(loss.detach().item()), "bc/coef": float(coef), "bc/examples": float(len(all_ids))} + + +def build_dashboard_metrics(detail, prefix="train"): + return { + f"{prefix}/accuracy_exact_match": float(detail.get("exact_match", 0.0)), + f"{prefix}/accuracy_answer_cov": float(detail.get("answer_cov", 0.0)), + f"{prefix}/format_answer_tag_rate": float(detail.get("answer_tag", 0.0)), + f"{prefix}/format_exact_answer_tag_rate": float(detail.get("exact_answer_tag", 0.0)), + f"{prefix}/tool_success_rate": float(detail.get("tool_success_rate", 0.0)), + f"{prefix}/tool_correct_choice_rate": float(detail.get("correct_tool_choice", 0.0)), + f"{prefix}/tool_math_call_coverage": float(detail.get("math_call_coverage", 0.0)), + f"{prefix}/tool_expr_order_cov": float(detail.get("tool_expr_order_cov", 0.0)), + f"{prefix}/tool_expr_set_cov": float(detail.get("tool_expr_set_cov", 0.0)), + f"{prefix}/tool_expr_exact": float(detail.get("tool_expr_exact", 0.0)), + f"{prefix}/tool_value_order_cov": float(detail.get("tool_value_order_cov", 0.0)), + f"{prefix}/tool_preferred_math_rate": float(detail.get("preferred_math_rate", 0.0)), + f"{prefix}/tool_canonical_rate": float(detail.get("canonical_tool_rate", 0.0)), + } + + +def compute_async_loss(args, batch, model, ref_model, reward_model, tokenizer, autocast_factory, lm_config, env): + input_ids = batch["input_ids"] + pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id + full_mask = (input_ids != pad_id).long() + full_response_masks = batch["full_response_masks"] + old_per_token_logps = torch.nan_to_num(batch["old_per_token_logps"], nan=0.0, posinf=0.0, neginf=0.0) + per_token_versions = batch["per_token_versions"] + + with autocast_factory(): + res = unwrap_model(model)(input_ids=input_ids, attention_mask=full_mask) + aux_loss = res.aux_loss if lm_config.use_moe else torch.tensor(0.0, device=args.device) + logits = res.logits[:, :-1, :] + logits = torch.nan_to_num(logits.float(), nan=0.0, posinf=30.0, neginf=-30.0).clamp(-80.0, 80.0) + target_ids = input_ids[:, 1:].clamp(min=0, max=logits.size(-1) - 1) + per_token_logps = F.log_softmax(logits, dim=-1).gather(2, target_ids.unsqueeze(-1)).squeeze(-1) + with torch.no_grad(): + ref_per_token_logps = compute_per_token_logps_safe(ref_model, input_ids, full_mask, autocast_factory=None) + + completion_mask = full_response_masks[:, 1:] + is_eos = (input_ids[:, 1:] == tokenizer.eos_token_id) & completion_mask.bool() + eos_idx = torch.full((completion_mask.size(0),), completion_mask.size(1) - 1, device=args.device, dtype=torch.long) + has_eos = is_eos.any(dim=1) + eos_idx[has_eos] = is_eos.int().argmax(dim=1)[has_eos] + pos = torch.arange(completion_mask.size(1), device=args.device).unsqueeze(0) + completion_mask = completion_mask * (pos <= eos_idx.unsqueeze(1)).float() + trajectory_versions = batch.get("trajectory_versions", []) + if trajectory_versions and len(trajectory_versions) == completion_mask.size(0): + row_versions = torch.tensor(trajectory_versions, device=args.device, dtype=torch.long) + row_staleness = (torch.full_like(row_versions, int(args.current_version)) - row_versions).clamp(min=0) + else: + row_staleness = torch.zeros(completion_mask.size(0), device=args.device, dtype=torch.long) + stale_discount = min(max(float(getattr(args, "async_stale_discount", 0.75)), 0.0), 1.0) + row_weights = torch.pow(torch.full_like(row_staleness, stale_discount, dtype=torch.float32), row_staleness.float()) if stale_discount < 1.0 else torch.ones_like(row_staleness, dtype=torch.float32) + + rewards, reward_details = calculate_rewards( + args, + batch["prompts"], + batch["completions"], + batch["gt_batch"], + batch["tools_batch"], + batch["tool_events_batch"], + batch["unfinished_batch"], + args.num_generations, + device=args.device, + reward_model=reward_model, + return_details=True, + messages_batch=batch["messages_batch"], + ) + advantages, adv_metrics = build_advantages(args, rewards, args.num_generations) + ref_delta = (ref_per_token_logps - per_token_logps).clamp(-20, 20) + per_token_kl = torch.exp(ref_delta) - ref_delta - 1 + + if args.use_decoupled_ppo: + prox_logps = resolve_prox_logps(old_per_token_logps, per_token_logps.detach(), per_token_versions, args.current_version, args.prox_logp_method) + ratio = torch.exp((per_token_logps - prox_logps).clamp(-10, 10)) + behav_weight = torch.exp((prox_logps - old_per_token_logps).clamp(-10, 10)).detach() + if args.behav_imp_weight_cap > 0: + behav_cap_frac = ((behav_weight > args.behav_imp_weight_cap).float() * completion_mask).sum() / completion_mask.sum().clamp(min=1.0) + behav_weight = behav_weight.clamp(max=args.behav_imp_weight_cap) + else: + behav_cap_frac = torch.tensor(0.0, device=args.device) + clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon) + surrogate = behav_weight * torch.min(ratio * advantages.unsqueeze(1), clipped_ratio * advantages.unsqueeze(1)) + m2_mask_frac = torch.tensor(0.0, device=args.device) + if args.m2_threshold > 0: + m2 = (old_per_token_logps - prox_logps).pow(2) + keep = (m2 <= args.m2_threshold).float() + m2_mask_frac = ((1.0 - keep) * completion_mask).sum() / completion_mask.sum().clamp(min=1.0) + completion_mask = completion_mask * keep + else: + ratio = torch.exp((per_token_logps - old_per_token_logps).clamp(-10, 10)) + clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon) + surrogate = torch.min(ratio * advantages.unsqueeze(1), clipped_ratio * advantages.unsqueeze(1)) + behav_weight = torch.ones_like(ratio) + behav_cap_frac = torch.tensor(0.0, device=args.device) + m2_mask_frac = torch.tensor(0.0, device=args.device) + + per_token_loss = -(surrogate - args.beta * per_token_kl) + token_counts = completion_mask.sum(dim=1) + valid_rows = token_counts > 0 + if getattr(args, "loss_reduction", "token") == "token": + weighted_mask = completion_mask * row_weights.unsqueeze(1) + policy_loss = (per_token_loss * weighted_mask).sum() / weighted_mask.sum().clamp(min=1.0) + else: + row_loss = (per_token_loss * completion_mask).sum(dim=1) / token_counts.clamp(min=1.0) + policy_loss = (row_loss[valid_rows] * row_weights[valid_rows]).sum() / row_weights[valid_rows].sum().clamp(min=1.0) if valid_rows.any() else per_token_loss.sum() * 0.0 + + bc_loss, bc_metrics = compute_bc_aux_loss(args, batch, model, tokenizer, env, autocast_factory) + bc_coef = float(bc_metrics.get("bc/coef", 0.0)) + loss = policy_loss + aux_loss + bc_coef * bc_loss + + with torch.no_grad(): + mask_denom = completion_mask.sum().clamp(min=1.0) + ratio_clip_frac = ((((ratio < 1 - args.epsilon) | (ratio > 1 + args.epsilon)).float() * completion_mask).sum() / mask_denom).item() + approx_kl = (((per_token_logps - old_per_token_logps) * completion_mask).sum() / mask_denom).item() + old_current_abs_delta = (((per_token_logps - old_per_token_logps).abs() * completion_mask).sum() / mask_denom).item() + version_gaps = torch.tensor([args.current_version - v for v in batch["versions"]], device=args.device, dtype=torch.float32) if batch["versions"] else torch.zeros(1, device=args.device) + valid_version_mask = (per_token_versions >= 0) & completion_mask.bool() + token_stale = torch.where(valid_version_mask, torch.full_like(per_token_versions, int(args.current_version)) - per_token_versions, torch.zeros_like(per_token_versions)).float() + reward_scalar = {k: safe_mean([d[k] for d in reward_details]) for k in reward_details[0].keys()} if reward_details else {} + metrics = { + "loss/total": float(loss.detach().item()), + "loss/policy": float(policy_loss.detach().item()), + "loss/aux": float(aux_loss.detach().item()), + "reward/mean": float(rewards.mean().item()), + "reward/min": float(rewards.min().item()), + "reward/max": float(rewards.max().item()), + "reward/baseline": float(getattr(args, "reward_baseline", 0.0)), + "length/avg_completion_tokens": float(token_counts.float().mean().item()), + "policy/kl_ref_k3": float(((per_token_kl * completion_mask).sum() / mask_denom).item()), + "policy/approx_kl_old": float(approx_kl), + "policy/approx_kl_old_abs": float(abs(approx_kl)), + "policy/old_current_abs_delta": float(old_current_abs_delta), + "policy/entropy": float(logits_entropy_safe(logits, completion_mask)), + "policy/ratio_clip_frac": float(ratio_clip_frac), + "policy/behav_imp_weight_cap_frac": float(behav_cap_frac.item()), + "policy/m2_mask_frac": float(m2_mask_frac.item()), + "policy/valid_completion_rows": float(valid_rows.float().mean().item()), + "async/row_staleness_mean": float(row_staleness.float().mean().item()), + "async/row_weight_mean": float(row_weights.mean().item()), + "async/staleness_group_mean": float(version_gaps.mean().item()), + "async/staleness_group_max": float(version_gaps.max().item()), + "pack/dropped_groups": float(batch.get("dropped_groups", 0)), + **adv_metrics, + **masked_stats(ratio.detach(), completion_mask, "policy/ratio"), + **masked_stats(behav_weight.detach(), completion_mask, "policy/behav_weight"), + **masked_stats(token_stale.detach(), valid_version_mask.float(), "async/token_staleness"), + **bc_metrics, + } + for k, v in reward_scalar.items(): + metrics[f"reward_detail/{k}"] = float(v) + metrics.update(build_dashboard_metrics(reward_scalar, prefix="train")) + return loss, metrics, {"rewards": [float(x) for x in rewards.detach().cpu().tolist()], "reward_details": reward_details} + + +@torch.no_grad() +def run_agent_eval(args, eval_ds, rollout_engine, tokenizer, env, engine_lock, policy_version, reward_model=None): + if eval_ds is None or len(eval_ds) == 0 or args.eval_samples <= 0: + return {} + indices = list(getattr(args, "eval_fixed_indices", [])) + if not indices: + indices = build_fixed_eval_indices(eval_ds, args.eval_samples, seed=getattr(args, "eval_seed", 1234)) + indices = indices[: min(args.eval_samples, len(indices), len(eval_ds))] + prompts, completions, gt_batch, tools_batch, events_batch, unfinished_batch, messages_batch = [], [], [], [], [], [], [] + with engine_lock: + for idx in indices: + sample = eval_ds[idx] + traj = rollout_single_online( + rollout_engine, + tokenizer, + env, + sample, + args.max_turns, + args.eval_max_gen_len if args.eval_max_gen_len > 0 else args.max_gen_len, + args.max_context_len, + 0.0, + args.device, + policy_version, + args.eval_temperature, + ) + prompts.append(traj.prompt) + messages_batch.append(traj.messages) + completions.append(traj.completion) + gt_batch.append(traj.gt) + tools_batch.append(traj.tools) + events_batch.append(traj.tool_events) + unfinished_batch.append(traj.unfinished) + rewards, details = calculate_rewards( + args, + prompts, + completions, + gt_batch, + tools_batch, + events_batch, + unfinished_batch, + 1, + args.device, + reward_model, + True, + messages_batch=messages_batch, + ) + scalar = {k: safe_mean([d[k] for d in details]) for k in details[0].keys()} if details else {} + out = { + "eval/reward": float(rewards.mean().item()), + "eval/exact_match": float(scalar.get("exact_match", 0.0)), + "eval/answer_cov": float(scalar.get("answer_cov", 0.0)), + "eval/strict_exact_match": float(scalar.get("strict_exact_match", 0.0)), + "eval/tool_success_rate": float(scalar.get("tool_success_rate", 0.0)), + "eval/tool_correct_choice_rate": float(scalar.get("correct_tool_choice", 0.0)), + "eval/math_call_coverage": float(scalar.get("math_call_coverage", 0.0)), + "eval/tool_expr_order_cov": float(scalar.get("tool_expr_order_cov", 0.0)), + "eval/tool_expr_set_cov": float(scalar.get("tool_expr_set_cov", 0.0)), + "eval/tool_expr_exact": float(scalar.get("tool_expr_exact", 0.0)), + "eval/tool_value_order_cov": float(scalar.get("tool_value_order_cov", 0.0)), + "eval/tool_value_set_cov": float(scalar.get("tool_value_set_cov", 0.0)), + "eval/tool_value_exact": float(scalar.get("tool_value_exact", 0.0)), + "eval/preferred_math_rate": float(scalar.get("preferred_math_rate", 0.0)), + "eval/tool_canonical_rate": float(scalar.get("canonical_tool_rate", 0.0)), + "eval/non_math_tool_calls": float(scalar.get("non_math_tool_calls", 0.0)), + "eval/final_refs_tool": float(scalar.get("final_refs_tool", 0.0)), + "eval/answer_tag": float(scalar.get("answer_tag", 0.0)), + "eval/exact_answer_tag": float(scalar.get("exact_answer_tag", 0.0)), + "eval/extra_numbers": float(scalar.get("extra_numbers", 0.0)), + "eval/missing_numbers": float(scalar.get("missing_numbers", 0.0)), + "eval/order_ok": float(scalar.get("order_ok", 0.0)), + "eval/avg_answer_len": float(scalar.get("answer_len", 0.0)), + "eval/unfinished_rate": float(scalar.get("unfinished", 0.0)), + "eval/reward_clipped_rate": float(scalar.get("clipped", 0.0)), + } + out.update(build_dashboard_metrics(scalar, prefix="eval_dashboard")) + return out + + +def maybe_log_debug_sample(step, groups, diagnostics, debug_interval): + if not is_main_process() or debug_interval <= 0 or step % debug_interval != 0 or not groups: + return + rewards = diagnostics.get("rewards", []) + details = diagnostics.get("reward_details", []) + group = groups[0] + Logger(f"[DEBUG] step={step}, data_index={group.data_index}, policy_version={group.policy_version}") + Logger("-" * 100) + Logger(group.prompt[:1200]) + for i, traj in enumerate(group.trajectories[:4]): + reward = rewards[i] if i < len(rewards) else None + detail = details[i] if i < len(details) else {} + Logger( + f"[DEBUG] traj={i}, reward={reward}, exact={detail.get('exact_match')}, cov={detail.get('answer_cov')}, " + f"tag={detail.get('answer_tag')}, tool_val={detail.get('tool_value_order_cov')}, " + f"tool_choice={detail.get('correct_tool_choice')}, tool_success={detail.get('tool_success_rate')}, " + f"unfinished={traj.unfinished}, events={len(traj.tool_events)}" + ) + for turn_id, turn_text in enumerate(traj.turn_outputs): + Logger(f"[DEBUG] assistant turn={turn_id}\n{turn_text[:1200]}") + if traj.tool_events: + Logger(f"[DEBUG] tool_events={json.dumps(traj.tool_events[:8], ensure_ascii=False)}") + Logger("-" * 100) + + +# -------------------------- CLI / main -------------------------- + +def build_args(): + p = argparse.ArgumentParser(description="MiniMind stable async agent RL trainer - tool-use optimized v5 for 4-GPU torch") + p.add_argument("--save_dir", type=str, default="../out") + p.add_argument("--save_weight", type=str, default="agentic_async_rl_tooluse_v5_fix") + p.add_argument("--epochs", type=int, default=1) + p.add_argument("--total_steps", type=int, default=0) + p.add_argument("--scale_total_steps_by_world_size", type=int, default=1, choices=[0, 1]) + p.add_argument("--batch_size", type=int, default=2) + p.add_argument("--learning_rate", type=float, default=2e-7) + p.add_argument("--weight_decay", type=float, default=0.01) + p.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu") + p.add_argument("--dtype", type=str, default="bfloat16", choices=["bfloat16", "float16", "float32"]) + p.add_argument("--accumulation_steps", type=int, default=2) + p.add_argument("--grad_clip", type=float, default=0.5) + p.add_argument("--log_interval", type=int, default=1) + p.add_argument("--save_interval", type=int, default=100) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--hidden_size", type=int, default=768) + p.add_argument("--num_hidden_layers", type=int, default=8) + p.add_argument("--use_moe", type=int, default=0, choices=[0, 1]) + p.add_argument("--max_seq_len", type=int, default=1024) + p.add_argument("--max_context_len", type=int, default=1024) + p.add_argument("--max_gen_len", type=int, default=192) + p.add_argument("--eval_max_gen_len", type=int, default=192) + p.add_argument("--max_total_len", type=int, default=1536) + p.add_argument("--data_path", type=str, default="/workspace/minimind/dataset/agent_rl_math.jsonl") + p.add_argument("--max_samples", type=int, default=0) + p.add_argument("--from_weight", type=str, default="full_sft") + p.add_argument("--from_resume", type=int, default=0, choices=[0, 1]) + p.add_argument("--ref_from_rl_checkpoint", type=int, default=0, choices=[0, 1]) + p.add_argument("--use_compile", type=int, default=0, choices=[0, 1]) + p.add_argument("--num_generations", type=int, default=4) + p.add_argument("--max_turns", type=int, default=3) + p.add_argument("--thinking_ratio", type=float, default=0.0) + p.add_argument("--temperature", type=float, default=0.7) + p.add_argument("--top_p", type=float, default=0.88) + p.add_argument("--top_k", type=int, default=40) + p.add_argument("--repetition_penalty", type=float, default=1.03) + p.add_argument("--torch_rollout_use_cache", type=int, default=1, choices=[0, 1]) + p.add_argument("--filter_mode", type=str, default="math_only", choices=["all", "math_only"]) + p.add_argument("--require_gt", type=int, default=1, choices=[0, 1]) + p.add_argument("--time_mode", type=str, default="fixed", choices=["fixed", "live"]) + p.add_argument("--tool_subset", type=str, default="calculate_math,unit_converter,get_current_time") + p.add_argument("--inject_answer_instruction", type=int, default=1, choices=[0, 1]) + p.add_argument("--augment_calculator_alias", type=int, default=0, choices=[0, 1]) + p.add_argument("--force_all_tools", type=int, default=0, choices=[0, 1]) + + p.add_argument("--epsilon", type=float, default=0.20) + p.add_argument("--beta", type=float, default=0.03) + p.add_argument("--use_decoupled_ppo", type=int, default=1, choices=[0, 1]) + p.add_argument("--prox_logp_method", type=str, default="loglinear", choices=["loglinear", "behavior"]) + p.add_argument("--behav_imp_weight_cap", type=float, default=1.5) + p.add_argument("--m2_threshold", type=float, default=0.0) + p.add_argument("--advantage_mode", type=str, default="mixed", choices=["group", "baseline", "mixed"]) + p.add_argument("--adv_min_std", type=float, default=0.10) + p.add_argument("--zero_std_advantage_mode", type=str, default="baseline", choices=["zero", "baseline", "positive_baseline"]) + p.add_argument("--adv_clip", type=float, default=3.0) + p.add_argument("--reward_ema_decay", type=float, default=0.98) + p.add_argument("--reward_scale", type=float, default=3.0) + p.add_argument("--beta_auto_tune", type=int, default=1, choices=[0, 1]) + p.add_argument("--beta_control_metric", type=str, default="old_current_abs_delta", choices=["old_current_abs_delta", "approx_kl_old", "kl_ref"]) + p.add_argument("--beta_update_interval", type=int, default=4) + p.add_argument("--beta_ema_decay", type=float, default=0.90) + p.add_argument("--kl_target", type=float, default=0.06) + p.add_argument("--beta_min", type=float, default=0.01) + p.add_argument("--beta_max", type=float, default=0.08) + p.add_argument("--hard_kl_stop_mult", type=float, default=8.0) + p.add_argument("--loss_reduction", type=str, default="sequence", choices=["token", "sequence"]) + + p.add_argument("--bc_coef", type=float, default=0.16) + p.add_argument("--bc_min_coef", type=float, default=0.03) + p.add_argument("--bc_stop_steps", type=int, default=0) + p.add_argument("--bc_hold_steps", type=int, default=1200) + p.add_argument("--bc_decay_steps", type=int, default=4000) + p.add_argument("--bc_max_examples", type=int, default=24) + p.add_argument("--bc_max_exprs", type=int, default=8) + p.add_argument("--bc_max_len", type=int, default=1536) + + p.add_argument("--format_enforce_mode", type=str, default="semi_hard", choices=["soft", "semi_hard", "hard"]) + p.add_argument("--format_warmup_steps", type=int, default=100) + p.add_argument("--format_missing_penalty_initial", type=float, default=0.30) + p.add_argument("--format_missing_penalty_final", type=float, default=1.50) + p.add_argument("--no_answer_tag_reward_cap", type=float, default=2.0) + p.add_argument("--strict_order_reward", type=int, default=1, choices=[0, 1]) + p.add_argument("--strict_exact_reward", type=int, default=1, choices=[0, 1]) + p.add_argument("--reward_base", type=float, default=-0.10) + p.add_argument("--reward_answer_cov_weight", type=float, default=9.0) + p.add_argument("--reward_exact_match_weight", type=float, default=9.0) + p.add_argument("--reward_answer_tag_weight", type=float, default=0.90) + p.add_argument("--reward_exact_answer_tag_weight", type=float, default=0.80) + p.add_argument("--reward_tool_success_weight", type=float, default=0.60) + p.add_argument("--reward_final_refs_tool_weight", type=float, default=0.45) + p.add_argument("--reward_tool_fail_penalty", type=float, default=0.55) + p.add_argument("--reward_no_tool_penalty", type=float, default=1.50) + p.add_argument("--reward_math_call_cov_weight", type=float, default=0.60) + p.add_argument("--reward_tool_expr_order_weight", type=float, default=0.60) + p.add_argument("--reward_tool_expr_set_weight", type=float, default=0.20) + p.add_argument("--reward_tool_expr_exact_weight", type=float, default=0.20) + p.add_argument("--reward_tool_value_order_weight", type=float, default=2.40) + p.add_argument("--reward_tool_value_set_weight", type=float, default=0.80) + p.add_argument("--reward_tool_value_exact_weight", type=float, default=0.80) + p.add_argument("--reward_tool_over_call_penalty", type=float, default=0.25) + p.add_argument("--reward_tool_under_call_penalty", type=float, default=0.35) + p.add_argument("--reward_preferred_math_tool_weight", type=float, default=0.70) + p.add_argument("--reward_correct_tool_choice_weight", type=float, default=0.70) + p.add_argument("--reward_tool_count_exact_weight", type=float, default=0.30) + p.add_argument("--reward_canonical_tool_weight", type=float, default=0.25) + p.add_argument("--reward_wrong_tool_penalty", type=float, default=0.55) + p.add_argument("--reward_alias_only_penalty", type=float, default=0.35) + p.add_argument("--extra_number_penalty", type=float, default=0.55) + p.add_argument("--missing_number_penalty", type=float, default=0.70) + p.add_argument("--order_mismatch_penalty", type=float, default=1.20) + p.add_argument("--reward_cap_warmup_steps", type=int, default=300) + p.add_argument("--non_exact_reward_cap_initial", type=float, default=4.0) + p.add_argument("--non_exact_reward_cap", type=float, default=3.5) + p.add_argument("--zero_answer_cov_reward_cap_initial", type=float, default=0.5) + p.add_argument("--zero_answer_cov_reward_cap", type=float, default=0.0) + p.add_argument("--max_reward_answer_len", type=int, default=96) + + p.add_argument("--rollout_engine", type=str, default="torch", choices=["torch", "sglang"]) + p.add_argument("--multi_gpu_mode", type=str, default="trainer_rollout", choices=["trainer_rollout", "ddp"]) + p.add_argument("--sglang_base_url", type=str, default="http://localhost:8998") + p.add_argument("--sglang_model_path", type=str, default="../model") + p.add_argument("--sglang_shared_path", type=str, default="./sglang_ckpt_agentic_async") + p.add_argument("--async_workers", type=int, default=1) + p.add_argument("--max_version_gap", type=int, default=-1) + p.add_argument("--max_head_offpolicyness", type=int, default=1) + p.add_argument("--max_queue_groups", type=int, default=0) + p.add_argument("--global_prefetch_groups", type=int, default=0) + p.add_argument("--prefill_queue_groups", type=int, default=0) + p.add_argument("--prefill_timeout_sec", type=float, default=180.0) + p.add_argument("--fetch_timeout_sec", type=float, default=180.0) + p.add_argument("--policy_sync_interval", type=int, default=1) + p.add_argument("--async_stale_discount", type=float, default=0.65) + p.add_argument("--strict_group_packing", type=int, default=1, choices=[0, 1]) + p.add_argument("--eval_ratio", type=float, default=0.02) + p.add_argument("--eval_max_pool", type=int, default=256) + p.add_argument("--eval_interval", type=int, default=100) + p.add_argument("--eval_samples", type=int, default=32) + p.add_argument("--eval_seed", type=int, default=1234) + p.add_argument("--eval_temperature", type=float, default=0.0) + p.add_argument("--eval_before_train", type=int, default=1, choices=[0, 1]) + p.add_argument("--use_reward_model", type=int, default=0, choices=[0, 1]) + p.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward") + p.add_argument("--use_wandb", default=True, type=str2bool) + p.add_argument("--wandb_project", type=str, default="MiniMind-Agentic-Async-RL") + p.add_argument("--debug_interval", type=int, default=0) + return p.parse_args() + + +def build_autocast_factory(device_type, dtype): + if device_type == "cpu" or dtype == torch.float32: + return lambda: nullcontext() + return lambda: torch.cuda.amp.autocast(dtype=dtype) + + +def maybe_step_optimizer(args, model, optimizer, scheduler): + grad_norm = 0.0 + if args.grad_clip > 0: + norm = torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) + grad_norm = float(norm.detach().item()) if torch.is_tensor(norm) else float(norm) + bad_grad = any(p.grad is not None and not torch.isfinite(p.grad).all() for p in model.parameters()) + if bad_grad or not math.isfinite(grad_norm): + Logger("[WARN] non-finite gradient; skip optimizer step") + optimizer.zero_grad(set_to_none=True) + return False, grad_norm + optimizer.step() + scheduler.step() + optimizer.zero_grad(set_to_none=True) + return True, grad_norm + + +def beta_control_metric_key(args) -> str: + metric = getattr(args, "beta_control_metric", "old_current_abs_delta") + if metric == "kl_ref": + return "policy/kl_ref_k3" + if metric == "approx_kl_old": + return "policy/approx_kl_old_abs" + return "policy/old_current_abs_delta" + + +def maybe_update_beta(args, metrics: Dict[str, Any], opt_step: int) -> float: + metric_key = beta_control_metric_key(args) + observed = finite_float(metrics.get(metric_key, 0.0), 0.0) + prev_ema = getattr(args, "beta_ema", observed) + decay = min(max(float(getattr(args, "beta_ema_decay", 0.90)), 0.0), 0.999) + ema = observed if not math.isfinite(prev_ema) else decay * prev_ema + (1.0 - decay) * observed + args.beta_ema = float(ema) + if not getattr(args, "beta_auto_tune", False): + return float(ema) + + interval = max(1, int(getattr(args, "beta_update_interval", 4))) + if opt_step % interval != 0: + return float(ema) + + target = max(finite_float(getattr(args, "kl_target", 0.08), 0.08), 1e-6) + upper = target * 1.25 + lower = target * 0.80 + max_mult = 1.12 + min_mult = 1.0 / max_mult + if ema > upper: + ratio = min(max(ema / target, 1.0), max_mult * max_mult) + args.beta = min(args.beta * max(min(math.sqrt(ratio), max_mult), 1.0), args.beta_max) + elif ema < lower: + ratio = min(max(target / max(ema, 1e-6), 1.0), max_mult * max_mult) + args.beta = max(args.beta * min(max(1.0 / math.sqrt(ratio), min_mult), 1.0), args.beta_min) + return float(ema) + + +def split_prefetched_groups(fetched_groups, pending_groups, batch_size: int, current_version: int, max_version_gap: int): + if not fetched_groups: + return None, 0 + batch_size = int(batch_size) + current_version = int(current_version) + max_version_gap = max(0, int(max_version_gap)) + stale_drops = 0 + batch = [] + remainder = [] + + def keep_group(group) -> bool: + nonlocal stale_drops + if group is None: + return False + if current_version - int(group.policy_version) > max_version_gap: + stale_drops += 1 + return False + return True + + for group in fetched_groups: + if not keep_group(group): + continue + if len(batch) < batch_size: + batch.append(group) + else: + remainder.append(group) + + while len(batch) < batch_size and pending_groups: + group = pending_groups.pop() + if keep_group(group): + batch.append(group) + + if remainder: + pending_groups.extend(reversed(remainder)) + return (batch if len(batch) >= batch_size else None), stale_drops + + +def parse_tool_subset(text: str) -> List[str]: + allowed = {"calculate_math", "unit_converter", "get_current_time"} + names = [x.strip() for x in str(text).split(",") if x.strip()] + names = [x for x in names if x in allowed] + return names or ["calculate_math", "unit_converter", "get_current_time"] + + +def main(): + args = build_args() + local_rank = init_distributed_mode_compat() + if dist.is_initialized(): + args.device = f"cuda:{local_rank}" + rank = dist.get_rank() if dist.is_initialized() else 0 + setup_seed(args.seed + rank) + use_default_max_version_gap = args.max_version_gap < 0 + args.use_decoupled_ppo = bool(args.use_decoupled_ppo) + args.reward_baseline = 0.0 + args.beta_ema = finite_float(getattr(args, "kl_target", 0.08), 0.08) + args.current_step = 0 + os.makedirs(args.save_dir, exist_ok=True) + + lm_config = MiniMindConfig( + hidden_size=args.hidden_size, + num_hidden_layers=args.num_hidden_layers, + max_seq_len=max(args.max_seq_len + args.max_gen_len, args.max_total_len, args.max_context_len + args.max_gen_len), + use_moe=bool(args.use_moe), + ) + ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir="../checkpoints") if args.from_resume == 1 else None + device_type = "cuda" if "cuda" in args.device else "cpu" + dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16 if args.dtype == "float16" else torch.float32 + autocast_factory = build_autocast_factory(device_type, dtype) + + wandb = None + if args.use_wandb and is_main_process(): + try: + import swanlab as wandb + wandb_id = ckp_data.get("wandb_id") if ckp_data else None + wandb.init(project=args.wandb_project, name=f"ToolUseV4-E{args.epochs}-B{args.batch_size}-G{args.num_generations}", id=wandb_id, resume="must" if wandb_id else None) + except Exception as exc: + Logger(f"[WARN] swanlab disabled: {repr(exc)}") + wandb = None + + train_on_this_rank = is_trainer_process(args) + model, tokenizer = init_model(lm_config, args.from_weight, device=args.device) + ensure_tokenizer_ids(tokenizer, lm_config) + ref_model = None + if train_on_this_rank: + ref_model, _ = init_model(lm_config, args.from_weight, device=args.device) + ref_model = ref_model.eval().requires_grad_(False) + rollout_model = None + if args.rollout_engine == "torch": + rollout_model, _ = init_model(lm_config, args.from_weight, device=args.device) + rollout_model = rollout_model.eval().requires_grad_(False) + + if ckp_data: + model.load_state_dict(ckp_data["model"], strict=False) + if ref_model is not None and args.ref_from_rl_checkpoint: + ref_model.load_state_dict(ckp_data["model"], strict=False) + Logger("[WARN] ref_model loaded from RL checkpoint because --ref_from_rl_checkpoint=1") + if rollout_model is not None: + rollout_model.load_state_dict(ckp_data["model"], strict=False) + args.reward_baseline = float(ckp_data.get("reward_baseline", 0.0)) + + if args.use_compile == 1 and train_on_this_rank: + model = torch.compile(model) + Logger("torch.compile enabled") + if dist.is_initialized() and not is_trainer_rollout_mode(args): + model = DistributedDataParallel(model, device_ids=[local_rank]) + elif not train_on_this_rank: + model = model.eval().requires_grad_(False) + + reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16) if args.use_reward_model and train_on_this_rank else None + if reward_model is not None: + Logger(f"Loaded reward model from {args.reward_model_path}") + + active_tools = parse_tool_subset(args.tool_subset) + env = RealToolEnv(time_mode=args.time_mode, active_tools=active_tools, allow_calculator_alias=bool(args.augment_calculator_alias)) + train_ds = PromptPoolDataset( + args.data_path, + max_samples=args.max_samples, + filter_mode=args.filter_mode, + require_gt=bool(args.require_gt), + allowed_tools=list(env.supported_names()), + inject_answer_instruction=bool(args.inject_answer_instruction), + augment_calculator_alias=bool(args.augment_calculator_alias), + default_tools=env.tool_specs(), + force_all_tools=bool(args.force_all_tools), + ) + train_ds, eval_ds = split_prompt_pool(train_ds, args.eval_ratio, args.eval_max_pool, seed=args.seed) + global_train_samples = len(train_ds) + world_size = ddp_world_size() + trainer_rollout_dist = dist.is_initialized() and is_trainer_rollout_mode(args) and world_size > 1 + rollout_ranks = max(1, world_size - 1) if trainer_rollout_dist else max(world_size, 1) + trainer_produces_rollout = not (trainer_rollout_dist and rank == trainer_rank()) + if use_default_max_version_gap: + if trainer_rollout_dist: + args.max_version_gap = max(int(args.max_head_offpolicyness), min(max(2, int(args.policy_sync_interval) * 2), 4)) + else: + args.max_version_gap = int(args.max_head_offpolicyness) + if dist.is_initialized() and not trainer_rollout_dist: + train_ds = shard_prompt_pool(train_ds, world_size, rank) + args.eval_fixed_indices = build_fixed_eval_indices(eval_ds, args.eval_samples, seed=args.eval_seed) + if args.rollout_engine == "torch" and world_size > 1 and args.async_workers > 1 and not trainer_rollout_dist: + if is_main_process(): + Logger(f"[WARN] torch rollout + DDP: async_workers {args.async_workers} -> 1 to avoid per-rank lock contention") + args.async_workers = 1 + if is_main_process(): + Logger(f"Train samples: global={global_train_samples}, local_rank0={len(train_ds)}, Eval samples: {len(eval_ds)}") + Logger(f"Dataset stats: {json.dumps(train_ds.stats, ensure_ascii=False)}") + Logger(f"Active tools: {','.join(supported_tool_names(env.tool_specs()))}") + + if args.rollout_engine == "torch": + rollout_engine = StableTorchRolloutEngine( + rollout_model, + tokenizer, + device=args.device, + autocast_factory=autocast_factory, + top_p=args.top_p, + top_k=args.top_k, + repetition_penalty=args.repetition_penalty, + use_cache=bool(args.torch_rollout_use_cache), + ) + else: + rollout_engine = create_rollout_engine( + engine_type=args.rollout_engine, + policy_model=model, + tokenizer=tokenizer, + device=args.device, + autocast_ctx=autocast_factory(), + sglang_base_url=args.sglang_base_url, + sglang_model_path=args.sglang_model_path, + sglang_shared_path=args.sglang_shared_path, + ) + + optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate, betas=(0.9, 0.95), weight_decay=args.weight_decay) if train_on_this_rank else None + if args.total_steps > 0: + if dist.is_initialized() and bool(args.scale_total_steps_by_world_size) and not trainer_rollout_dist: + total_steps = max(1, math.ceil(args.total_steps / max(ddp_world_size(), 1))) + if is_main_process(): + Logger(f"[WARN] total_steps scaled by world_size: requested={args.total_steps}, effective={total_steps}") + else: + total_steps = args.total_steps + else: + total_steps = math.ceil(len(train_ds) / args.batch_size) * args.epochs + scheduler = CosineAnnealingLR(optimizer, T_max=max(1, math.ceil(total_steps / args.accumulation_steps)), eta_min=args.learning_rate / 10) if train_on_this_rank else None + start_step = int(ckp_data.get("step", 0)) if ckp_data else 0 + opt_step = int(ckp_data.get("opt_step", 0)) if ckp_data else 0 + if ckp_data and optimizer is not None: + optimizer.load_state_dict(ckp_data["optimizer"]) + if scheduler is not None and ckp_data.get("scheduler") is not None: + scheduler.load_state_dict(ckp_data["scheduler"]) + + version = PolicyVersion(int(ckp_data.get("policy_version", 0)) if ckp_data else 0) + args.current_version = version.get() + engine_lock = threading.Lock() + if trainer_rollout_dist: + sync_rollout_policy_distributed(args, rollout_engine, rollout_model, model, engine_lock, version, src_rank=trainer_rank()) + else: + sync_rollout_policy(args, rollout_engine, rollout_model, model, engine_lock, version) + args.current_version = version.get() + + if args.eval_before_train: + if dist.is_initialized(): + dist.barrier() + if is_main_process(): + eval_metrics = run_agent_eval(args, eval_ds, rollout_engine, tokenizer, env, engine_lock, version.get(), reward_model) + if eval_metrics: + Logger( + f"[EVAL_INIT] R={eval_metrics['eval/reward']:.4f}, " + f"Exact={eval_metrics['eval/exact_match']:.3f}, Cov={eval_metrics['eval/answer_cov']:.3f}, " + f"Tag={eval_metrics['eval/answer_tag']:.3f}, ToolSucc={eval_metrics['eval/tool_success_rate']:.3f}, " + f"ToolChoice={eval_metrics['eval/tool_correct_choice_rate']:.3f}, ToolExpr={eval_metrics['eval/tool_expr_order_cov']:.3f}, ToolVal={eval_metrics['eval/tool_value_order_cov']:.3f}, " + f"Canon={eval_metrics['eval/tool_canonical_rate']:.3f}" + ) + if wandb: + wandb.log(eval_metrics, step=start_step) + if dist.is_initialized(): + dist.barrier() + + if args.max_queue_groups > 0: + max_queue_groups = int(args.max_queue_groups) + elif trainer_rollout_dist: + max_queue_groups = max((args.max_version_gap + 1) * args.batch_size, args.batch_size * max(8, rollout_ranks * 4)) + else: + max_queue_groups = (args.max_version_gap + 1) * args.batch_size + manager_workers = int(args.async_workers) if trainer_produces_rollout else 0 + manager = AsyncRolloutManager( + train_ds, + tokenizer, + rollout_engine, + env, + version, + args.batch_size, + args.num_generations, + args.max_turns, + args.max_gen_len, + args.max_context_len, + args.thinking_ratio, + args.temperature, + args.device, + args.max_version_gap, + max_queue_groups, + manager_workers, + engine_lock, + args.seed, + ) + stop_requested = {"flag": False} + + def _sig_handler(signum, frame): + stop_requested["flag"] = True + Logger("received stop signal, will save and exit safely") + + signal.signal(signal.SIGINT, _sig_handler) + signal.signal(signal.SIGTERM, _sig_handler) + manager.start() + global_queue_groups = max_queue_groups * (rollout_ranks if trainer_rollout_dist else 1) + pending_groups = deque() + prefill_capacity = global_queue_groups if trainer_rollout_dist else max_queue_groups + default_global_prefetch_groups = ( + max(args.batch_size, min(global_queue_groups, args.batch_size * max(int(args.max_version_gap) + 1, rollout_ranks))) + if trainer_rollout_dist + else args.batch_size + ) + prefill_default = max(args.batch_size, min(prefill_capacity, default_global_prefetch_groups if trainer_rollout_dist else args.batch_size * 2)) + prefill_groups = min(prefill_capacity, args.prefill_queue_groups if args.prefill_queue_groups > 0 else prefill_default) + if trainer_rollout_dist: + prefetched = distributed_fetch_groups( + rank, + world_size, + manager, + pending_groups, + prefill_groups, + fetch_timeout_sec=args.prefill_timeout_sec, + producer_ranks=rollout_ranks, + src_rank=trainer_rank(), + min_return_groups=prefill_groups, + ) + ready = prefetched is not None if rank == trainer_rank() else bool(prefetched) + if rank == trainer_rank() and prefetched is not None: + pending_groups.extend(prefetched) + else: + ready = manager.wait_until_ready(prefill_groups, timeout_sec=args.prefill_timeout_sec) + if is_main_process(): + Logger(f"Queue prefill: target={prefill_groups}, ready={int(bool(ready))}") + Logger( + f"ToolUseV4 async rollout started: engine={args.rollout_engine}, mode={args.multi_gpu_mode}, workers={manager_workers}, rollout_ranks={rollout_ranks}, " + f"max_version_gap={args.max_version_gap}, queue(local/global)={max_queue_groups}/{global_queue_groups}, decoupled={int(args.use_decoupled_ppo)}, " + f"advantage={args.advantage_mode}, beta={args.beta}, beta_max={args.beta_max}, kl_target={args.kl_target}, " + f"bc_coef={args.bc_coef}, bc_min={args.bc_min_coef}, loss_reduction={args.loss_reduction}, cache={args.torch_rollout_use_cache}, " + f"tools={','.join(supported_tool_names(env.tool_specs()))}" + ) + + if optimizer is not None: + optimizer.zero_grad(set_to_none=True) + if train_on_this_rank: + model.train() + else: + model.eval().requires_grad_(False) + last_metrics = {} + step = start_step + try: + for step in range(start_step + 1, total_steps + 1): + if trainer_rollout_dist: + loop_flag = torch.tensor([1 if (rank == trainer_rank() and not stop_requested["flag"]) else 0], device=args.device, dtype=torch.int64) + dist.broadcast(loop_flag, src=trainer_rank()) + if loop_flag.item() <= 0: + break + elif stop_requested["flag"]: + break + args.current_step = step + args.current_version = version.get() + diagnostics, metrics, groups = {}, {}, None + sync_needed = 0 + + if trainer_rollout_dist: + fetch_target = min(global_queue_groups, int(args.global_prefetch_groups) if args.global_prefetch_groups > 0 else default_global_prefetch_groups) + stale_prefetch_drops = 0 + enough_groups = False + groups = None + fetch_wait_rounds = 0 + stop_fetch = False + while True: + fetched = distributed_fetch_groups( + rank, + world_size, + manager, + pending_groups, + fetch_target, + fetch_timeout_sec=args.fetch_timeout_sec, + producer_ranks=rollout_ranks, + src_rank=trainer_rank(), + min_return_groups=args.batch_size, + ) + if rank == trainer_rank(): + groups, stale_drops_now = split_prefetched_groups(fetched, pending_groups, args.batch_size, version.get(), args.max_version_gap) + stale_prefetch_drops += stale_drops_now + enough_groups = groups is not None and len(groups) >= args.batch_size + else: + enough_groups = False + stale_drops_now = 0 + enough_tensor = torch.tensor([1 if (rank == trainer_rank() and enough_groups) else 0], device=args.device, dtype=torch.int64) + dist.broadcast(enough_tensor, src=trainer_rank()) + enough_groups = bool(enough_tensor.item() > 0) + if enough_groups: + break + fetch_ctrl = torch.tensor([1 if (rank == trainer_rank() and stop_requested["flag"]) else 0], device=args.device, dtype=torch.int64) + dist.broadcast(fetch_ctrl, src=trainer_rank()) + stop_fetch = bool(fetch_ctrl.item() > 0) + if stop_fetch: + break + if rank == trainer_rank() and fetched is None: + fetch_wait_rounds += 1 + if fetch_wait_rounds == 1 or fetch_wait_rounds % 10 == 0: + Logger("[WARN] rollout queue lagging behind trainer; continue waiting for enough fresh groups") + if stop_fetch: + break + + if rank == trainer_rank(): + pack_ok = True + try: + batch = pack_groups(groups, tokenizer, args.max_total_len, args.device, args.num_generations, bool(args.strict_group_packing)) + except RuntimeError as exc: + pack_ok = False + Logger(f"[WARN] pack_groups failed at step={step}: {repr(exc)}") + batch = None + + if pack_ok: + loss, metrics, diagnostics = compute_async_loss(args, batch, model, ref_model, reward_model, tokenizer, autocast_factory, lm_config, env) + metrics["train/world_size"] = float(world_size) + metrics["policy/kl_control"] = float(metrics.get(beta_control_metric_key(args), 0.0)) + if not bool(torch.isfinite(loss).item()): + Logger(f"[WARN] non-finite loss at step={step}; skip update") + optimizer.zero_grad(set_to_none=True) + metrics = {} + else: + kl_now = float(metrics.get("policy/kl_ref_k3", 0.0)) + kl_control_now = float(metrics.get("policy/kl_control", kl_now)) + if args.hard_kl_stop_mult > 0 and kl_control_now > args.kl_target * args.hard_kl_stop_mult: + Logger(f"[WARN] KL too high at step={step}: control={kl_control_now:.4f}, ref={kl_now:.4f}; skip update") + optimizer.zero_grad(set_to_none=True) + else: + sync_now = (step % args.accumulation_steps == 0) + with nullcontext(): + (loss / args.accumulation_steps).backward() + last_grad_norm = 0.0 + if sync_now: + updated, last_grad_norm = maybe_step_optimizer(args, model, optimizer, scheduler) + if updated: + opt_step += 1 + reward_now = float(metrics.get("reward/mean", 0.0)) + args.reward_baseline = reward_now if opt_step == 1 and abs(args.reward_baseline) < 1e-12 else args.reward_ema_decay * args.reward_baseline + (1 - args.reward_ema_decay) * reward_now + metrics["train/beta_ema"] = maybe_update_beta(args, metrics, opt_step) + if opt_step % args.policy_sync_interval == 0: + sync_needed = 1 + metrics["train/grad_norm"] = float(last_grad_norm) + metrics["train/beta"] = float(args.beta) + metrics["train/beta_ema"] = float(getattr(args, "beta_ema", 0.0)) + metrics["train/lr"] = float(optimizer.param_groups[0]["lr"]) + metrics["policy/version"] = float(version.get()) + metrics["policy/max_version_gap"] = float(args.max_version_gap) + metrics["async/prefetch_stale_drop"] = float(stale_prefetch_drops) + if args.rollout_engine == "torch": + metrics["rollout/sanitized_logits_batches"] = float(getattr(rollout_engine, "nan_batches", 0)) + metrics["rollout/bad_prob_batches"] = float(getattr(rollout_engine, "bad_prob_batches", 0)) + last_metrics = metrics + + sync_tensor = torch.tensor([int(sync_needed)], device=args.device, dtype=torch.int64) + dist.broadcast(sync_tensor, src=trainer_rank()) + if int(sync_tensor.item()) > 0: + args.current_version = sync_rollout_policy_distributed(args, rollout_engine, rollout_model, model, engine_lock, version, src_rank=trainer_rank()) + else: + args.current_version = version.get() + else: + groups = manager.get_batch(timeout=None) + enough_groups = ddp_all_true(len(groups) >= args.batch_size, args.device) + if not enough_groups: + Logger("Not enough rollout groups; stopping early.") + break + pack_ok = True + try: + batch = pack_groups(groups, tokenizer, args.max_total_len, args.device, args.num_generations, bool(args.strict_group_packing)) + except RuntimeError as exc: + pack_ok = False + Logger(f"[WARN] pack_groups failed at step={step}: {repr(exc)}") + batch = None + if not ddp_all_true(pack_ok, args.device): + optimizer.zero_grad(set_to_none=True) + continue + loss, metrics, diagnostics = compute_async_loss(args, batch, model, ref_model, reward_model, tokenizer, autocast_factory, lm_config, env) + metrics["train/world_size"] = float(ddp_world_size()) + metrics["policy/kl_control"] = float(metrics.get(beta_control_metric_key(args), 0.0)) + if not ddp_all_true(bool(torch.isfinite(loss).item()), args.device): + Logger(f"[WARN] non-finite loss at step={step}; skip update") + optimizer.zero_grad(set_to_none=True) + continue + kl_now = ddp_mean_scalar(float(metrics.get("policy/kl_ref_k3", 0.0)), args.device) + kl_control_now = ddp_mean_scalar(float(metrics.get("policy/kl_control", kl_now)), args.device) + if args.hard_kl_stop_mult > 0 and kl_control_now > args.kl_target * args.hard_kl_stop_mult: + if is_main_process(): + Logger(f"[WARN] KL too high at step={step}: control={kl_control_now:.4f}, ref={kl_now:.4f}; skip update") + optimizer.zero_grad(set_to_none=True) + continue + sync_now = (step % args.accumulation_steps == 0) + backward_ctx = model.no_sync() if isinstance(model, DistributedDataParallel) and not sync_now else nullcontext() + with backward_ctx: + (loss / args.accumulation_steps).backward() + last_grad_norm = 0.0 + if sync_now: + updated, last_grad_norm = maybe_step_optimizer(args, model, optimizer, scheduler) + if updated: + opt_step += 1 + reward_now = ddp_mean_scalar(float(metrics.get("reward/mean", 0.0)), args.device) + args.reward_baseline = reward_now if opt_step == 1 and abs(args.reward_baseline) < 1e-12 else args.reward_ema_decay * args.reward_baseline + (1 - args.reward_ema_decay) * reward_now + metrics["train/beta_ema"] = maybe_update_beta(args, metrics, opt_step) + if opt_step % args.policy_sync_interval == 0: + args.current_version = sync_rollout_policy(args, rollout_engine, rollout_model, model, engine_lock, version) + + metrics["train/grad_norm"] = float(last_grad_norm) + metrics["train/beta"] = float(args.beta) + metrics["train/beta_ema"] = float(getattr(args, "beta_ema", 0.0)) + metrics["train/lr"] = float(optimizer.param_groups[0]["lr"]) + metrics["policy/version"] = float(version.get()) + metrics["policy/max_version_gap"] = float(args.max_version_gap) + if args.rollout_engine == "torch": + metrics["rollout/sanitized_logits_batches"] = float(getattr(rollout_engine, "nan_batches", 0)) + metrics["rollout/bad_prob_batches"] = float(getattr(rollout_engine, "bad_prob_batches", 0)) + last_metrics = metrics + + if step % args.log_interval == 0 or step == total_steps: + if trainer_rollout_dist: + local_stats = manager.snapshot_stats() + stats = ddp_average_metrics(local_stats, args.device) + local_queued_total = ddp_sum_scalar(float(local_stats.get("queued", 0.0)), args.device) + metrics_to_log = dict(last_metrics) if rank == trainer_rank() and last_metrics else {} + if args.rollout_engine == "torch": + cache_prefill = ddp_mean_scalar(float(getattr(rollout_engine, "cache_prefill_tokens", 0.0)), args.device) + cache_decode = ddp_mean_scalar(float(getattr(rollout_engine, "cache_decode_tokens", 0.0)), args.device) + cache_fallbacks = ddp_mean_scalar(float(getattr(rollout_engine, "cache_fallbacks", 0.0)), args.device) + if metrics_to_log: + metrics_to_log["rollout/cache_prefill_tokens"] = cache_prefill + metrics_to_log["rollout/cache_decode_tokens"] = cache_decode + metrics_to_log["rollout/cache_decode_ratio"] = cache_decode / max(cache_prefill + cache_decode, 1.0) + metrics_to_log["rollout/cache_fallbacks"] = cache_fallbacks + if rank == trainer_rank() and metrics_to_log: + stats["local_queued_total"] = float(local_queued_total) + stats["global_pending_groups"] = float(len(pending_groups)) + stats["global_queue_capacity"] = float(global_queue_groups) + stats["global_available_groups"] = stats["local_queued_total"] + stats["global_pending_groups"] + stats["global_queue_utilization"] = stats["global_available_groups"] / max(stats["global_queue_capacity"], 1.0) + stats["rollout_ranks"] = float(rollout_ranks) + last_metrics = metrics_to_log + Logger( + f"Step:[{step}/{total_steps}] V:{version.get()} " + f"R:{metrics_to_log['reward/mean']:.3f}({metrics_to_log['reward/min']:.2f}/{metrics_to_log['reward/max']:.2f}) " + f"Acc(E/C):{metrics_to_log.get('reward_detail/exact_match', 0.0):.2f}/{metrics_to_log.get('reward_detail/answer_cov', 0.0):.2f} " + f"Fmt(T/ET/Mal):{metrics_to_log.get('reward_detail/answer_tag', 0.0):.2f}/{metrics_to_log.get('reward_detail/exact_answer_tag', 0.0):.2f}/{metrics_to_log.get('reward_detail/malformed_tags', 0.0):.2f} " + f"Tool(S/Cov/Expr/Val/Choice/Pref/Canon):{metrics_to_log.get('reward_detail/tool_success_rate', 0.0):.2f}/{metrics_to_log.get('reward_detail/math_call_coverage', 0.0):.2f}/{metrics_to_log.get('reward_detail/tool_expr_order_cov', 0.0):.2f}/{metrics_to_log.get('reward_detail/tool_value_order_cov', 0.0):.2f}/{metrics_to_log.get('reward_detail/correct_tool_choice', 0.0):.2f}/{metrics_to_log.get('reward_detail/preferred_math_rate', 0.0):.2f}/{metrics_to_log.get('reward_detail/canonical_tool_rate', 0.0):.2f} " + f"Num(+/-/Ord):{metrics_to_log.get('reward_detail/extra_numbers', 0.0):.1f}/{metrics_to_log.get('reward_detail/missing_numbers', 0.0):.1f}/{metrics_to_log.get('reward_detail/order_ok', 0.0):.2f} " + f"PPO(KL/Ent/Clip):{metrics_to_log['policy/kl_ref_k3']:.3f}/{metrics_to_log['policy/entropy']:.2f}/{metrics_to_log['policy/ratio_clip_frac']:.3f} " + f"BC:{metrics_to_log.get('bc/loss', 0.0):.3f}*{metrics_to_log.get('bc/coef', 0.0):.3f} " + f"Q(local/global):{stats['local_queued_total']:.0f}/{stats['global_available_groups']:.0f} Roll:{stats['last_rollout_time']:.2f}s Wait:{stats['last_train_wait']:.2f}s P90:{stats.get('train_wait_sec_p90', 0.0):.2f}s " + f"Tok:{stats['avg_tokens_per_traj']:.1f} LR:{optimizer.param_groups[0]['lr']:.2e} Beta:{args.beta:.3f}" + ) + if wandb: + payload = { + **{k: v for k, v in metrics_to_log.items() if isinstance(v, (int, float)) and math.isfinite(float(v))}, + **{f"async/{k}": v for k, v in stats.items() if isinstance(v, (int, float)) and math.isfinite(float(v))}, + } + wandb.log(payload, step=step) + else: + metrics = ddp_average_metrics(metrics, args.device) + if args.rollout_engine == "torch": + cache_prefill = ddp_mean_scalar(float(getattr(rollout_engine, "cache_prefill_tokens", 0.0)), args.device) + cache_decode = ddp_mean_scalar(float(getattr(rollout_engine, "cache_decode_tokens", 0.0)), args.device) + cache_fallbacks = ddp_mean_scalar(float(getattr(rollout_engine, "cache_fallbacks", 0.0)), args.device) + metrics["rollout/cache_prefill_tokens"] = cache_prefill + metrics["rollout/cache_decode_tokens"] = cache_decode + metrics["rollout/cache_decode_ratio"] = cache_decode / max(cache_prefill + cache_decode, 1.0) + metrics["rollout/cache_fallbacks"] = cache_fallbacks + stats = ddp_average_metrics(manager.snapshot_stats(), args.device) + last_metrics = metrics + Logger( + f"Step:[{step}/{total_steps}] V:{version.get()} " + f"R:{metrics['reward/mean']:.3f}({metrics['reward/min']:.2f}/{metrics['reward/max']:.2f}) " + f"Acc(E/C):{metrics.get('reward_detail/exact_match', 0.0):.2f}/{metrics.get('reward_detail/answer_cov', 0.0):.2f} " + f"Fmt(T/ET/Mal):{metrics.get('reward_detail/answer_tag', 0.0):.2f}/{metrics.get('reward_detail/exact_answer_tag', 0.0):.2f}/{metrics.get('reward_detail/malformed_tags', 0.0):.2f} " + f"Tool(S/Cov/Expr/Val/Choice/Pref/Canon):{metrics.get('reward_detail/tool_success_rate', 0.0):.2f}/{metrics.get('reward_detail/math_call_coverage', 0.0):.2f}/{metrics.get('reward_detail/tool_expr_order_cov', 0.0):.2f}/{metrics.get('reward_detail/tool_value_order_cov', 0.0):.2f}/{metrics.get('reward_detail/correct_tool_choice', 0.0):.2f}/{metrics.get('reward_detail/preferred_math_rate', 0.0):.2f}/{metrics.get('reward_detail/canonical_tool_rate', 0.0):.2f} " + f"Num(+/-/Ord):{metrics.get('reward_detail/extra_numbers', 0.0):.1f}/{metrics.get('reward_detail/missing_numbers', 0.0):.1f}/{metrics.get('reward_detail/order_ok', 0.0):.2f} " + f"PPO(KL/Ent/Clip):{metrics['policy/kl_ref_k3']:.3f}/{metrics['policy/entropy']:.2f}/{metrics['policy/ratio_clip_frac']:.3f} " + f"BC:{metrics.get('bc/loss', 0.0):.3f}*{metrics.get('bc/coef', 0.0):.3f} " + f"Q:{stats['queued']:.0f}/{stats['queue_capacity']:.0f} Roll:{stats['last_rollout_time']:.2f}s Wait:{stats['last_train_wait']:.2f}s P90:{stats.get('train_wait_sec_p90', 0.0):.2f}s " + f"Tok:{stats['avg_tokens_per_traj']:.1f} LR:{optimizer.param_groups[0]['lr']:.2e} Beta:{args.beta:.3f}" + ) + if wandb and is_main_process(): + payload = { + **{k: v for k, v in metrics.items() if isinstance(v, (int, float)) and math.isfinite(float(v))}, + **{f"async/{k}": v for k, v in stats.items() if isinstance(v, (int, float)) and math.isfinite(float(v))}, + } + wandb.log(payload, step=step) + + if not trainer_rollout_dist or rank == trainer_rank(): + maybe_log_debug_sample(step, groups, diagnostics, args.debug_interval) + if args.eval_interval > 0 and (step % args.eval_interval == 0 or step == total_steps): + if dist.is_initialized(): + dist.barrier() + if is_main_process(): + eval_metrics = run_agent_eval(args, eval_ds, rollout_engine, tokenizer, env, engine_lock, version.get(), reward_model) + if eval_metrics: + Logger( + f"[EVAL] step={step}, R={eval_metrics['eval/reward']:.4f}, " + f"Exact={eval_metrics['eval/exact_match']:.3f}, Cov={eval_metrics['eval/answer_cov']:.3f}, " + f"Tag={eval_metrics['eval/answer_tag']:.3f}, ToolSucc={eval_metrics['eval/tool_success_rate']:.3f}, " + f"ToolChoice={eval_metrics['eval/tool_correct_choice_rate']:.3f}, ToolExpr={eval_metrics['eval/tool_expr_order_cov']:.3f}, ToolVal={eval_metrics['eval/tool_value_order_cov']:.3f}, " + f"Canon={eval_metrics['eval/tool_canonical_rate']:.3f}" + ) + if wandb: + wandb.log(eval_metrics, step=step) + if dist.is_initialized(): + dist.barrier() + if args.save_interval > 0 and step % args.save_interval == 0: + save_checkpoint(args, lm_config, model, optimizer, scheduler, epoch=0, step=step, opt_step=opt_step, version=version.get(), wandb=wandb) + if step % 20 == 0: + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + save_checkpoint(args, lm_config, model, optimizer, scheduler, epoch=0, step=step, opt_step=opt_step, version=version.get(), wandb=wandb) + if last_metrics and is_main_process(): + Logger(f"Training finished. final_reward={last_metrics.get('reward/mean', 0.0):.4f}") + finally: + manager.stop() + if wandb and is_main_process(): + try: + wandb.finish() + except Exception: + pass + if dist.is_initialized(): + dist.destroy_process_group() + + +if __name__ == "__main__": + main()