初始化项目,由ModelHub XC社区提供模型

Model: ayh015/myLightningOPD
Source: Original Platform
This commit is contained in:
ModelHub XC
2026-08-27 23:50:14 +08:00
commit d4e0a1af66
368 changed files with 559583 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass
from typing import Any
from slime.utils.types import Sample
@dataclass
class RolloutFnTrainOutput:
samples: list[list[Sample]]
metrics: dict[str, Any] = None
@dataclass
class RolloutFnEvalOutput:
data: dict[str, dict[str, Any]]
metrics: dict[str, Any] = None
def call_rollout_fn(fn, *args, evaluation: bool, **kwargs):
output = fn(*args, **kwargs, evaluation=evaluation)
# compatibility for legacy version
if not isinstance(output, (RolloutFnTrainOutput, RolloutFnEvalOutput)):
output = RolloutFnEvalOutput(data=output) if evaluation else RolloutFnTrainOutput(samples=output)
return output

View File

@@ -0,0 +1,230 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import abc
import copy
import logging
import os
from pathlib import Path
import torch
from slime.utils.data import create_dataset
from slime.utils.misc import load_function
from slime.utils.processing_utils import load_processor, load_tokenizer
from slime.utils.types import Sample
logger = logging.getLogger(__name__)
class DataSource(abc.ABC):
@abc.abstractmethod
def get_samples(self, num_samples: int) -> list[list[Sample]]:
"""
Return num_samples samples
"""
@abc.abstractmethod
def add_samples(self, samples: list[list[Sample]]):
"""
Add samples to the data source
"""
@abc.abstractmethod
def save(self, rollout_id):
"""
Save the state of the data source
"""
@abc.abstractmethod
def load(self, rollout_id=None):
"""
Load the state of the data source
"""
# TODO may further refactor data-loading part later
class RolloutDataSource(DataSource):
def __init__(self, args):
self.args = args
self.epoch_id = 0
self.sample_group_index = 0
self.sample_index = 0
self.sample_offset = 0
# TODO remove this
self.metadata = {}
if args.rollout_global_dataset:
tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
# TODO move (during the refactor)
if (d := args.dump_details) is not None:
tokenizer.save_pretrained(Path(d) / "tokenizer")
if processor:
processor.save_pretrained(Path(d) / "processor")
self.dataset = create_dataset(
args.prompt_data,
tokenizer=tokenizer,
processor=processor,
max_length=args.rollout_max_prompt_len,
prompt_key=args.input_key,
multimodal_keys=args.multimodal_keys,
label_key=args.label_key,
metadata_key=args.metadata_key,
tool_key=args.tool_key,
apply_chat_template=args.apply_chat_template,
apply_chat_template_kwargs=args.apply_chat_template_kwargs,
seed=args.rollout_seed,
)
if self.args.rollout_shuffle:
self.dataset.shuffle(self.epoch_id)
else:
self.dataset = None
def get_samples(self, num_samples):
# TODO further improve code
if self.dataset is not None:
if self.sample_offset + num_samples <= len(self.dataset):
prompt_samples = self.dataset.samples[self.sample_offset : self.sample_offset + num_samples]
self.sample_offset += num_samples
else:
prompt_samples = self.dataset.samples[self.sample_offset :]
num_samples -= len(prompt_samples)
self.epoch_id += 1
if self.args.rollout_shuffle:
self.dataset.shuffle(self.epoch_id)
prompt_samples += self.dataset.samples[:num_samples]
self.sample_offset = num_samples
else:
prompt_samples = [Sample() for _ in range(num_samples)]
samples = []
for prompt_sample in prompt_samples:
group = []
for _ in range(self.args.n_samples_per_prompt):
sample = copy.deepcopy(prompt_sample)
sample.group_index = self.sample_group_index
sample.index = self.sample_index
self.sample_index += 1
group.append(sample)
self.sample_group_index += 1
samples.append(group)
return samples
def add_samples(self, samples: list[list[Sample]]):
raise RuntimeError(f"Cannot add samples to {self.__class__.__name__}. This is a read-only data source.")
def save(self, rollout_id):
if not self.args.rollout_global_dataset:
return
state_dict = {
"sample_offset": self.sample_offset,
"epoch_id": self.epoch_id,
"sample_group_index": self.sample_group_index,
"sample_index": self.sample_index,
"metadata": self.metadata,
# Save wandb_run_id for resume support
"wandb_run_id": getattr(self.args, "wandb_run_id", None),
}
path = os.path.join(self.args.save, f"rollout/global_dataset_state_dict_{rollout_id}.pt")
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save(state_dict, path)
def load(self, rollout_id=None):
if not self.args.rollout_global_dataset:
return
if self.args.load is None:
return
path = os.path.join(self.args.load, f"rollout/global_dataset_state_dict_{rollout_id}.pt")
if not os.path.exists(path):
logger.info(f"Checkpoint {path} does not exist.")
return
logger.info(f"load metadata from {path}")
logger.info(f"load metadata: {self.metadata}")
state_dict = torch.load(path)
self.sample_offset = state_dict.get("sample_offset", 0)
self.epoch_id = state_dict.get("epoch_id", 0)
self.sample_group_index = state_dict.get("sample_group_index", 0)
self.sample_index = state_dict.get("sample_index", 0)
self.metadata = state_dict.get("metadata", {})
# Load wandb_run_id for resume support (only if not already set)
if not getattr(self.args, "wandb_run_id", None):
loaded_wandb_run_id = state_dict.get("wandb_run_id")
if loaded_wandb_run_id:
self.args.wandb_run_id = loaded_wandb_run_id
logger.info(f"Loaded wandb_run_id from checkpoint: {loaded_wandb_run_id}")
if self.args.rollout_global_dataset and self.args.rollout_shuffle:
self.dataset.shuffle(self.epoch_id)
class RolloutDataSourceWithBuffer(RolloutDataSource):
def __init__(self, args):
super().__init__(args)
self.buffer = []
if self.args.buffer_filter_path is None:
self.buffer_filter = pop_first
else:
self.buffer_filter = load_function(self.args.buffer_filter_path)
def get_samples(self, num_samples: int) -> list[list[Sample]]:
"""
Return num_samples samples
"""
samples = self._get_samples_from_buffer(num_samples)
num_samples -= len(samples)
if num_samples == 0:
return samples
samples += super().get_samples(num_samples=num_samples)
return samples
def _get_samples_from_buffer(self, num_samples: int) -> list[list[Sample]]:
if len(self.buffer) == 0 or num_samples == 0:
return []
samples = self.buffer_filter(self.args, None, self.buffer, num_samples)
return samples
def add_samples(self, samples: list[list[Sample]]):
"""
Add a sample group to buffer.
"""
if not samples:
return
assert isinstance(samples, list), f"samples must be a list, got {type(samples)}"
assert isinstance(samples[0], list), f"the elements of samples must be list, got {type(samples[0])}"
for i in range(0, len(samples)):
assert (
len(samples[i]) == self.args.n_samples_per_prompt
), f"the length of the elements of samples must be equal to n_samples_per_prompt, got {len(samples[i])} != {self.args.n_samples_per_prompt}"
group = samples[i] # type: ignore
self.buffer.append(group)
# TODO remove
def update_metadata(self, metadata: dict):
self.metadata.update(metadata)
# TODO remove
def get_metadata(self):
return self.metadata
def get_buffer_length(self):
return len(self.buffer)
def pop_first(args, rollout_id, buffer: list[list[Sample]], num_samples: int) -> list[list[Sample]]:
num_to_pop = min(len(buffer), num_samples)
samples = buffer[:num_to_pop]
del buffer[:num_to_pop]
return samples

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

View File

@@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass
@dataclass
class DynamicFilterOutput:
keep: bool
reason: str | None = None

View File

@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import torch
from slime.rollout.filter_hub.base_types import DynamicFilterOutput
from slime.utils.types import Sample
__all__ = ["check_reward_nonzero_std"]
def check_reward_nonzero_std(args, samples: list[Sample], **kwargs):
rewards = [sample.get_reward_value(args) for sample in samples]
keep = torch.tensor(rewards, dtype=torch.float).std() > 0.0
return DynamicFilterOutput(
keep=keep,
reason=None if keep else f"zero_std_{round(rewards[0], 1)}",
)

View File

