File size: 8,007 Bytes
caea08d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04b615f
 
 
 
 
 
 
 
 
 
 
caea08d
 
 
 
 
 
 
 
04b615f
 
 
caea08d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04b615f
 
 
 
 
 
 
 
 
 
 
 
caea08d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04b615f
 
 
 
 
 
 
caea08d
04b615f
 
 
caea08d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""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