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

195 lines
7.3 KiB
Python

# Copyright (c) ModelScope Contributors. All rights reserved.
"""Text-based FrozenLake env for multi-turn GRPO training.
Each turn the LLM sees an ASCII grid (S/G/H/F/P) and must reply with a
single move inside ``<action>...</action>``. Reaching G yields reward 1.0;
stepping into H ends the episode with 0. The outer step budget comes from
swift's ``--max_turns`` flag. Per-row ``env_config.seed`` controls map
generation so all ``num_generations`` rollouts of a row share the same map.
Register via ``--external_plugins`` and select with ``--gym_env frozen_lake``.
"""
# code borrowed from ROLL/roll/pipeline/agentic/env/frozen_lake
from __future__ import annotations
import random
import re
from typing import Any, Dict, List, Optional, Tuple
from swift.infer_engine.protocol import RolloutInferRequest
from swift.rollout.gym_env import Env, envs
from swift.template import Messages
from swift.utils import get_logger
logger = get_logger()
def _is_valid(board: List[List[str]], size: int) -> bool:
"""BFS from S; return True iff G is reachable through non-hole cells."""
start = None
for r in range(size):
for c in range(size):
if board[r][c] == 'S':
start = (r, c)
break
if start is not None:
break
if start is None:
return False
frontier = [start]
discovered = set()
while frontier:
r, c = frontier.pop()
if (r, c) in discovered:
continue
discovered.add((r, c))
for dr, dc in ((1, 0), (0, 1), (-1, 0), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < size and 0 <= nc < size:
cell = board[nr][nc]
if cell == 'G':
return True
if cell != 'H':
frontier.append((nr, nc))
return False
def generate_random_map(size: int = 4, p: float = 0.8, seed: Optional[int] = None) -> List[str]:
"""Generate a random solvable FrozenLake map.
Args:
size: side length of the square grid.
p: probability of a non-hole (F) cell — 1 - p of any cell being H.
seed: RNG seed; same seed always yields the same map.
Returns:
List of `size` strings, each of length `size`, using chars S/G/F/H.
Both S and G positions are randomised (unlike gym's ``FrozenLake-v1``
which pins S to top-left and G to bottom-right); BFS-validated.
"""
rng = random.Random(seed)
while True:
board = [['F' if rng.random() < p else 'H' for _ in range(size)] for _ in range(size)]
start_r, start_c = rng.randrange(size), rng.randrange(size)
goal_r, goal_c = rng.randrange(size), rng.randrange(size)
if (start_r, start_c) == (goal_r, goal_c):
continue
board[start_r][start_c] = 'S'
board[goal_r][goal_c] = 'G'
if _is_valid(board, size):
return [''.join(row) for row in board]
# (row_delta, col_delta) for each canonical action token.
ACTIONS: Dict[str, Tuple[int, int]] = {
'up': (-1, 0),
'down': (1, 0),
'left': (0, -1),
'right': (0, 1),
}
SYSTEM_PROMPT = ('You are playing FrozenLake. You see a grid where:\n'
' P = your current position, S = start, G = goal, H = hole, F = safe ice.\n'
'Move one cell per turn. Reach G to win (+1 reward). Stepping into H ends '
'the episode with 0 reward. Moves that would go off the grid leave you in '
'place.\n\n'
'On every turn, output your move inside <action>...</action>. '
'The action must be exactly one of: up, down, left, right.\n\n'
'Example: <action>down</action>')
_ACTION_TAG_RE = re.compile(r'<action>\s*(up|down|left|right)\s*</action>', re.IGNORECASE)
_BARE_ACTION_RE = re.compile(r'\b(up|down|left|right)\b', re.IGNORECASE)
def _render(grid: List[str], row: int, col: int) -> str:
"""Render the grid with the player position marked as 'P'."""
rendered = []
for r, line in enumerate(grid):
if r == row:
chars = list(line)
chars[col] = 'P' if chars[col] != 'G' else '*' # '*' = player on goal
rendered.append(' '.join(chars))
else:
rendered.append(' '.join(line))
return '\n'.join(rendered)
def _parse_action(completion: str) -> Optional[str]:
"""Extract the action from the assistant message. Returns None if missing."""
m = _ACTION_TAG_RE.search(completion)
if m:
return m.group(1).lower()
matches = _BARE_ACTION_RE.findall(completion)
if matches:
return matches[-1].lower()
return None
class FrozenLakeEnv(Env):
def __init__(self, env_config: Dict[str, Any]):
super().__init__(env_config)
self.size: int = int(env_config.get('size', 4))
self.p: float = float(env_config.get('p', 0.8))
seed = env_config.get('seed')
self.seed: Optional[int] = int(seed) if seed is not None else None
self.grid: List[str] = []
self.row: int = 0
self.col: int = 0
self.steps: int = 0
async def reset(self, config: RolloutInferRequest) -> Tuple[str, Dict[str, Any], str]:
self.grid = generate_random_map(size=self.size, p=self.p, seed=self.seed)
for r, line in enumerate(self.grid):
if 'S' in line:
self.row, self.col = r, line.index('S')
break
self.steps = 0
observation = (f'FrozenLake {self.size}x{self.size}:\n'
f'{_render(self.grid, self.row, self.col)}\n\n'
f'You are at row {self.row}, col {self.col}. Output your first move.')
info = {'seed': self.seed, 'size': self.size}
return observation, info, SYSTEM_PROMPT
async def step(self, action: Messages) -> Tuple[str, float, bool, Dict[str, Any]]:
completion = action[-1].get('content', '') if action else ''
move = _parse_action(completion)
self.steps += 1
info: Dict[str, Any] = {'seed': self.seed, 'step': self.steps, 'parsed_action': move}
if move is None:
obs = (f'Invalid response: could not find a move. Reply with '
f'<action>up|down|left|right</action>.\n\n'
f'{_render(self.grid, self.row, self.col)}')
return obs, 0.0, False, {**info, 'status': 'invalid_action'}
dr, dc = ACTIONS[move]
new_row = max(0, min(len(self.grid) - 1, self.row + dr))
new_col = max(0, min(len(self.grid[0]) - 1, self.col + dc))
cell = self.grid[new_row][new_col]
self.row, self.col = new_row, new_col
if cell == 'G':
obs = (f'You moved {move} and reached the goal!\n'
f'{_render(self.grid, self.row, self.col)}')
return obs, 1.0, True, {**info, 'status': 'goal'}
if cell == 'H':
obs = (f'You moved {move} and fell into a hole. Episode over.\n'
f'{_render(self.grid, self.row, self.col)}')
return obs, 0.0, True, {**info, 'status': 'hole'}
obs = (f'You moved {move}. Now at row {self.row}, col {self.col} (step {self.steps}).\n'
f'{_render(self.grid, self.row, self.col)}\n'
f'Output your next move.')
return obs, 0.0, False, {**info, 'status': 'ok'}
async def close(self):
pass
envs['frozen_lake'] = FrozenLakeEnv