@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import aiohttp
import torch
from slime.utils.processing_utils import encode_image_for_rollout_engine
from slime.utils.types import Sample
async def reward_func(args, sample, **kwargs):
# For Lightning OPD: teacher log-probs are pre-computed in metadata,
# no teacher server call needed. Return a sentinel so post_process_rewards knows.
metadata = sample.metadata or {}
if metadata.get("is_lightning_opd", False) or metadata.get("is_offline_opd", False):
return {"lightning_opd": True}
payload = {
"input_ids": sample.tokens,
"sampling_params": {
"temperature": 0,
"max_new_tokens": 0,
"skip_special_tokens": False,
},
"return_logprob": True,
"logprob_start_len": 0,
}
if sample.multimodal_inputs and sample.multimodal_inputs.get("images"):
image_data = sample.multimodal_inputs["images"]
payload["image_data"] = [encode_image_for_rollout_engine(image) for image in image_data]
session_kwargs = {}
async with aiohttp.ClientSession(**session_kwargs) as session:
async with session.post(args.rm_url, json=payload) as resp:
resp.raise_for_status()
return await resp.json()
def post_process_rewards(args, samples: list[Sample], **kwargs):
"""Process rewards from teacher model and extract teacher log probabilities.
This function:
1. Extracts teacher log-probs from the reward response (which contains sglang's logprob output)
2. Trims them to match the response length
3. Stores them in sample.teacher_log_probs for OPD KL penalty computation
4. Returns scalar rewards (0.0 for pure distillation) compatible with GRPO/PPO
For Lightning OPD, teacher log-probs are pre-computed in the parquet
metadata instead of being fetched from a teacher server at runtime.
"""
raw_rewards = [sample.get_reward_value(args) for sample in samples]
response_lengths = [sample.response_length for sample in samples]
for i, (sample, reward) in enumerate(zip(samples, raw_rewards)):
metadata = sample.metadata or {}
if isinstance(reward, dict) and reward.get("lightning_opd"):
# Lightning OPD: teacher log-probs are pre-computed in metadata
pre_teacher_lp = metadata.get("teacher_log_probs", [])
sample.teacher_log_probs = torch.tensor(
[float(x) for x in pre_teacher_lp], dtype=torch.float32
)
else:
# Online OPD: extract teacher log-probs from sglang response
t_log_probs = torch.tensor(
[item[0] for item in reward["meta_info"]["input_token_logprobs"][1:]],
dtype=torch.float32,
)
sample.teacher_log_probs = t_log_probs[-response_lengths[i]:]
scalar_rewards = [0.0] * len(samples)
return scalar_rewards, scalar_rewards

View File

@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio
import random
import aiohttp
from slime.utils.misc import load_function
from slime.utils.types import Sample
from .deepscaler import get_deepscaler_rule_based_reward
from .f1 import f1_score
from .gpqa import compute_gpqa_reward
from .math_dapo_utils import compute_score as compute_score_dapo
from .math_utils import extract_answer as extract_boxed_answer
from .math_utils import grade_answer_verl
async def remote_rm(args, sample: Sample):
payload = {
"prompt": sample.prompt,
"response": sample.response,
"label": sample.label,
}
session_kwargs = {}
async with aiohttp.ClientSession(**session_kwargs) as session:
async with session.post(args.rm_url, json=payload) as resp:
resp.raise_for_status()
return await resp.json()
async def async_rm(args, sample: Sample, **kwargs):
if args.custom_rm_path is not None:
rm_function = load_function(args.custom_rm_path)
return await rm_function(args, sample, **kwargs)
metadata = sample.metadata if isinstance(sample.metadata, dict) else {}
rm_type = (metadata.get("rm_type") or args.rm_type or "").strip()
response = sample.response
label = sample.label
if rm_type.startswith("boxed_"):
response = extract_boxed_answer(response) or ""
rm_type = rm_type[len("boxed_") :]
# This function is intended for remote or time-consuming reward model evaluation.
# Implement the actual logic as needed.
if rm_type == "remote_rm":
return await remote_rm(args, sample)
elif rm_type == "deepscaler":
return get_deepscaler_rule_based_reward(response, label)
elif rm_type == "dapo":
return compute_score_dapo(response, label)
elif rm_type == "math":
return 1 if grade_answer_verl(response, label) else 0
elif rm_type == "f1":
return f1_score(response, label)[0]
elif rm_type == "gpqa":
return compute_gpqa_reward(response, label, metadata=metadata)
elif rm_type == "ifbench":
from .ifbench import compute_ifbench_reward
return compute_ifbench_reward(response, label, metadata=metadata)
elif rm_type == "random":
return random.randint(0, 1)
elif rm_type:
raise NotImplementedError(f"Rule-based RM for {rm_type} is not implemented.")
else:
raise NotImplementedError("Rule-based RM type is not specified.")
async def batched_async_rm(
args,
samples: list[Sample],
**kwargs,
) -> list[int | float]:
if args.custom_rm_path is not None:
# Ensure the custom reward function is implemented in batch mode
rm_function = load_function(args.custom_rm_path)
return await rm_function(args, samples, **kwargs)
tasks = [async_rm(args, sample, **kwargs) for sample in samples]
rewards = await asyncio.gather(*tasks)
return rewards

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from .math_utils import extract_answer, grade_answer_mathd, grade_answer_sympy
def get_deepscaler_rule_based_reward(response, label):
if "</think>" in response:
model_solution = response.split("</think>")[-1]
elif "###Response" in response:
model_solution = response.split("###Response")[1]
else:
return 0
model_answer = extract_answer(model_solution)
if model_answer is None:
return 0
if label == "":
return 0
# Convert single answer to list for uniform processing
assert isinstance(label, (str, float, int))
ground_truths = [label]
# Process each ground truth
processed_ground_truths = []
for truth in ground_truths:
truth = str(truth)
if "\\boxed" in truth:
processed_truth = extract_answer(truth)
if processed_truth is not None:
processed_ground_truths.append(processed_truth)
else:
processed_ground_truths.append(truth)
if not processed_ground_truths:
return 0
# Check against all possible correct answers
for ground_truth in processed_ground_truths:
is_correct = grade_answer_mathd(model_answer, ground_truth) or grade_answer_sympy(model_answer, ground_truth)
if is_correct:
return 1
return 0

View File

@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import re
import string
from collections import Counter
def normalize_answer(s):
def remove_articles(text):
return re.sub(r"\b(a|an|the)\b", " ", text)
def white_space_fix(text):
return " ".join(text.split())
def remove_punc(text):
exclude = set(string.punctuation)
return "".join(ch for ch in text if ch not in exclude)
def lower(text):
return text.lower()
return white_space_fix(remove_articles(remove_punc(lower(s))))
def f1_score(prediction, ground_truth):
ZERO_METRIC = (0, 0, 0)
if prediction is None:
return ZERO_METRIC
normalized_prediction = normalize_answer(prediction)
normalized_ground_truth = normalize_answer(ground_truth)
if normalized_prediction in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth:
return ZERO_METRIC
if normalized_ground_truth in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth:
return ZERO_METRIC
prediction_tokens = normalized_prediction.split()
ground_truth_tokens = normalized_ground_truth.split()
common = Counter(prediction_tokens) & Counter(ground_truth_tokens)
num_same = sum(common.values())
if num_same == 0:
return ZERO_METRIC
precision = 1.0 * num_same / len(prediction_tokens)
recall = 1.0 * num_same / len(ground_truth_tokens)
f1 = (2 * precision * recall) / (precision + recall)
return f1, precision, recall

View File

