chore: import upstream snapshot with attribution
Lint test / lint (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:58 +08:00
commit a203934033
1368 changed files with 175001 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# ============================================================
# Swift GRPO training with OpenEnv TextArena Sudoku
#
# Prerequisites:
# 1. Start Sudoku server (separate terminal):
# TEXTARENA_ENV_ID=Sudoku-v0 MAX_CONCURRENT_ENVS=8 \
# python examples/train/grpo/plugin/openenv/start_sudoku_server.py
#
# 2. This script uses colocate mode:
# - vLLM and training share the same GPUs
# - No separate rollout server needed
#
# Environment: TextArena Sudoku (local server, port 8000)
# Model: Qwen3.5-4B (enable_thinking=false)
# Scheduler: SudokuScheduler (multi-turn, content diff tracking)
# Multi-turn: max_turns=20 (20 moves per game)
# Rewards: 5-component (empty_cell/valid_move/repetition/progress/correct)
# Hints: Board parsing + guaranteed moves + candidates
#
# ============================================================
CUDA_VISIBLE_DEVICES=0,1,2,3 \
NPROC_PER_NODE=4 \
swift rlhf \
--rlhf_type grpo \
--model Qwen/Qwen3.5-4B \
--dataset examples/train/grpo/plugin/openenv/sudoku.jsonl#1000 \
--external_plugins examples/train/grpo/plugin/openenv/sudoku_scheduler.py \
--enable_thinking false \
--torch_dtype bfloat16 \
--max_completion_length 256 \
--max_length 8192 \
--learning_rate 5e-6 \
--num_train_epochs 3 \
--per_device_train_batch_size 1 \
--num_generations 4 \
--generation_batch_size 4 \
--gradient_accumulation_steps 4 \
--temperature 1 \
--use_vllm true \
--vllm_mode colocate \
--vllm_max_model_len 12288 \
--vllm_gpu_memory_utilization 0.35 \
--gradient_checkpointing true \
--use_gym_env true \
--multi_turn_scheduler sudoku_scheduler \
--max_turns 20 \
--save_strategy steps \
--save_steps 50 \
--logging_steps 1 \
--log_completions true \
--report_to tensorboard swanlab
@@ -0,0 +1,65 @@
# ============================================================
# Swift GRPO training with OpenEnv TextArena Sudoku (Server Mode)
#
# Prerequisites:
# 1. Start Sudoku server (separate terminal):
# TEXTARENA_ENV_ID=Sudoku-v0 MAX_CONCURRENT_ENVS=8 \
# python examples/train/grpo/plugin/openenv/start_sudoku_server.py
#
# 2. Start vLLM rollout server (separate terminal):
# CUDA_VISIBLE_DEVICES=0 \
# swift rollout \
# --model Qwen/Qwen3.5-4B \
# --external_plugins examples/train/grpo/plugin/openenv/sudoku_scheduler.py \
# --enable_thinking false \
# --max_length 8192 \
# --vllm_max_model_len 12288 \
# --vllm_gpu_memory_utilization 0.9 \
# --use_gym_env true \
# --multi_turn_scheduler sudoku_scheduler \
# --max_turns 20
#
# 3. This script starts training in server mode:
# - vLLM rollout server handles multi-turn + env interaction
# - Training process sends generation requests to rollout server
# - --multi_turn_scheduler / --max_turns go to BOTH rollout and rlhf
#
# Environment: TextArena Sudoku (local server, port 8000)
# Model: Qwen3.5-4B (enable_thinking=false)
# Scheduler: SudokuScheduler (multi-turn, content diff tracking)
# Multi-turn: max_turns=20 (20 moves per game)
# Rewards: 5-component (empty_cell/valid_move/repetition/progress/correct)
# Hints: Board parsing + guaranteed moves + candidates
#
# ============================================================
CUDA_VISIBLE_DEVICES=1,2,3 \
NPROC_PER_NODE=3 \
swift rlhf \
--rlhf_type grpo \
--model Qwen/Qwen3.5-4B \
--dataset examples/train/grpo/plugin/openenv/sudoku.jsonl \
--external_plugins examples/train/grpo/plugin/openenv/sudoku_scheduler.py \
--enable_thinking false \
--torch_dtype bfloat16 \
--max_completion_length 256 \
--max_length 8192 \
--learning_rate 5e-6 \
--num_train_epochs 3 \
--per_device_train_batch_size 1 \
--num_generations 6 \
--gradient_accumulation_steps 4 \
--temperature 1 \
--use_vllm true \
--vllm_mode server \
--vllm_server_host 127.0.0.1 \
--vllm_server_port 8001 \
--gradient_checkpointing true \
--use_gym_env true \
--multi_turn_scheduler sudoku_scheduler \
--max_turns 20 \
--save_strategy steps \
--save_steps 50 \
--logging_steps 1 \
--log_completions true \
--report_to tensorboard swanlab
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Start the TextArena Sudoku server with configurable concurrent sessions.
The default OpenEnv server only allows 1 concurrent session because
TextArenaEnvironment is not marked as SUPPORTS_CONCURRENT_SESSIONS.
Since each WebSocket session creates an independent game instance,
it is safe to enable concurrent sessions.
Usage:
TEXTARENA_ENV_ID=Sudoku-v0 python start_sudoku_server.py
TEXTARENA_ENV_ID=Sudoku-v0 MAX_CONCURRENT_ENVS=8 python start_sudoku_server.py
"""
import os
import uvicorn
from openenv.core.env_server.http_server import create_app
from textarena_env.server.app import (TextArenaAction, TextArenaObservation, build_textarena_gradio_app,
create_textarena_environment)
from textarena_env.server.environment import TextArenaEnvironment
# Read config from environment
# Note: TEXTARENA_ENV_ID is read by create_textarena_environment factory,
# not by this script directly.
max_concurrent_envs = int(os.getenv('MAX_CONCURRENT_ENVS', '8'))
host = os.getenv('HOST', '0.0.0.0')
port = int(os.getenv('PORT', '8000'))
# Mark TextArenaEnvironment as supporting concurrent sessions.
# Each WebSocket session creates an independent game instance via the factory,
# so concurrent sessions are safe.
TextArenaEnvironment.SUPPORTS_CONCURRENT_SESSIONS = True
# Build the app with custom max_concurrent_envs
app = create_app(
create_textarena_environment,
TextArenaAction,
TextArenaObservation,
env_name='textarena_env',
max_concurrent_envs=max_concurrent_envs,
gradio_builder=build_textarena_gradio_app,
)
if __name__ == '__main__':
env_id = os.getenv('TEXTARENA_ENV_ID', 'Sudoku-v0')
print(f'Starting server: env={env_id}, max_concurrent_envs={max_concurrent_envs}')
uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,10 @@
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
{"messages": [{"role": "user", "content": "Play"}], "env_config": {"name": "openenv", "base_url": "http://127.0.0.1:8000", "reset_kwargs": {}}}
@@ -0,0 +1,416 @@
"""Sudoku scheduler for OpenEnv TextArena Sudoku environment.
Reference: TRL openenv_sudoku_grpo.ipynb
Key features:
1. Multiple reward functions (empty_cell, valid_move, repetition, progress, correct)
2. Hints system: parse board, provide guaranteed moves and candidates
3. Board state tracking with content diff for bounded context
"""
import asyncio
import re
from collections import defaultdict
from typing import Any, Dict, List, Optional, Union
from swift.rollout.multi_turn import OpenEnvScheduler, multi_turns
SUDOKU_SYSTEM_PROMPT = """You are an expert Sudoku player with deep knowledge of logical deduction strategies.
## GAME RULES
1. The puzzle is a 9x9 grid divided into nine 3x3 subgrids (boxes)
2. Some cells are pre-filled with numbers 1-9
3. Fill empty cells ('.') with numbers 1-9
4. Each row, column, and 3x3 box must contain 1-9 without repetition
5. Cannot overwrite pre-filled cells
6. Invalid moves result in penalties
## HOW TO PLAY
Output your move in this format: [row col number]
Example: [3 5 7] means place 7 at row 3, column 5.
You may reason before your move, but always end with [row col number].
## STRATEGIC APPROACH
- Naked Singles: If a cell has only one possible candidate, fill it immediately.
- Hidden Singles: If a number can only go in one cell within a row/column/box, place it there.
- Scanning: Look at each row, column, and box to find where numbers can go.
## COMMON PITFALLS
- Don't guess randomly - Sudoku is pure logic
- Don't overwrite pre-filled cells
- Don't repeat a move that was already made
- Coordinates are 1-indexed (1-9)
## BOARD READING
- Rows labeled R1-R9 (top to bottom)
- Columns labeled C1-C9 (left to right)
- Empty cells shown as '.'"""
def _is_valid_board_state(board_str: str) -> bool:
return 'R1' in board_str and 'R9' in board_str and '|' in board_str
def _parse_board(board_str: str) -> list:
grid = [[0] * 9 for _ in range(9)]
if not _is_valid_board_state(board_str):
return grid
for line in board_str.split('\n'):
line_stripped = line.strip()
if line_stripped and line_stripped[0] == 'R' and len(line_stripped) > 1 and line_stripped[1].isdigit():
row = int(line_stripped[1]) - 1
col = 0
for char in line_stripped[2:]:
if col >= 9:
break
if char == '.':
grid[row][col] = 0
col += 1
elif char.isdigit():
grid[row][col] = int(char)
col += 1
return grid
def _count_filled_cells(board_str: str) -> int:
grid = _parse_board(board_str)
return sum(1 for row in grid for cell in row if cell != 0)
def _get_valid_numbers(grid: list, row: int, col: int) -> set:
if grid[row][col] != 0:
return set()
used = set()
for c in range(9):
if grid[row][c] != 0:
used.add(grid[row][c])
for r in range(9):
if grid[r][col] != 0:
used.add(grid[r][col])
box_row, box_col = 3 * (row // 3), 3 * (col // 3)
for r in range(box_row, box_row + 3):
for c in range(box_col, box_col + 3):
if grid[r][c] != 0:
used.add(grid[r][c])
return set(range(1, 10)) - used
def _extract_empty_cells_with_candidates(board_str: str, sort_by_difficulty: bool = True):
grid = _parse_board(board_str)
cells = []
for row in range(9):
for col in range(9):
if grid[row][col] == 0:
candidates = _get_valid_numbers(grid, row, col)
cells.append((row + 1, col + 1, candidates))
if sort_by_difficulty:
cells.sort(key=lambda x: len(x[2]))
return cells
def _extract_empty_cells(board_str: str) -> list:
"""Return list of (row, col) tuples for empty cells, 0-indexed."""
grid = _parse_board(board_str)
return [(r, c) for r in range(9) for c in range(9) if grid[r][c] == 0]
def _extract_board_only(text: str) -> str:
if not text:
return ''
lines = text.split('\n')
board_lines = []
in_board = False
for line in lines:
stripped = line.strip()
if stripped.startswith('C1') or (stripped and stripped[0] == 'R' and len(stripped) > 1
and stripped[1].isdigit()):
in_board = True
if in_board and (stripped.startswith('-') or stripped.startswith('R') or stripped.startswith('C1')):
board_lines.append(line)
elif (in_board and stripped and not stripped.startswith('-')
and not (stripped[0] == 'R' and len(stripped) > 1 and stripped[1].isdigit())):
break
return '\n'.join(board_lines) if board_lines else ''
def _make_hints(board_str: str, successful_moves: list, failed_moves: list, difficulty: str = 'easy') -> str:
parts = []
all_tried = successful_moves + failed_moves
if all_tried:
parts.append(f"\nMOVES ALREADY TRIED (do not repeat): {', '.join(all_tried[:10])}")
if not board_str or not _is_valid_board_state(board_str):
return '\n'.join(parts)
cells = _extract_empty_cells_with_candidates(board_str, sort_by_difficulty=True)
if cells:
guaranteed = []
other = []
for r, c, candidates in cells[:10]:
if len(candidates) == 1:
guaranteed.append(f'[{r} {c} {list(candidates)[0]}]')
elif len(candidates) <= 3:
nums = ','.join(str(n) for n in sorted(candidates))
other.append(f'({r},{c})->{nums}')
if guaranteed:
parts.append(f"\nGUARANTEED MOVES (only one option): {', '.join(guaranteed[:5])}")
if other:
parts.append(f"Other options: {' | '.join(other[:5])}")
return '\n'.join(parts)
class SudokuScheduler(OpenEnvScheduler):
"""Sudoku scheduler with multi-reward and hints system.
Tracks 5 reward components per trajectory:
- empty_cell_reward: Did the model target empty cells? (+1/-1)
- valid_move_reward: Were moves accepted by env? (1.0/-0.5/0.0)
- repetition_reward: Penalty for repeating moves (exponential)
- progress_reward: How much of the puzzle was filled (0-1)
- correct_reward: Environment's reward (0 or 1)
Combined reward = sum of all components.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_content_len: Dict[str, int] = {}
# Per-uuid state tracking
self._board_states: Dict[str, str] = {}
self._move_counts: Dict[str, defaultdict] = {}
self._successful_moves: Dict[str, list] = {}
self._failed_moves: Dict[str, list] = {}
self._valid_move_scores: Dict[str, list] = {}
self._empty_cell_scores: Dict[str, list] = {}
self._correct_scores: Dict[str, list] = {}
self._repetition_scores: Dict[str, list] = {}
self._initial_filled: Dict[str, int] = {}
self._max_filled: Dict[str, int] = {}
async def on_trajectory_start(self, requests):
"""Initialize env, parse board, compute hints."""
semaphore = asyncio.Semaphore(getattr(self, 'max_concurrent_envs', 4))
async def _init_single(req):
async with semaphore:
uuid = req.uuid
if uuid in self._envs:
await self._close_and_remove(uuid)
row_env_config = (req.data_dict or {}).get('env_config', {}) if hasattr(req, 'data_dict') else {}
env_config = {**getattr(self, 'env_config_defaults', {}), **row_env_config}
wrapper = self._create_env(env_config)
obs, metadata = await asyncio.to_thread(wrapper.reset)
system_message = env_config.get('system_message', SUDOKU_SYSTEM_PROMPT)
content = self._extract_content(obs)
self._last_content_len[uuid] = len(content)
# Parse initial board state
board = _extract_board_only(content) if _is_valid_board_state(content) else content
self._board_states[uuid] = content if _is_valid_board_state(content) else ''
initial_filled = _count_filled_cells(self._board_states[uuid]) if self._board_states[uuid] else 0
# Initialize tracking state
self._move_counts[uuid] = defaultdict(int)
self._successful_moves[uuid] = []
self._failed_moves[uuid] = []
self._valid_move_scores[uuid] = []
self._empty_cell_scores[uuid] = []
self._correct_scores[uuid] = []
self._repetition_scores[uuid] = []
self._initial_filled[uuid] = initial_filled
self._max_filled[uuid] = initial_filled
# Build initial message with board + hints
hints = _make_hints(self._board_states[uuid], [], [])
user_content = f'{board}{hints}' if board else content
from swift.rollout.multi_turn import Messages
messages = []
if system_message:
messages.append({'role': 'system', 'content': system_message})
messages.append({'role': 'user', 'content': user_content})
req.messages = messages
self._envs[uuid] = wrapper
self._total_rewards[uuid] = 0.0
self._step_rewards[uuid] = []
self._pending_obs[uuid] = None
await asyncio.gather(*[_init_single(req) for req in requests])
async def _close_and_remove(self, uuid):
"""Override to clean up all tracking state."""
await super()._close_and_remove(uuid)
self._last_content_len.pop(uuid, None)
self._board_states.pop(uuid, None)
self._move_counts.pop(uuid, None)
self._successful_moves.pop(uuid, None)
self._failed_moves.pop(uuid, None)
self._valid_move_scores.pop(uuid, None)
self._empty_cell_scores.pop(uuid, None)
self._correct_scores.pop(uuid, None)
self._repetition_scores.pop(uuid, None)
self._initial_filled.pop(uuid, None)
self._max_filled.pop(uuid, None)
def _extract_content(self, observation: Any) -> str:
if isinstance(observation, dict):
messages = observation.get('messages', [])
if messages:
return messages[0].get('content', '')
prompt = observation.get('prompt', '')
if prompt:
return prompt
return str(observation)
async def on_turn_end(self, infer_request, response_choice, current_turn):
"""Parse move, step env, compute multi-reward, generate hints."""
uuid = infer_request.uuid
wrapper = self._envs.get(uuid)
if wrapper is None:
return {'done': True, 'rollout_infos': {}}
action_text = response_choice.message.content
action_dict = self.parse_action(action_text)
if action_dict is None:
# Parse failed: end trajectory with penalty instead of polluting env
self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) - 1.0
self._step_rewards.setdefault(uuid, []).append(-1.0)
await self._close_and_remove(uuid)
return {
'done': True,
'rollout_infos': {
'total_reward': self._total_rewards[uuid],
'step_rewards': list(self._step_rewards.get(uuid, [])),
'gym_done': True,
}
}
move = action_dict.get('message', '')
# Step environment
obs, env_reward, done, metadata = await asyncio.to_thread(wrapper.step, action_dict)
correct_score = float(env_reward or 0.0)
# Extract new content (diff from last seen)
full_content = self._extract_content(obs)
last_len = self._last_content_len.get(uuid, 0)
new_content = full_content[last_len:] if len(full_content) > last_len else full_content
self._last_content_len[uuid] = len(full_content)
# Check if env says invalid
new_content_lower = new_content.lower()
env_says_invalid = any(kw in new_content_lower
for kw in ['invalid', 'error', 'cannot', 'already', 'violation', 'lost'])
# Check if move targets an empty cell
if self._board_states.get(uuid):
empty_cells = _extract_empty_cells(self._board_states[uuid])
# Convert move coords (1-indexed from model) to 0-indexed for comparison
move_nums = re.findall(r'\d+', move)
targets_empty = tuple(int(x) - 1 for x in move_nums[:2]) in empty_cells if len(move_nums) >= 3 else True
else:
targets_empty = True
# Empty cell reward: +1 if targeted empty, -1 if tried to overwrite
empty_cell_score = 1.0 if targets_empty else -1.0
# Repetition tracking
is_new_move = self._move_counts[uuid][move] == 0
repetition_count = self._move_counts[uuid][move]
self._move_counts[uuid][move] += 1
repetition_score = -min(2**repetition_count, 10.0) if repetition_count > 0 else 0.0
# Valid move score
is_valid = not env_says_invalid and targets_empty
if is_valid and is_new_move:
valid_move_score = 1.0
self._successful_moves[uuid].append(move)
elif 'please resubmit' in new_content_lower or 'avoid penalties' in new_content_lower:
valid_move_score = -0.5
self._failed_moves[uuid].append(move)
else:
valid_move_score = 0.0
if not is_valid:
self._failed_moves[uuid].append(move)
# Update board state if valid and new content has board
if is_valid and _is_valid_board_state(new_content):
self._board_states[uuid] = new_content
current_filled = _count_filled_cells(new_content)
if current_filled > self._max_filled[uuid]:
self._max_filled[uuid] = current_filled
# Progress reward
remaining = 81 - self._initial_filled[uuid]
if remaining > 0:
progress_score = (self._max_filled[uuid] - self._initial_filled[uuid]) / remaining
else:
progress_score = 1.0
# Track all scores
self._valid_move_scores[uuid].append(valid_move_score)
self._empty_cell_scores[uuid].append(empty_cell_score)
self._correct_scores[uuid].append(correct_score)
self._repetition_scores[uuid].append(repetition_score)
combined_reward = (
sum(self._empty_cell_scores[uuid]) / max(len(self._empty_cell_scores[uuid]), 1)
+ sum(self._valid_move_scores[uuid]) / max(len(self._valid_move_scores[uuid]), 1)
+ sum(self._repetition_scores[uuid]) / max(len(self._repetition_scores[uuid]), 1) + progress_score
+ correct_score)
self._total_rewards[uuid] = combined_reward
self._step_rewards.setdefault(uuid, []).append(combined_reward)
# Build next observation with board + hints
if not done:
board_str = self._board_states.get(uuid, '')
board = _extract_board_only(board_str) if board_str else ''
hints = _make_hints(
board_str,
self._successful_moves[uuid],
self._failed_moves[uuid],
)
step_num = len(self._successful_moves[uuid])
next_obs = f'Step {step_num}. Progress: {step_num} cells filled.\n\nBoard:\n{board}{hints}'
else:
next_obs = None
self._pending_obs[uuid] = next_obs
rollout_infos = {
'total_reward': self._total_rewards[uuid],
'step_rewards': list(self._step_rewards.get(uuid, [])),
'gym_done': done,
'empty_cell_reward': sum(self._empty_cell_scores[uuid]) / max(len(self._empty_cell_scores[uuid]), 1),
'valid_move_reward': sum(self._valid_move_scores[uuid]) / max(len(self._valid_move_scores[uuid]), 1),
'repetition_reward': sum(self._repetition_scores[uuid]) / max(len(self._repetition_scores[uuid]), 1),
'progress_reward': progress_score,
'correct_reward': correct_score,
}
if done:
await self._close_and_remove(uuid)
return {'done': done, 'rollout_infos': rollout_infos}
def parse_action(self, text: str) -> Optional[Dict[str, Any]]:
"""Extract [row col number] from model output. Returns None if parse fails."""
match = re.search(r'\[\s*(\d+)\s+(\d+)\s+(\d+)\s*\]', text)
if match:
row, col, num = match.groups()
return {'message': f'[{row} {col} {num}]'}
numbers = re.findall(r'\d+', text)
if len(numbers) >= 3:
return {'message': f'[{numbers[0]} {numbers[1]} {numbers[2]}]'}
return None
def format_observation(self, observation: Any) -> Union[str, List[Dict]]:
return self._extract_content(observation)
# Register scheduler so --external_plugins can load it
multi_turns['sudoku_scheduler'] = SudokuScheduler