Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

417 lines
17 KiB
Python

"""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