@@ -0,0 +1,132 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import re
import string
from collections.abc import Iterable
DEFAULT_VALID_LETTERS = list(string.ascii_uppercase[:8])
def _strip_chain_of_thought(text: str) -> str:
if not text:
return ""
if "</think>" in text:
return text.rsplit("</think>", 1)[-1]
return text
def _normalize_text(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
def _extract_letter_from_response(response: str, valid_letters: Iterable[str]) -> str | None:
"""
Best-effort extraction of the selected option letter from the model response.
"""
if not response:
return None
text = _strip_chain_of_thought(response)
patterns = [
r"(?:answer|option|choice)\s*(?:is|:)?\s*([A-Z])",
r"([A-Z])\s*(?:is\s*(?:the)?\s*correct)",
r"final\s*(?:answer|option)\s*(?:is|:)?\s*([A-Z])",
]
valid_letters = {letter.upper() for letter in valid_letters}
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
letter = match.group(1).upper()
if letter in valid_letters:
return letter
# Fallback: last standalone capital letter that is valid.
candidates = re.findall(r"\b([A-Z])\b", text)
for letter in reversed(candidates):
letter = letter.upper()
if letter in valid_letters:
return letter
return None
def compute_gpqa_reward(response: str, label, metadata: dict | None = None) -> float:
"""Rule-based scorer for GPQA-style multiple-choice evaluation."""
if response is None:
return 0.0
metadata = metadata or {}
choices = metadata.get("choices")
if isinstance(choices, dict):
choices = list(choices.values())
elif choices is not None:
choices = list(choices)
valid_letters = metadata.get("valid_letters")
if valid_letters:
valid_letters = [str(letter).upper() for letter in valid_letters]
elif choices:
valid_letters = list(string.ascii_uppercase[: len(choices)])
else:
valid_letters = DEFAULT_VALID_LETTERS
correct_letter = metadata.get("correct_letter")
if isinstance(correct_letter, str):
correct_letter = correct_letter.strip().upper()
else:
correct_letter = None
label_text = None
if isinstance(label, str):
label_text = label.strip()
if len(label_text) == 1 and label_text.upper() in valid_letters and not correct_letter:
correct_letter = label_text.upper()
elif isinstance(label, (int, float)):
idx = int(label)
if 0 <= idx < len(valid_letters):
correct_letter = valid_letters[idx]
if not correct_letter and choices and label_text:
normalized_label = _normalize_text(label_text)
for idx, choice in enumerate(choices):
if _normalize_text(str(choice)) == normalized_label:
correct_letter = valid_letters[idx]
metadata.setdefault("correct_answer", choice)
break
extracted_letter = _extract_letter_from_response(response, valid_letters)
if extracted_letter and correct_letter:
return 1.0 if extracted_letter == correct_letter else 0.0
candidate_answers = []
if correct_letter and choices:
try:
idx = valid_letters.index(correct_letter)
except ValueError:
idx = None
if idx is not None and idx < len(choices):
candidate_answers.append(str(choices[idx]))
for key in ("correct_answer", "answer_text"):
value = metadata.get(key)
if value:
candidate_answers.append(str(value))
if label_text:
candidate_answers.append(label_text)
normalized_targets = {_normalize_text(text) for text in candidate_answers if text}
normalized_response = _normalize_text(_strip_chain_of_thought(response))
for target in normalized_targets:
if target and target in normalized_response:
return 1.0
if extracted_letter and not correct_letter and label_text:
return 1.0 if extracted_letter == label_text.strip().upper() else 0.0
return 0.0

View File

@@ -0,0 +1,173 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import importlib
import logging
import os
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_WORKSPACE_ROOT = Path(__file__).resolve().parents[3]
_WORKSPACE_PARENT = _WORKSPACE_ROOT.parent
_LOCAL_IFBENCH_REQUIREMENTS = _WORKSPACE_ROOT / "examples" / "eval_multi_task" / "requirements_ifbench.txt"
def _ensure_ifbench_repo() -> Path:
"""Clone IFBench repo if needed and ensure it is available on sys.path."""
repo_path = _WORKSPACE_PARENT / "IFBench"
if not repo_path.exists():
clone_cmd = ["git", "clone", "https://github.com/allenai/IFBench.git", str(repo_path)]
try:
subprocess.run(clone_cmd, check=True, capture_output=True)
except Exception as exc:
raise ImportError(
"Unable to automatically clone IFBench. Please clone "
"https://github.com/allenai/IFBench.git into the repo root."
) from exc
repo_str = str(repo_path)
if repo_str not in sys.path:
sys.path.insert(0, repo_str)
current_pythonpath = os.environ.get("PYTHONPATH")
if current_pythonpath is None:
os.environ["PYTHONPATH"] = repo_str
elif repo_str not in current_pythonpath.split(os.pathsep):
os.environ["PYTHONPATH"] = os.pathsep.join([repo_str, current_pythonpath])
return repo_path
def _ensure_ifbench_dependencies(repo_path: Path) -> None:
"""Install IFBench requirements the first time the module is imported."""
requirements_file = _LOCAL_IFBENCH_REQUIREMENTS
if not requirements_file.exists():
logger.debug("Local IFBench requirements file not found at %s; skipping install.", requirements_file)
return
sentinel = repo_path / ".deps_installed"
if sentinel.exists():
return
install_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
try:
subprocess.run(install_cmd, check=True)
except Exception as exc:
logger.warning("Failed to install IFBench dependencies automatically: %s", exc)
else:
sentinel.write_text("installed\n")
def _load_evaluation_lib():
repo_path = _ensure_ifbench_repo()
try:
return importlib.import_module("evaluation_lib")
except ImportError:
_ensure_ifbench_dependencies(repo_path)
return importlib.import_module("evaluation_lib")
evaluation_lib = _load_evaluation_lib()
InputExample = evaluation_lib.InputExample
JsonDict = dict[str, Any]
KwargsDict = dict[str, str | int | float | None]
def _normalize_instruction_ids(raw_ids: Sequence[Any]) -> list[str]:
"""Ensure instruction identifiers are clean strings."""
normalized: list[str] = []
for entry in raw_ids or []:
if entry is None:
continue
text = str(entry).strip()
if not text:
continue
normalized.append(text)
return normalized
def _coerce_kwargs_list(
raw_kwargs: Any,
num_instructions: int,
) -> list[KwargsDict]:
"""Convert stored kwargs into the list structure expected by IFBench."""
if isinstance(raw_kwargs, list):
processed: list[KwargsDict] = []
for entry in raw_kwargs:
if isinstance(entry, dict):
processed.append(dict(entry))
else:
processed.append({})
elif isinstance(raw_kwargs, dict):
processed = [dict(raw_kwargs) for _ in range(num_instructions)]
else:
processed = [{} for _ in range(num_instructions)]
if len(processed) < num_instructions:
tail = processed[-1] if processed else {}
processed.extend([dict(tail) for _ in range(num_instructions - len(processed))])
elif len(processed) > num_instructions:
processed = processed[:num_instructions]
# Remove explicit None values to match official preprocessing.
sanitized: list[KwargsDict] = []
for entry in processed:
sanitized.append({k: v for k, v in entry.items() if v is not None})
return sanitized
def _build_input_example(metadata: JsonDict) -> InputExample | None:
instruction_ids = _normalize_instruction_ids(metadata.get("instruction_id_list") or [])
if not instruction_ids:
logger.debug("Missing instruction identifiers in metadata: %s", metadata)
return None
prompt_text = metadata.get("prompt_text")
if prompt_text is None:
prompt_text = ""
else:
prompt_text = str(prompt_text)
raw_kwargs = metadata.get("kwargs")
kwargs_list = _coerce_kwargs_list(raw_kwargs, len(instruction_ids))
return InputExample(
key=int(metadata.get("record_id") or 0),
instruction_id_list=instruction_ids,
prompt=prompt_text,
kwargs=kwargs_list,
)
def compute_ifbench_reward(response: str, label: Any, metadata: JsonDict | None = None) -> float:
"""Score a model response using the official IFBench rules."""
if metadata is None:
logger.debug("No metadata provided for IFBench scoring.")
return 0.0
if response is None:
return 0.0
inp = _build_input_example(metadata)
if inp is None:
return 0.0
prompt_to_response = {inp.prompt: str(response or "")}
output = evaluation_lib.test_instruction_following_strict(inp, prompt_to_response)
return 1.0 if output.follow_all_instructions else 0.0

View File

@@ -0,0 +1,292 @@
# Copyright 2024 Bytedance Ltd. and/or its affiliates
# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Adapted from https://github.com/EleutherAI/lm-evaluation-harness/blob/main/lm_eval/tasks/hendrycks_math/utils.py
import re
import signal
def last_boxed_only_string(string: str) -> str | None:
"""Extract the last LaTeX boxed expression from a string.
Args:
string: Input string containing LaTeX code
Returns:
The last boxed expression or None if not found
"""
idx = string.rfind("\\boxed{")
if idx < 0:
return None
i = idx
right_brace_idx = None
num_left_braces_open = 0
while i < len(string):
if string[i] == "{":
num_left_braces_open += 1
if string[i] == "}":
num_left_braces_open -= 1
if num_left_braces_open == 0:
right_brace_idx = i
break
i += 1
return string[idx : right_brace_idx + 1] if right_brace_idx is not None else None
def remove_boxed(s: str) -> str:
"""Remove the LaTeX boxed command from a string.
Args:
s: String with format "\\boxed{content}"
Returns:
The content inside the boxed command
"""
left = "\\boxed{"
assert s[: len(left)] == left, f"box error: {s}"
assert s[-1] == "}", f"box error: {s}"
return s[len(left) : -1]
class timeout:
def __init__(self, seconds=1, error_message="Timeout"):
self.seconds = seconds
self.error_message = error_message
def handle_timeout(self, signum, frame):
raise TimeoutError(self.error_message)
def __enter__(self):
signal.signal(signal.SIGALRM, self.handle_timeout)
signal.alarm(self.seconds)
def __exit__(self, type, value, traceback):
signal.alarm(0)
# Constants for normalization
SUBSTITUTIONS = [
("an ", ""),
("a ", ""),
(".$", "$"),
("\\$", ""),
(r"\ ", ""),
(" ", ""),
("mbox", "text"),
(",\\text{and}", ","),
("\\text{and}", ","),
("\\text{m}", "\\text{}"),
]
REMOVED_EXPRESSIONS = [
"square",
"ways",
"integers",
"dollars",
"mph",
"inches",
"hours",
"km",
"units",
"\\ldots",
"sue",
"points",
"feet",
"minutes",
"digits",
"cents",
"degrees",
"cm",
"gm",
"pounds",
"meters",
"meals",
"edges",
"students",
"childrentickets",
"multiples",
"\\text{s}",
"\\text{.}",
"\\text{\ns}",
"\\text{}^2",
"\\text{}^3",
"\\text{\n}",
"\\text{}",
r"\mathrm{th}",
r"^\circ",
r"^{\circ}",
r"\;",
r",\!",
"{,}",
'"',
"\\dots",
"<|im_end|>",
"<|endoftext|>",
]
def normalize_final_answer(final_answer: str) -> str:
"""Normalize a final answer to a quantitative reasoning question.
Args:
final_answer: The answer string to normalize
Returns:
Normalized answer string
"""
final_answer = str(final_answer)
final_answer = final_answer.split("=")[-1]
# Apply substitutions and removals
for before, after in SUBSTITUTIONS:
final_answer = final_answer.replace(before, after)
for expr in REMOVED_EXPRESSIONS:
final_answer = final_answer.replace(expr, "")
# Extract and normalize LaTeX math
final_answer = re.sub(r"(.*?)(\$)(.*?)(\$)(.*)", "$\\3$", final_answer)
final_answer = re.sub(r"(\\text\{)(.*?)(\})", "\\2", final_answer)
final_answer = re.sub(r"(\\textbf\{)(.*?)(\})", "\\2", final_answer)
final_answer = re.sub(r"(\\overline\{)(.*?)(\})", "\\2", final_answer)
final_answer = re.sub(r"(\\boxed\{)(.*)(\})", "\\2", final_answer)
# Normalize shorthand TeX:
# \fracab -> \frac{a}{b}
# \frac{abc}{bef} -> \frac{abc}{bef}
# \fracabc -> \frac{a}{b}c
# \sqrta -> \sqrt{a}
# \sqrtab -> sqrt{a}b
final_answer = re.sub(r"(frac)([^{])(.)", "frac{\\2}{\\3}", final_answer)
final_answer = re.sub(r"(sqrt)([^{])", "sqrt{\\2}", final_answer)
final_answer = final_answer.replace("$", "")
# Normalize numbers
if final_answer.replace(",", "").isdigit():
final_answer = final_answer.replace(",", "")
return final_answer.strip()
def is_correct_minerva(
solution_str: str, gt: str, gt_need_extract: bool = False, answer_pattern: str = r"(?i)Answer\s*:\s*([^\n]+)"
) -> tuple[bool, str]:
"""Check if the solution is correct according to Minerva criteria.
Args:
solution_str: The solution string to check
gt: The ground truth answer
gt_need_extract: Whether the ground truth needs extraction
answer_pattern: Regex pattern to extract the answer
Returns:
Tuple of (is_correct, normalized_prediction)
"""
# Extract answer from solution
match = re.findall(answer_pattern, solution_str)
extracted_answer = match[-1] if match else "[INVALID]"
pred = normalize_final_answer(extracted_answer)
# Process ground truth
if gt_need_extract:
gt = normalize_final_answer(remove_boxed(last_boxed_only_string(gt)))
else:
gt = normalize_final_answer(gt)
gt = str(int(float(gt))) # in dapo, all answers are integers
return (pred == gt), pred
def is_correct_strict_box(pred: str, gt: str, pause_tokens_index: list[int] | None = None) -> tuple[int, str | None]:
"""Check if the prediction is correct using strict boxed answer criteria.
Args:
pred: The prediction string
gt: The ground truth answer
pause_tokens_index: Indices of pause tokens
Returns:
Tuple of (score, extracted_prediction)
"""
# Extract the relevant part of the prediction
if pause_tokens_index is not None:
assert len(pause_tokens_index) == 4
pred = pred[pause_tokens_index[-1] - 100 :]
else:
pred = pred[-100:]
# Extract and check the boxed answer
boxed_pred = last_boxed_only_string(pred)
extracted_pred = remove_boxed(boxed_pred) if boxed_pred is not None else None
return 1 if (extracted_pred == gt) else -1, extracted_pred
def verify(
solution_str: str, answer: str, strict_box_verify: bool = False, pause_tokens_index: list[int] | None = None
) -> bool:
"""Verify if the solution is correct.
Args:
solution_str: The solution string to verify
answer: The ground truth answer
strict_box_verify: Whether to use strict box verification
pause_tokens_index: Indices of pause tokens
Returns:
True if the solution is correct, False otherwise
"""
if strict_box_verify:
correct, pred = is_correct_strict_box(solution_str, answer, pause_tokens_index)
return correct == 1, pred
correct, pred = is_correct_minerva(solution_str, answer)
return correct, pred
def compute_score(
solution_str: str,
ground_truth: str,
strict_box_verify: bool = False,
pause_tokens_index: list[int] | None = None,
) -> float:
"""Compute the reward score for a solution.
Args:
solution_str: The solution string
ground_truth: The ground truth answer
config: Configuration object containing reward model settings
pause_tokens_index: Indices of pause tokens
Returns:
Reward score (1.0 for correct, -1.0 for incorrect)
"""
# Limit solution length for efficiency
solution_str = solution_str[-300:] # The longest answer in MATH-500 has 159 characters
# Verify the solution
correct, pred = verify(solution_str, ground_truth, strict_box_verify, pause_tokens_index)
reward = 1.0 if correct else -1.0
acc = correct
return {
"score": reward,
"acc": acc,
"pred": pred,
}

View File

@@ -0,0 +1,491 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# from https://github.com/agentica-project/deepscaler/blob/e6080ccd974eb64bd3430f0b36108244a6fee330/deepscaler/rewards/math_utils/utils.py
"""
Answer checker API that uses sympy to simplify expressions and check for equality.
Call grade_answer(given_answer: str, ground_truth: str).
"""
import re
import sympy
from pylatexenc import latex2text
from sympy.parsing import sympy_parser
# Dan Hendrycks' code
def mathd_normalize_answer(answer: str | None) -> str | None:
if answer is None:
return None
answer = answer.strip()
try:
# Remove enclosing `\text{}`.
m = re.search("^\\\\text\{(?P<text>.+?)\}$", answer)
if m is not None:
answer = m.group("text").strip()
return _strip_string(answer)
except Exception:
return answer
def _strip_string(string):
def _fix_fracs(string):
substrs = string.split("\\frac")
new_str = substrs[0]
if len(substrs) > 1:
substrs = substrs[1:]
for substr in substrs:
new_str += "\\frac"
if substr[0] == "{":
new_str += substr
else:
try:
assert len(substr) >= 2
except Exception:
return string
a = substr[0]
b = substr[1]
if b != "{":
if len(substr) > 2:
post_substr = substr[2:]
new_str += "{" + a + "}{" + b + "}" + post_substr
else:
new_str += "{" + a + "}{" + b + "}"
else:
if len(substr) > 2:
post_substr = substr[2:]
new_str += "{" + a + "}" + b + post_substr
else:
new_str += "{" + a + "}" + b
string = new_str
return string
def _fix_a_slash_b(string):
if len(string.split("/")) != 2:
return string
a = string.split("/")[0]
b = string.split("/")[1]
try:
a = int(a)
b = int(b)
assert string == f"{a}/{b}"
new_string = "\\frac{" + str(a) + "}{" + str(b) + "}"
return new_string
except Exception:
return string
def _remove_right_units(string):
# "\\text{ " only ever occurs (at least in the val set) when describing units
if "\\text{ " in string:
splits = string.split("\\text{ ")
assert len(splits) == 2
return splits[0]
else:
return string
def _fix_sqrt(string):
if "\\sqrt" not in string:
return string
splits = string.split("\\sqrt")
new_string = splits[0]
for split in splits[1:]:
if split[0] != "{":
a = split[0]
new_substr = "\\sqrt{" + a + "}" + split[1:]
else:
new_substr = "\\sqrt" + split
new_string += new_substr
return new_string
# linebreaks
string = string.replace("\n", "")
# remove inverse spaces
string = string.replace("\\!", "")
# replace \\ with \
string = string.replace("\\\\", "\\")
# replace tfrac and dfrac with frac
string = string.replace("tfrac", "frac")
string = string.replace("dfrac", "frac")
# remove \left and \right
string = string.replace("\\left", "")
string = string.replace("\\right", "")
# Remove circ (degrees)
string = string.replace("^{\\circ}", "")
string = string.replace("^\\circ", "")
# remove dollar signs
string = string.replace("\\$", "")
# remove units (on the right)
string = _remove_right_units(string)
# remove percentage
string = string.replace("\\%", "")
string = string.replace("\%", "")
# " 0." equivalent to " ." and "{0." equivalent to "{." Alternatively, add "0" if "." is the start of the string
string = string.replace(" .", " 0.")
string = string.replace("{.", "{0.")
# if empty, return empty string
if len(string) == 0:
return string
if string[0] == ".":
string = "0" + string
# to consider: get rid of e.g. "k = " or "q = " at beginning
if len(string.split("=")) == 2:
if len(string.split("=")[0]) <= 2:
string = string.split("=")[1]
# fix sqrt3 --> sqrt{3}
string = _fix_sqrt(string)
# remove spaces
string = string.replace(" ", "")
# \frac1b or \frac12 --> \frac{1}{b} and \frac{1}{2}, etc. Even works with \frac1{72} (but not \frac{72}1). Also does a/b --> \\frac{a}{b}
string = _fix_fracs(string)
# manually change 0.5 --> \frac{1}{2}
if string == "0.5":
string = "\\frac{1}{2}"
# NOTE: X/Y changed to \frac{X}{Y} in dataset, but in simple cases fix in case the model output is X/Y
string = _fix_a_slash_b(string)
return string
# sympy might hang -- we don't care about trying to be lenient in these cases
BAD_SUBSTRINGS = ["^{", "^("]
BAD_REGEXES = ["\^[0-9]+\^", "\^[0-9][0-9]+"]
TUPLE_CHARS = "()[]"
def _sympy_parse(expr: str):
"""Parses an expression with sympy."""
py_expr = expr.replace("^", "**")
return sympy_parser.parse_expr(
py_expr,
transformations=(sympy_parser.standard_transformations + (sympy_parser.implicit_multiplication_application,)),
)
def _parse_latex(expr: str) -> str:
"""Attempts to parse latex to an expression sympy can read."""
expr = expr.replace("\\tfrac", "\\frac")
expr = expr.replace("\\dfrac", "\\frac")
expr = expr.replace("\\frac", " \\frac") # Play nice with mixed numbers.
expr = latex2text.LatexNodes2Text().latex_to_text(expr)
# Replace the specific characters that this parser uses.
expr = expr.replace("", "sqrt")
expr = expr.replace("π", "pi")
expr = expr.replace("", "inf")
expr = expr.replace("", "U")
expr = expr.replace("·", "*")
expr = expr.replace("×", "*")
return expr.strip()
def _is_float(num: str) -> bool:
try:
float(num)
return True
except Exception:
return False
def _is_int(x: float) -> bool:
try:
return abs(x - int(round(x))) <= 1e-7
except Exception:
return False
def _is_frac(expr: str) -> bool:
return bool(re.search(r"^-?[0-9]+.?/0*[1-9][0-9]*.?$", expr))
def _str_is_int(x: str) -> bool:
try:
x = _strip_properly_formatted_commas(x)
x = float(x)
return abs(x - int(round(x))) <= 1e-7
except Exception:
return False
def _str_to_int(x: str) -> int:
x = x.replace(",", "")
x = float(x)
return int(x)
def _inject_implicit_mixed_number(step: str):
"""
Automatically make a mixed number evalable
e.g. 7 3/4 => 7+3/4
"""
p1 = re.compile("([0-9]) +([0-9])")
step = p1.sub("\\1+\\2", step) ## implicit mults
return step
def _strip_properly_formatted_commas(expr: str):
# We want to be careful because we don't want to strip tuple commas
p1 = re.compile("(\d)(,)(\d\d\d)($|\D)")
while True:
next_expr = p1.sub("\\1\\3\\4", expr)
if next_expr == expr:
break
expr = next_expr
return next_expr
def _normalize(expr: str) -> str:
"""Normalize answer expressions."""
if expr is None:
return None
# Remove enclosing `\text{}`.
m = re.search("^\\\\text\{(?P<text>.+?)\}$", expr)
if m is not None:
expr = m.group("text")
expr = expr.replace("\\%", "%")
expr = expr.replace("\\$", "$")
expr = expr.replace("$", "")
expr = expr.replace("%", "")
expr = expr.replace(" or ", " , ")
expr = expr.replace(" and ", " , ")
expr = expr.replace("million", "*10^6")
expr = expr.replace("billion", "*10^9")
expr = expr.replace("trillion", "*10^12")
for unit in [
"degree",
"cm",
"centimeter",
"meter",
"mile",
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
"foot",
"feet",
"inch",
"yard",
]:
expr = re.sub(f"{unit}(es)?(s)? *(\^[0-9]+)?", "", expr)
expr = re.sub("\^ *\\\\circ", "", expr)
if len(expr) > 0 and expr[0] == "{" and expr[-1] == "}":
expr = expr[1:-1]
expr = re.sub(",\\\\! *", "", expr)
if _is_float(expr) and _is_int(float(expr)):
expr = str(int(round(float(expr))))
if "\\" in expr:
try:
expr = _parse_latex(expr)
except Exception:
pass
# edge case with mixed numbers and negative signs
expr = re.sub("- *", "-", expr)
expr = _inject_implicit_mixed_number(expr)
expr = expr.replace(" ", "")
# if we somehow still have latex braces here, just drop them
expr = expr.replace("{", "")
expr = expr.replace("}", "")
# don't be case sensitive for text answers
expr = expr.lower()
if _str_is_int(expr):
expr = str(_str_to_int(expr))
return expr
def count_unknown_letters_in_expr(expr: str):
expr = expr.replace("sqrt", "")
expr = expr.replace("frac", "")
letters_in_expr = set([x for x in expr if x.isalpha()])
return len(letters_in_expr)
def should_allow_eval(expr: str):
# we don't want to try parsing unknown text or functions of more than two variables
if count_unknown_letters_in_expr(expr) > 2:
return False
for bad_string in BAD_SUBSTRINGS:
if bad_string in expr:
return False
for bad_regex in BAD_REGEXES:
if re.search(bad_regex, expr) is not None:
return False
return True
def are_equal_under_sympy(ground_truth_normalized: str, given_normalized: str):
are_equal = False
try:
expr = f"({ground_truth_normalized})-({given_normalized})"
if should_allow_eval(expr):
sympy_diff = _sympy_parse(expr)
simplified = sympy.simplify(sympy_diff)
if simplified == 0:
are_equal = True
except Exception:
pass
return are_equal
def split_tuple(expr: str):
"""
Split the elements in a tuple/interval, while handling well-formatted commas in large numbers
"""
expr = _strip_properly_formatted_commas(expr)
if len(expr) == 0:
return []
if (
len(expr) > 2
and expr[0] in TUPLE_CHARS
and expr[-1] in TUPLE_CHARS
and all([ch not in expr[1:-1] for ch in TUPLE_CHARS])
):
elems = [elem.strip() for elem in expr[1:-1].split(",")]
else:
elems = [expr]
return elems
def last_boxed_only_string(string):
idx = string.rfind("\\boxed")
if idx < 0:
idx = string.rfind("\\fbox")
if idx < 0:
return None
i = idx
right_brace_idx = None
num_left_braces_open = 0
while i < len(string):
if string[i] == "{":
num_left_braces_open += 1
if string[i] == "}":
num_left_braces_open -= 1
if num_left_braces_open == 0:
right_brace_idx = i
break
i += 1
if right_brace_idx is None:
retval = None
else:
retval = string[idx : right_brace_idx + 1]
return retval
def remove_boxed(s):
left = "\\boxed{"
try:
assert s[: len(left)] == left
assert s[-1] == "}"
return s[len(left) : -1]
except Exception:
return None
def extract_boxed_answer(solution: str) -> str:
"""Extract the answer from inside a LaTeX \\boxed{} command"""
solution = last_boxed_only_string(solution)
solution = remove_boxed(solution)
return solution
def grade_answer_sympy(given_answer: str, ground_truth: str) -> bool:
ground_truth_normalized = _normalize(ground_truth)
given_normalized = _normalize(given_answer)
if ground_truth_normalized is None:
return False
if ground_truth_normalized == given_normalized:
return True
if len(given_normalized) == 0:
return False
ground_truth_elems = split_tuple(ground_truth_normalized)
given_elems = split_tuple(given_normalized)
if len(ground_truth_elems) > 1 and (
ground_truth_normalized[0] != given_normalized[0] or ground_truth_normalized[-1] != given_normalized[-1]
):
is_correct = False
elif len(ground_truth_elems) != len(given_elems):
is_correct = False
else:
for ground_truth_elem, given_elem in zip(ground_truth_elems, given_elems, strict=False):
if _is_frac(ground_truth_elem) and _is_frac(given_elem):
# if fractions aren't reduced, then shouldn't be marked as correct
# so, we don't want to allow sympy.simplify in this case
is_correct = ground_truth_elem == given_elem
elif _str_is_int(ground_truth_elem) != _str_is_int(given_elem):
# if the ground truth answer is an integer, we require the given answer to be a strict match (no sympy.simplify)
is_correct = False
else:
is_correct = are_equal_under_sympy(ground_truth_elem, given_elem)
if not is_correct:
break
return is_correct
def grade_answer_mathd(given_answer: str, ground_truth: str) -> bool:
ground_truth_normalized_mathd = mathd_normalize_answer(ground_truth)
given_answer_normalized_mathd = mathd_normalize_answer(given_answer)
# be at least as lenient as mathd
if ground_truth_normalized_mathd == given_answer_normalized_mathd:
return True
return False
def extract_answer(passage: str) -> str:
if "\\boxed" in passage:
return extract_boxed_answer(passage)
return None
def grade_answer_verl(solution_str, ground_truth):
if not ground_truth:
return False
ground_truth = str(ground_truth)
if "\\boxed" in ground_truth:
ground_truth = extract_answer(ground_truth)
given_answer = extract_answer(solution_str)
if given_answer is None:
return False
return grade_answer_mathd(given_answer, ground_truth) or grade_answer_sympy(given_answer, ground_truth)

View File

@@ -0,0 +1,675 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio
import copy
import logging
from argparse import Namespace
from collections import defaultdict
from collections.abc import Callable
from typing import Any
import numpy as np
import pybase64
import torch
import sglang_router
from packaging.version import parse
from tqdm import tqdm
from slime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput
from slime.rollout.filter_hub.base_types import DynamicFilterOutput
from slime.utils.async_utils import run
from slime.utils.data import Dataset
from slime.utils.eval_config import EvalDatasetConfig
from slime.utils.http_utils import get, post
from slime.utils.mask_utils import get_response_lengths, MultiTurnLossMaskGenerator
from slime.utils.misc import SingletonMeta, load_function
from slime.utils.processing_utils import encode_image_for_rollout_engine, load_processor, load_tokenizer
from slime.utils.types import Sample
from .rm_hub import async_rm, batched_async_rm
__all__ = ["generate_rollout"]
logger = logging.getLogger(__name__)
class GenerateState(metaclass=SingletonMeta):
"""
The global state for the generation process.
"""
def __init__(self, args: Namespace) -> None:
# persistent state for the generation process
self.args = args
self.tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
self.processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
num_engines = args.rollout_num_gpus // args.rollout_num_gpus_per_engine
self.semaphore = asyncio.Semaphore(
args.sglang_server_concurrency * num_engines if num_engines > 0 else 1
)
self.sampling_params: dict[str, Any] = dict(
temperature=args.rollout_temperature,
top_p=args.rollout_top_p,
top_k=args.rollout_top_k,
max_new_tokens=args.rollout_max_response_len,
stop=args.rollout_stop,
stop_token_ids=args.rollout_stop_token_ids,
skip_special_tokens=args.rollout_skip_special_tokens,
no_stop_trim=False, # Changed to remove stop tokens from rollout output
spaces_between_special_tokens=False,
)
if getattr(args, "sglang_enable_deterministic_inference", False):
sampling_seed_base = args.rollout_seed
self.group_sampling_seeds = [sampling_seed_base + i for i in range(args.n_samples_per_prompt)]
self.reset()
def reset(self) -> None:
self.remaining_batch_size = 0
self.pendings = set()
self.aborted = False
self.current_rollout_id = 0
def submit_generate_tasks(self, samples: list[list[Sample]]) -> None:
for group in samples:
self.pendings.add(
asyncio.create_task(
# submit a group of samples as a single task.
generate_and_rm_group(
self.args,
group,
sampling_params=self.sampling_params.copy(),
evaluation=False,
)
)
)
self.remaining_batch_size += len(samples)
def _is_lightning_opd(sample: Sample) -> bool:
"""Check if sample is a Lightning OPD sample (pre-computed response + teacher logprobs)."""
metadata = sample.metadata or {}
return metadata.get("is_lightning_opd", False) or metadata.get("is_offline_opd", False)
def _handle_lightning_opd_sample(sample: Sample, state: "GenerateState") -> Sample:
"""Handle Lightning OPD samples: response tokens are pre-computed in parquet metadata.
Expected metadata fields:
response_tokens: list[int] pre-tokenized response token IDs
loss_mask: list[int] 1 for each response token to compute loss on
response: str decoded response text (used by verifiable reward)
The sample's prompt tokens are prepended to form the full sequence sent to the teacher
server for logprob computation. The training model then computes student logprobs on
the same sequence during each forward pass, so the OPD advantage
log P_teacher - log P_πt is still computed against the *current* policy.
"""
import numpy as np
metadata = sample.metadata or {}
# Prompt may be a raw string (common case) or already tokenized list[int].
prompt = sample.prompt
if isinstance(prompt, str):
prompt = state.tokenizer.encode(prompt, add_special_tokens=False)
elif isinstance(prompt, np.ndarray):
prompt = prompt.tolist()
response_tokens = metadata.get("response_tokens", [])
if isinstance(response_tokens, np.ndarray):
response_tokens = response_tokens.tolist()
loss_mask = metadata.get("loss_mask", [1] * len(response_tokens))
if isinstance(loss_mask, np.ndarray):
loss_mask = loss_mask.tolist()
# Full sequence = prompt tokens + response tokens (RM needs full context for logprobs)
# Force Python int conversion: parquet pyarrow backend can produce numpy.int64 elements
# that survive list() but fail JSON serialization when sent to the teacher server.
sample.tokens = [int(x) for x in prompt] + [int(x) for x in response_tokens]
sample.loss_mask = [int(x) for x in loss_mask]
sample.response_length = int(sum(loss_mask))
sample.response = metadata.get("response", "")
sample.status = Sample.Status.COMPLETED
# Load pre-computed student (pi_ref) log-probs for importance weight tracking.
# These are produced by data_curation/add_student_logprobs.py and stored as
# metadata["student_log_probs"]. When present they are passed through as
# rollout_log_probs so that loss.py can compute w = pi_theta / pi_ref.
student_log_probs = metadata.get("student_log_probs")
if student_log_probs is not None:
sample.rollout_log_probs = [float(x) for x in student_log_probs]
return sample
async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, Any]) -> Sample:
"""Generate using traditional SGLang router with token-based workflow"""
if args.ci_test:
assert isinstance(sample.prompt, str)
state = GenerateState(args)
url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/generate"
assert (
sample.status == Sample.Status.PENDING or sample.status == Sample.Status.ABORTED
), f"Sample status is {sample.status}"
# Handle Lightning OPD samples: response tokens are pre-computed in metadata, skip sglang.
# The RM call (teacher logprob computation) still runs normally after this.
if _is_lightning_opd(sample):
return _handle_lightning_opd_sample(sample, state)
if state.processor:
processor_output = state.processor(text=sample.prompt, **sample.multimodal_inputs)
prompt_ids = processor_output["input_ids"][0]
sample.multimodal_train_inputs = {
k: v for k, v in processor_output.items() if k not in ["input_ids", "attention_mask"]
} or None
else:
prompt_ids = state.tokenizer.encode(sample.prompt, add_special_tokens=False)
if len(sample.response) > 0:
sampling_params["max_new_tokens"] -= len(sample.tokens) - len(prompt_ids)
assert (
sampling_params["max_new_tokens"] >= 0
), f"max_new_tokens: {sampling_params['max_new_tokens']} should not be less than 0"
if sampling_params["max_new_tokens"] == 0:
sample.status = Sample.Status.TRUNCATED
return sample
# Prepare payload for sglang server
payload = {
"sampling_params": sampling_params,
"return_logprob": True,
}
if args.use_rollout_routing_replay:
payload["return_routed_experts"] = True
if sample.multimodal_inputs and sample.multimodal_inputs["images"]:
image_data = sample.multimodal_inputs["images"]
payload["image_data"] = [encode_image_for_rollout_engine(image) for image in image_data]
# Use existing tokens for multi-turn or tokenize the new prompt
if len(sample.response) > 0:
payload["input_ids"] = sample.tokens
else:
payload["input_ids"] = prompt_ids
if not sample.tokens: # Initialize sample.tokens for the first turn
sample.tokens = prompt_ids
output = await post(url, payload)
# Extract new response tokens
if args.use_slime_router and "RadixTreeMiddleware" in args.slime_router_middleware_paths:
assert not args.partial_rollout, "Currently partial rollout is not supported when using slime router"
retrieve_url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/retrieve_from_text"
retrieve_payload = {"text": sample.prompt + output["text"], "return_logp": True}
retrieve_output = await post(retrieve_url, retrieve_payload)
sample.tokens = retrieve_output["tokens"]
sample.response += output["text"]
sample.loss_mask = retrieve_output["loss_mask"]
sample.response_length = get_response_lengths([sample.loss_mask])[0]
sample.loss_mask = sample.loss_mask[-sample.response_length :]
sample.rollout_log_probs = retrieve_output["rollout_logp"][-sample.response_length :]
# Notice: currently cannot get the spec info from radix router output.
else:
if "output_token_logprobs" in output["meta_info"]:
new_response_tokens = [item[1] for item in output["meta_info"]["output_token_logprobs"]]
new_response_log_probs = [item[0] for item in output["meta_info"]["output_token_logprobs"]]
else:
new_response_tokens, new_response_log_probs = [], []
# Update sample with tokens directly - avoiding re-tokenization
sample.tokens = sample.tokens + new_response_tokens
sample.response_length += len(new_response_tokens)
sample.response += output["text"]
if sample.rollout_log_probs is None:
sample.rollout_log_probs = []
sample.rollout_log_probs += new_response_log_probs
if args.sglang_speculative_algorithm:
# cannot directly use spec info from sglang because of partial rollout.
sample.spec_info.add(
meta_info=output["meta_info"],
response_length=sample.response_length,
)
if "weight_version" in output["meta_info"]:
sample.weight_versions.append(output["meta_info"]["weight_version"])
if "routed_experts" in output["meta_info"]:
sample.rollout_routed_experts = np.frombuffer(
pybase64.b64decode(output["meta_info"]["routed_experts"].encode("ascii")),
dtype=np.int32,
).reshape(
len(sample.tokens) - 1,
args.num_layers,
args.moe_router_topk,
)
match output["meta_info"]["finish_reason"]["type"]:
case "length":
sample.status = Sample.Status.TRUNCATED
case "abort":
sample.status = Sample.Status.ABORTED
case "stop":
sample.status = Sample.Status.COMPLETED
return sample
async def generate_and_rm(
args: Namespace,
sample: Sample | list[Sample],
sampling_params: dict[str, Any],
evaluation: bool = False,
) -> Sample | list[Sample]:
# mask previous off-policy generation for partial rollout
if args.partial_rollout and args.mask_offpolicy_in_partial_rollout and sample.response_length > 0:
sample.loss_mask = [0] * sample.response_length
# For samples with existing response, check if they're complete
if sample.status == Sample.Status.COMPLETED or sample.status == Sample.Status.TRUNCATED:
assert sample.response is not None
if not args.group_rm:
assert sample.reward is not None
return sample
state = GenerateState(args)
is_lightning_opd = _is_lightning_opd(sample)
# generate (skip for Lightning OPD samples whose tokens are pre-computed)
if is_lightning_opd:
sample = _handle_lightning_opd_sample(sample, state)
else:
async with state.semaphore:
if state.aborted:
sample.status = Sample.Status.ABORTED
return sample
if args.custom_generate_function_path is not None:
custom_generate_func = load_function(args.custom_generate_function_path)
sample = await custom_generate_func(args, sample, sampling_params)
else:
sample = await generate(args, sample, sampling_params)
# for the rm that need the whole group, we will not do the rm here
if args.group_rm:
return sample
# multi samples
if isinstance(sample, list):
samples = sample
if any([sample.status == Sample.Status.ABORTED for sample in samples]):
return samples
# for multi agent system, the reward of some sample is calculated during generation.
samples_need_reward = [sample for sample in samples if sample.reward is None]
rewards = await batched_async_rm(args, samples_need_reward)
for sample, reward in zip(samples_need_reward, rewards, strict=False):
sample.reward = reward
return samples
else:
if sample.status == Sample.Status.ABORTED:
return sample
# for multi-turn environment, a reward could be assigned to the agent.
if sample.reward is None:
sample.reward = await async_rm(args, sample)
return sample
async def generate_and_rm_group(
args: Namespace, group: list[Sample], sampling_params: dict[str, Any], evaluation: bool = False
) -> list[Sample]:
state = GenerateState(args)
if state.aborted:
return group
tasks = []
for idx, sample in enumerate(group):
current_sampling_params = sampling_params.copy()
if getattr(args, "sglang_enable_deterministic_inference", False):
seed = state.group_sampling_seeds[idx]
current_sampling_params["sampling_seed"] = seed
tasks.append(
asyncio.create_task(generate_and_rm(args, sample, current_sampling_params, evaluation=evaluation))
)
group = await asyncio.gather(*tasks)
# for the rm that need the whole group, we will do the rm here
if not state.aborted and args.group_rm:
rewards = await batched_async_rm(args, group)
for sample, reward in zip(group, rewards, strict=False):
sample.reward = reward
return group
async def abort(args: Namespace, rollout_id: int) -> list[list[Sample]]:
aborted_samples = []
state = GenerateState(args)
assert not state.aborted
state.aborted = True
# No rollout engines → no router, no pending tasks; nothing to abort.
if not args.rollout_num_gpus:
return aborted_samples
if parse(sglang_router.__version__) <= parse("0.2.1") or args.use_slime_router:
response = await get(f"http://{args.sglang_router_ip}:{args.sglang_router_port}/list_workers")
urls = response["urls"]
else:
response = await get(f"http://{args.sglang_router_ip}:{args.sglang_router_port}/workers")
urls = [worker["url"] for worker in response["workers"]]
logger.info(f"Abort request for {urls}")
await asyncio.gather(*[post(f"{url}/abort_request", {"abort_all": True}) for url in urls])
# make sure all the pending tasks are finished
count = 0
while state.pendings:
done, state.pendings = await asyncio.wait(state.pendings, return_when=asyncio.FIRST_COMPLETED)
if not args.partial_rollout:
continue
# for partial rollout, collect the partial samples into the data buffer
for task in done:
group = task.result()
for sample in group:
if sample.response and "start_rollout_id" not in sample.metadata:
sample.metadata["start_rollout_id"] = rollout_id
aborted_samples.append(group)
count += len(group)
if args.partial_rollout:
logger.info(f"Collected {count} partial samples into the data buffer")
return aborted_samples
async def generate_rollout_async(
args: Namespace, rollout_id: int, data_source: Callable[[int], list[list[Sample]]]
) -> tuple[RolloutFnTrainOutput, list[list[Sample]]]:
"""An example to implement the generate_rollout function for an rule based rm rollout generation.
Args:
args: the whole args
rollout_id: int, the id of the rollout, used for deterministic data generation
data_source: the data source to fetch
Returns:
tuple[RolloutFnTrainOutput, list[list[Sample]]]:
- data: a list of groups of samples generated by the rollout, length equals `rollout_batch_size`
- aborted_samples: any partial groups collected during abort when partial_rollout is enabled
"""
assert args.rollout_global_dataset
state = GenerateState(args)
state.current_rollout_id = rollout_id
# instantiate data filters
dynamic_filter = (
load_function(args.dynamic_sampling_filter_path) if args.dynamic_sampling_filter_path is not None else None
)
metric_gatherer = _MetricGatherer()
# target_data_size is the total number of valid samples to get
target_data_size = args.rollout_batch_size
data = []
all_data = []
do_print = True
pbar = tqdm(total=target_data_size * args.n_samples_per_prompt, desc="Rollout generation")
while len(data) < target_data_size:
while state.remaining_batch_size < target_data_size:
# get samples from the buffer and submit the generation requests.
samples = data_source(args.over_sampling_batch_size)
state.submit_generate_tasks(samples)
# wait for the generation to finish
done, state.pendings = await asyncio.wait(state.pendings, return_when=asyncio.FIRST_COMPLETED)
for task in done:
group: list[Sample] = task.result()
if do_print:
sample = group[0][0] if isinstance(group[0], list) else group[0]
logger.info(
f"First rollout sample: {[str(sample.prompt) + sample.response]}, label: {sample.label}, reward: {sample.reward}",
)
do_print = False
assert len(group) == args.n_samples_per_prompt
all_data.append(group)
dynamic_filter_output = _call_dynamic_filter(dynamic_filter, args, group)
if not dynamic_filter_output.keep:
metric_gatherer.on_dynamic_filter_drop(reason=dynamic_filter_output.reason)
state.remaining_batch_size -= 1
continue
# add the samples to the data
# NOTE: here we have not stored all the unused samples back to the data buffer.
if len(data) < target_data_size:
data.append(group)
pbar.update(len(group))
pbar.close()
sample = data[-1][0][0] if isinstance(data[-1][0], list) else data[-1][0]
logger.info(
f"Finish rollout: {[str(sample.prompt) + sample.response]}, label: {sample.label}, reward: {sample.reward}",
)
# there are still some unfinished requests, abort them
aborted_samples = await abort(args, rollout_id)
assert len(data) == args.rollout_batch_size, f"Got {len(data)} samples, expected {args.rollout_batch_size}"
data = sorted(data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index)
all_samples = sorted(data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index)
# reset the global state to prevent effects on the next rollout or eval.
state.reset()
if args.rollout_sample_filter_path is not None:
filter_func = load_function(args.rollout_sample_filter_path)
filter_func(args, data)
# There can be circumstances where users want to process all samples including filtered ones.
if args.rollout_all_samples_process_path is not None:
process_func = load_function(args.rollout_all_samples_process_path)
process_func(args, all_samples, data_source)
return RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()), aborted_samples
def _call_dynamic_filter(fn, *args, **kwargs):
if fn is None:
return DynamicFilterOutput(keep=True)
output = fn(*args, **kwargs)
# compatibility for legacy version
if not isinstance(output, DynamicFilterOutput):
output = DynamicFilterOutput(keep=output)
return output
class _MetricGatherer:
def __init__(self):
self._dynamic_filter_drop_reason_count = defaultdict(lambda: 0)
def on_dynamic_filter_drop(self, reason: str | None):
if not reason:
return
self._dynamic_filter_drop_reason_count[reason] += 1
def collect(self):
return {
f"rollout/dynamic_filter/drop_{reason}": count
for reason, count in self._dynamic_filter_drop_reason_count.items()
}
EVAL_PROMPT_DATASET = {}
async def eval_rollout(args: Namespace, rollout_id: int) -> tuple[dict[str, dict[str, list[Any]]], list[list[Sample]]]:
assert not args.group_rm, "Group RM is not supported for eval rollout"
coros = []
for dataset_cfg in getattr(args, "eval_datasets", []) or []:
coros.append(eval_rollout_single_dataset(args, rollout_id, dataset_cfg))
results_list = await asyncio.gather(*coros)
results = {}
for r in results_list:
results.update(r)
return RolloutFnEvalOutput(data=results), []
async def eval_rollout_single_dataset(
args: Namespace, rollout_id: int, dataset_cfg: EvalDatasetConfig
) -> dict[str, dict[str, list[Any]]]:
"""An example to implement the eval_rollout function for an rule based rm rollout generation.
Args:
args: the whole args
rollout_id: int, the id of the rollout, used for deterministic data generation
dataset_cfg: configuration of the dataset
"""
assert not args.group_rm, "Group RM is not supported for eval rollout"
global EVAL_PROMPT_DATASET
cache_key = dataset_cfg.cache_key + (args.hf_checkpoint, args.apply_chat_template)
if cache_key not in EVAL_PROMPT_DATASET:
tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
EVAL_PROMPT_DATASET[cache_key] = Dataset(
path=dataset_cfg.path,
tokenizer=tokenizer,
processor=processor,
max_length=args.eval_max_prompt_len,
prompt_key=dataset_cfg.input_key,
label_key=dataset_cfg.label_key,
multimodal_keys=args.multimodal_keys,
metadata_key=dataset_cfg.metadata_key,
tool_key=dataset_cfg.tool_key,
apply_chat_template=args.apply_chat_template,
apply_chat_template_kwargs=args.apply_chat_template_kwargs,
)
dataset = EVAL_PROMPT_DATASET[cache_key]
base_sampling_params = dict(
temperature=dataset_cfg.temperature,
top_p=dataset_cfg.top_p,
top_k=dataset_cfg.top_k,
max_new_tokens=dataset_cfg.max_response_len,
stop=args.rollout_stop,
stop_token_ids=args.rollout_stop_token_ids,
skip_special_tokens=args.rollout_skip_special_tokens,
no_stop_trim=False, # Changed to remove stop tokens from rollout output
spaces_between_special_tokens=False,
)
tasks = []
# do multiple samples for eval prompts
sample_index = 0
for _i, prompt_sample in enumerate(dataset.samples):
for j in range(dataset_cfg.n_samples_per_eval_prompt):
# use the same prompt for multiple samples
sample = copy.deepcopy(prompt_sample)
sample.index = sample_index
sample_index += 1
sample.metadata = dataset_cfg.inject_metadata(getattr(sample, "metadata", None))
sampling_params = base_sampling_params
if getattr(args, "sglang_enable_deterministic_inference", False):
sampling_params = base_sampling_params.copy()
sampling_params["sampling_seed"] = args.rollout_seed + j
tasks.append(
asyncio.create_task(
generate_and_rm(
args,
sample,
sampling_params=sampling_params,
evaluation=True,
)
)
)
data = []
do_print = True
pbar = tqdm(total=len(tasks), desc="Rollout generation", disable=not do_print)
for coro in asyncio.as_completed(tasks):
sample = await coro
if do_print:
logger.info(
"eval_rollout_single_dataset example data: "
f"{[str(sample.prompt) + sample.response]} "
f"reward={sample.reward}"
)
do_print = False
if isinstance(sample, list):
data.extend(sample)
else:
data.append(sample)
pbar.update(1)
pbar.close()
data.sort(key=lambda sample: sample.index)
reward_key = args.eval_reward_key or args.reward_key
return {
dataset_cfg.name: {
"rewards": [sample.reward if not reward_key else sample.reward[reward_key] for sample in data],
"truncated": [sample.status == Sample.Status.TRUNCATED for sample in data],
"samples": data,
}
}
# TODO remove this temp function
def generate_rollout(
args: Namespace, rollout_id: int, data_buffer: Any, evaluation: bool = False
) -> RolloutFnTrainOutput | RolloutFnEvalOutput:
"""An example to implement the generate_rollout function for an rule based rm rollout generation.
Args:
args: the whole args
rollout_id: int, the id of the rollout, used for deterministic data generation
data_buffer: the data buffer to store the generated samples
evaluation: bool, whether the rollout is for evaluation or not
Returns:
list[list[Sample]]: a list of list of samples generated by the rollout
"""
output, aborted_samples = generate_abortable_samples(
args, rollout_id, data_buffer.get_samples, evaluation=evaluation
)
data_buffer.add_samples(aborted_samples)
return output
def generate_abortable_samples(
args: Namespace,
rollout_id: int,
data_source: Callable[[int], list[list[Sample]]],
evaluation: bool = False,
) -> tuple[Any, list[list[Sample]]]:
assert args.rollout_global_dataset
if evaluation:
return run(eval_rollout(args, rollout_id))
return run(generate_rollout_async(args, rollout_id, data_source))

View File

@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import logging
import time
logger = logging.getLogger(__name__)
def sleep(args, rollout_id, data_source, evaluation=False):
count = 0
while True:
time.sleep(3600)
count += 1
logger.info(f"rollout sleep for {count} hours")