Spaces:
Sleeping
Sleeping
| """Module 5 (TRL OpenEnv Wordle) style rollout for ShopManagerEng. | |
| Two public symbols: | |
| * ``rollout_once(...)`` — plays a single multi-turn jewelry-shop episode | |
| against an already-connected sync env client and returns the per-episode | |
| signals TRL/GRPO needs. | |
| * ``build_rollout_func(...)`` — closure factory that returns the | |
| ``rollout_func(prompts, trainer=None)`` callable handed to ``GRPOTrainer``. | |
| The pattern (canonical for OpenEnv + TRL >= 0.17): | |
| sync_env = env.sync(); sync_env.connect() # one persistent WS | |
| trainer = GRPOTrainer(..., rollout_func=rollout_func) | |
| trainer.train() | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Any, Callable, Dict, List, Optional | |
| try: | |
| from .parse_action import parse_model_text_to_action | |
| from .prompts import build_user_prompt | |
| except ImportError: | |
| from training.parse_action import parse_model_text_to_action | |
| from training.prompts import build_user_prompt | |
| # Set of valid task ids supported by openenv.yaml; first one is the default. | |
| VALID_TASKS = ("market_timing", "demand_crafter", "profit_negotiator") | |
| _TASK_RE = re.compile(r"\[TASK=(\w+)\]") | |
| def extract_task_id(prompt_text: str, default: str = VALID_TASKS[0]) -> str: | |
| """Pull the [TASK=...] tag the dataset embeds, or fall back to the default.""" | |
| m = _TASK_RE.search(prompt_text or "") | |
| if not m: | |
| return default | |
| candidate = m.group(1) | |
| return candidate if candidate in VALID_TASKS else default | |
| def _apply_chat_template(tokenizer, messages, model_name: str = "") -> str: | |
| """Apply chat template, opting out of Qwen3 'thinking' mode when applicable.""" | |
| template_kwargs: Dict[str, Any] = { | |
| "add_generation_prompt": True, | |
| "tokenize": False, | |
| } | |
| # Qwen3 family supports the `enable_thinking` switch — disable it for short | |
| # action outputs. Other models silently ignore unknown kwargs in newer | |
| # transformers; older ones may raise, hence the lower() guard. | |
| if "qwen3" in (model_name or "").lower(): | |
| template_kwargs["enable_thinking"] = False | |
| return tokenizer.apply_chat_template(messages, **template_kwargs) | |
| def rollout_once( | |
| *, | |
| trainer, | |
| sync_env, | |
| tokenizer, | |
| dataset_prompt: str, | |
| system_prompt: str, | |
| max_turns: int, | |
| model_name: str = "", | |
| ) -> Dict[str, Any]: | |
| """Play one full jewelry-shop episode and return per-episode signals. | |
| Returns the dict shape TRL's GRPO loop expects: ``prompt_ids``, | |
| ``completion_ids``, ``logprobs`` for **a single** vLLM forward (the **last | |
| environment turn** in the episode) plus reward signals for reward | |
| functions. | |
| We **do not** concatenate multiple turns into one list. In ``GRPOTrainer``, | |
| each batch row is ``cat(prompt_ids, completion_ids)``; vLLM's per-token | |
| ``logprobs`` must be for **that** exact sequence, or the importance-sampling | |
| ratio (vLLM vs reference forward) collapses. Multi-turn play still runs in | |
| the environment; the policy gradient is applied to the **last** action's | |
| tokens, while ``total_reward`` remains the full episode return for GRPO | |
| group advantages. | |
| """ | |
| # Late import: trl.experimental.openenv only exists for trl >= 0.17. | |
| from trl.experimental.openenv import generate_rollout_completions | |
| task_id = extract_task_id(dataset_prompt) | |
| result = sync_env.reset(task_id=task_id) | |
| obs = result.observation | |
| # One (prompt_ids, completion_ids, logprobs) per vLLM call; last turn only | |
| # is returned to TRL (see module docstring). | |
| turn_traces: List[Dict[str, Any]] = [] | |
| history: List[str] = [] | |
| last_reward = 0.0 | |
| phase_rewards = {"market": 0.0, "warehouse": 0.0, "showroom": 0.0} | |
| for turn in range(1, max_turns + 1): | |
| if result.done: | |
| break | |
| user_prompt = build_user_prompt(turn, obs, last_reward, history) | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| prompt_text = _apply_chat_template(tokenizer, messages, model_name=model_name) | |
| rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0] | |
| p_ids = rollout_outputs["prompt_ids"] | |
| c_ids = rollout_outputs["completion_ids"] | |
| lps = rollout_outputs["logprobs"] | |
| p_list = p_ids.tolist() if hasattr(p_ids, "tolist") else list(p_ids) | |
| c_list = c_ids.tolist() if hasattr(c_ids, "tolist") else list(c_ids) | |
| turn_traces.append( | |
| { | |
| "prompt_ids": p_list, | |
| "completion_ids": c_list, | |
| "logprobs": [float(x) for x in lps], | |
| } | |
| ) | |
| completion_text = rollout_outputs.get("text") or tokenizer.decode( | |
| rollout_outputs["completion_ids"], skip_special_tokens=True | |
| ) | |
| current_phase = obs.phase | |
| action, raw_action_str = parse_model_text_to_action(current_phase, completion_text) | |
| result = sync_env.step(action) | |
| obs = result.observation | |
| step_reward = float(result.reward or 0.0) | |
| last_reward = step_reward | |
| if current_phase in phase_rewards: | |
| phase_rewards[current_phase] += step_reward | |
| history.append( | |
| f"Step {turn} ({current_phase}): {raw_action_str!r} -> reward {step_reward:+.2f}" | |
| ) | |
| total_reward = float(getattr(obs, "cumulative_reward", sum(phase_rewards.values()))) | |
| total_reward = max(0.0, min(total_reward, 1.0)) | |
| if not turn_traces: | |
| raise ValueError( | |
| "rollout_once produced no vLLM turns (max_turns too low or env ended " | |
| "before the first action)." | |
| ) | |
| last = turn_traces[-1] | |
| return { | |
| "prompt_ids": last["prompt_ids"], | |
| "completion_ids": last["completion_ids"], | |
| "logprobs": last["logprobs"], | |
| "total_reward": total_reward, | |
| "market_reward": float(phase_rewards["market"]), | |
| "warehouse_reward": float(phase_rewards["warehouse"]), | |
| "showroom_reward": float(phase_rewards["showroom"]), | |
| } | |
| def build_rollout_func( | |
| *, | |
| sync_env, | |
| tokenizer, | |
| system_prompt: str, | |
| max_turns: int = 15, | |
| model_name: str = "", | |
| ) -> Callable[..., Dict[str, List]]: | |
| """Return ``rollout_func(prompts, trainer=None)`` closing over the env client. | |
| A fresh episode is run for each prompt; the same persistent ``sync_env`` | |
| is reused across all prompts (single WebSocket session — matches Module 5). | |
| """ | |
| def rollout_func(prompts: List[str], trainer=None) -> Dict[str, List]: | |
| episode_prompt_ids: List[List[int]] = [] | |
| episode_completion_ids: List[List[int]] = [] | |
| episode_logprobs: List[List[float]] = [] | |
| total_rewards: List[float] = [] | |
| market_rewards: List[float] = [] | |
| warehouse_rewards: List[float] = [] | |
| showroom_rewards: List[float] = [] | |
| for prompt_text in prompts: | |
| ep = rollout_once( | |
| trainer=trainer, | |
| sync_env=sync_env, | |
| tokenizer=tokenizer, | |
| dataset_prompt=prompt_text, | |
| system_prompt=system_prompt, | |
| max_turns=max_turns, | |
| model_name=model_name, | |
| ) | |
| episode_prompt_ids.append(ep["prompt_ids"]) | |
| episode_completion_ids.append(ep["completion_ids"]) | |
| episode_logprobs.append(ep["logprobs"]) | |
| total_rewards.append(ep["total_reward"]) | |
| market_rewards.append(ep["market_reward"]) | |
| warehouse_rewards.append(ep["warehouse_reward"]) | |
| showroom_rewards.append(ep["showroom_reward"]) | |
| return { | |
| "prompt_ids": episode_prompt_ids, | |
| "completion_ids": episode_completion_ids, | |
| "logprobs": episode_logprobs, | |
| "total_reward": total_rewards, | |
| "market_reward": market_rewards, | |
| "warehouse_reward": warehouse_rewards, | |
| "showroom_reward": showroom_rewards, | |
| } | |
| return rollout_func | |