vishgg commited on
Commit
fd3dff7
Β·
1 Parent(s): 34f7311
README.md CHANGED
@@ -15,6 +15,8 @@ tags:
15
 
16
  An RL environment that simulates **Go-To-Market (GTM) strategy optimization** for product launches. Agents learn to allocate marketing budgets, target customer segments, craft messaging, run experiments, and adjust pricing to maximize revenue under uncertainty.
17
 
 
 
18
  ## Why GTM?
19
 
20
  Every startup and growth team does GTM optimization manually β€” iterating on channels, messaging, and targeting through trial and error. This environment captures the real complexity: noisy metrics, delayed brand effects, diminishing returns on ad spend, and the tension between short-term revenue and long-term brand strength.
@@ -96,6 +98,35 @@ export OPENAI_API_KEY=sk-...
96
  python baseline.py --model gpt-4o-mini
97
  ```
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ### API Endpoints
100
 
101
  | Endpoint | Method | Description |
@@ -103,6 +134,7 @@ python baseline.py --model gpt-4o-mini
103
  | `/tasks` | GET | List all tasks with action schemas |
104
  | `/baseline` | POST | Run heuristic baseline, return scores |
105
  | `/grader` | POST | Get grader score for a task |
 
106
  | `/reset` | POST | Reset environment for a task |
107
  | `/step` | POST | Execute one action step |
108
  | `/state` | GET | Get current episode state |
 
15
 
16
  An RL environment that simulates **Go-To-Market (GTM) strategy optimization** for product launches. Agents learn to allocate marketing budgets, target customer segments, craft messaging, run experiments, and adjust pricing to maximize revenue under uncertainty.
17
 
18
+ The Hugging Face Space ships with an interactive dashboard at `/web/` (the default route) that runs the **trained PPO policy**, an **equal-allocation heuristic**, and a **uniform random** agent on the same task and seed, then renders revenue / brand-health / budget-allocation comparisons via Plotly.
19
+
20
  ## Why GTM?
21
 
22
  Every startup and growth team does GTM optimization manually β€” iterating on channels, messaging, and targeting through trial and error. This environment captures the real complexity: noisy metrics, delayed brand effects, diminishing returns on ad spend, and the tension between short-term revenue and long-term brand strength.
 
98
  python baseline.py --model gpt-4o-mini
99
  ```
100
 
101
+ ### Train an RL Policy
102
+
103
+ A custom lightweight PPO trainer (`rl/train.py`) trains a small actor-critic
104
+ network against the simulator. One checkpoint per task.
105
+
106
+ ```bash
107
+ # Train
108
+ python -m rl.train --task channel_optimizer --total-steps 200000
109
+ python -m rl.train --task growth_strategist --total-steps 300000
110
+ python -m rl.train --task market_dominator --total-steps 500000
111
+
112
+ # Inference (greedy rollout, prints per-week actions and grader score)
113
+ python -m rl.infer --task channel_optimizer
114
+ ```
115
+
116
+ Checkpoints are written to `checkpoints/<task_id>.pt`. Commit them so the
117
+ deployed Space can serve `/infer` without retraining.
118
+
119
+ ### Inference via API
120
+
121
+ ```bash
122
+ curl -X POST http://localhost:7860/infer \
123
+ -H "Content-Type: application/json" \
124
+ -d '{"task_id": "channel_optimizer", "seed": 42}'
125
+ ```
126
+
127
+ Returns a JSON payload with `grader_score`, `total_revenue`, and the full
128
+ weekly action trajectory.
129
+
130
  ### API Endpoints
131
 
132
  | Endpoint | Method | Description |
 
134
  | `/tasks` | GET | List all tasks with action schemas |
135
  | `/baseline` | POST | Run heuristic baseline, return scores |
136
  | `/grader` | POST | Get grader score for a task |
137
+ | `/infer` | POST | Run trained RL policy on a task, return action trajectory |
138
  | `/reset` | POST | Reset environment for a task |
139
  | `/step` | POST | Execute one action step |
140
  | `/state` | GET | Get current episode state |
checkpoints/.gitkeep ADDED
File without changes
checkpoints/channel_optimizer.pt ADDED
Binary file (94 kB). View file
 
requirements.txt CHANGED
@@ -5,3 +5,6 @@ pydantic>=2.0.0
5
  websockets>=15.0.1
6
  openai>=1.0.0
7
  numpy>=1.24.0
 
 
 
 
5
  websockets>=15.0.1
6
  openai>=1.0.0
7
  numpy>=1.24.0
8
+ torch>=2.0.0
9
+ plotly>=5.0
10
+ pandas>=2.0
rl/__init__.py ADDED
File without changes
rl/env_adapter.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bridge between GTMEnvironment (dict-based) and tensor-based RL code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List
6
+
7
+ import torch
8
+
9
+ from models import GTMAction, GTMObservation
10
+ from server.simulation import MESSAGING_DIMS
11
+ from server.tasks import TaskDefinition
12
+
13
+ # Sentinel string used by the categorical heads when the agent picks "no action"
14
+ NONE_OPTION = "__none__"
15
+
16
+
17
+ def compute_obs_dim(task: TaskDefinition) -> int:
18
+ """Number of scalar features in the flattened observation tensor."""
19
+ n_channels = len(task.channels)
20
+ n_segments = len(task.segments)
21
+ # 5 globals + 5 per channel + 4 per segment
22
+ return 5 + 5 * n_channels + 4 * n_segments
23
+
24
+
25
+ def compute_action_dims(task: TaskDefinition) -> Dict[str, int]:
26
+ """Output sizes for each policy head."""
27
+ return {
28
+ "budget": len(task.channels),
29
+ "segment": len(task.segments),
30
+ "messaging": len(MESSAGING_DIMS),
31
+ # +1 for the "none" option
32
+ "experiment": len(task.available_experiments) + 1,
33
+ "pricing": len(task.available_pricing_actions) + 1,
34
+ }
35
+
36
+
37
+ def experiment_options(task: TaskDefinition) -> List[str]:
38
+ return [NONE_OPTION] + list(task.available_experiments)
39
+
40
+
41
+ def pricing_options(task: TaskDefinition) -> List[str]:
42
+ return [NONE_OPTION] + list(task.available_pricing_actions)
43
+
44
+
45
+ def obs_to_tensor(obs: GTMObservation, task: TaskDefinition) -> torch.Tensor:
46
+ """Flatten a GTMObservation into a fixed-size float32 tensor.
47
+
48
+ Layout (in order):
49
+ - week / total_weeks
50
+ - budget_remaining / total_budget
51
+ - brand_score / 100
52
+ - total_revenue / revenue_target
53
+ - average_cac / 100
54
+ - per channel (in task order): spend/weekly_budget, ctr, cvr, roi, conversions/100
55
+ - per segment (in task order): conversion_rate, engagement/100, churn, revenue/10k
56
+ """
57
+ total_weeks = max(obs.total_weeks, 1)
58
+ total_budget = task.total_budget if task.total_budget > 0 else 1.0
59
+ weekly_budget = max(obs.weekly_budget, 1.0)
60
+ revenue_target = task.revenue_target if task.revenue_target > 0 else 1.0
61
+
62
+ feats: List[float] = [
63
+ obs.week / total_weeks,
64
+ obs.budget_remaining / total_budget,
65
+ obs.brand_score / 100.0,
66
+ obs.total_revenue / revenue_target,
67
+ obs.average_cac / 100.0,
68
+ ]
69
+
70
+ for ch in task.channels:
71
+ m = obs.channel_metrics.get(ch.name)
72
+ if m is None:
73
+ feats.extend([0.0, 0.0, 0.0, 0.0, 0.0])
74
+ else:
75
+ feats.extend([
76
+ m.spend / weekly_budget,
77
+ m.ctr,
78
+ m.cvr,
79
+ # clip ROI into a reasonable range
80
+ max(-2.0, min(5.0, m.roi)),
81
+ m.conversions / 100.0,
82
+ ])
83
+
84
+ for seg in task.segments:
85
+ sm = obs.segment_performance.get(seg.name)
86
+ if sm is None:
87
+ feats.extend([0.0, 0.0, 0.0, 0.0])
88
+ else:
89
+ feats.extend([
90
+ sm.conversion_rate,
91
+ min(1.0, sm.engagement_score / 100.0),
92
+ sm.churn_rate,
93
+ sm.revenue / 10000.0,
94
+ ])
95
+
96
+ return torch.tensor(feats, dtype=torch.float32)
97
+
98
+
99
+ def policy_sample_to_action(
100
+ sample: Dict[str, torch.Tensor],
101
+ task: TaskDefinition,
102
+ ) -> GTMAction:
103
+ """Convert sampled policy outputs into a GTMAction.
104
+
105
+ sample keys:
106
+ budget β€” Tensor[n_channels] on the simplex (Dirichlet sample)
107
+ segment β€” Tensor[n_segments] on the simplex
108
+ messagingβ€” Tensor[6] on the simplex
109
+ experiment β€” int (index into experiment_options(task))
110
+ pricing β€” int (index into pricing_options(task))
111
+ """
112
+ budget = sample["budget"].detach().cpu().tolist()
113
+ segment = sample["segment"].detach().cpu().tolist()
114
+ messaging = sample["messaging"].detach().cpu().tolist()
115
+
116
+ budget_alloc = {ch.name: float(budget[i]) for i, ch in enumerate(task.channels)}
117
+ segment_target = {seg.name: float(segment[i]) for i, seg in enumerate(task.segments)}
118
+ messaging_dict = {dim: float(messaging[i]) for i, dim in enumerate(MESSAGING_DIMS)}
119
+
120
+ exp_idx = int(sample["experiment"].item())
121
+ exp_opts = experiment_options(task)
122
+ experiment = exp_opts[exp_idx] if exp_idx < len(exp_opts) else NONE_OPTION
123
+ if experiment == NONE_OPTION:
124
+ experiment = None
125
+
126
+ price_idx = int(sample["pricing"].item())
127
+ price_opts = pricing_options(task)
128
+ pricing = price_opts[price_idx] if price_idx < len(price_opts) else NONE_OPTION
129
+ if pricing == NONE_OPTION:
130
+ pricing = None
131
+
132
+ return GTMAction(
133
+ budget_allocation=budget_alloc,
134
+ segment_targeting=segment_target,
135
+ messaging=messaging_dict,
136
+ experiment=experiment,
137
+ pricing_action=pricing,
138
+ )
rl/infer.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference: load a trained checkpoint and run one episode of GTM optimization.
2
+
3
+ Usage:
4
+ python -m rl.infer --task channel_optimizer
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import torch
16
+
17
+ # Make repo root importable when run as a module
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from server.environment import GTMEnvironment
21
+ from server.tasks import get_task
22
+
23
+ from rl.env_adapter import (
24
+ compute_action_dims,
25
+ compute_obs_dim,
26
+ obs_to_tensor,
27
+ policy_sample_to_action,
28
+ )
29
+ from rl.policy import GTMActorCritic
30
+
31
+
32
+ def _build_policy(task_id: str) -> tuple[GTMActorCritic, Any]:
33
+ task = get_task(task_id)
34
+ obs_dim = compute_obs_dim(task)
35
+ action_dims = compute_action_dims(task)
36
+ policy = GTMActorCritic(
37
+ obs_dim=obs_dim,
38
+ n_channels=action_dims["budget"],
39
+ n_segments=action_dims["segment"],
40
+ n_messaging=action_dims["messaging"],
41
+ n_experiments=action_dims["experiment"],
42
+ n_pricing=action_dims["pricing"],
43
+ )
44
+ return policy, task
45
+
46
+
47
+ def run_inference(
48
+ task_id: str,
49
+ checkpoint_path: Optional[str] = None,
50
+ seed: Optional[int] = None,
51
+ ) -> Dict[str, Any]:
52
+ """Run one deterministic episode and return the action trajectory + metrics."""
53
+ policy, task = _build_policy(task_id)
54
+
55
+ if checkpoint_path is None:
56
+ checkpoint_path = os.path.join("checkpoints", f"{task_id}.pt")
57
+
58
+ loaded = False
59
+ if os.path.exists(checkpoint_path):
60
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
61
+ policy.load_state_dict(ckpt["model_state"])
62
+ loaded = True
63
+
64
+ policy.eval()
65
+
66
+ env = GTMEnvironment()
67
+ obs = env.reset(task_id=task_id, seed=seed)
68
+ actions: List[Dict[str, Any]] = []
69
+
70
+ while not obs.done:
71
+ obs_t = obs_to_tensor(obs, task)
72
+ with torch.no_grad():
73
+ sample, _, _ = policy.act(obs_t.unsqueeze(0), deterministic=True)
74
+ sample_squeezed = {k: v.squeeze(0) for k, v in sample.items()}
75
+ action = policy_sample_to_action(sample_squeezed, task)
76
+
77
+ next_obs = env.step(action)
78
+ actions.append({
79
+ "week": next_obs.week,
80
+ "budget_allocation": action.budget_allocation,
81
+ "segment_targeting": action.segment_targeting,
82
+ "messaging": action.messaging,
83
+ "experiment": action.experiment,
84
+ "pricing_action": action.pricing_action,
85
+ "weekly_reward": next_obs.reward,
86
+ "total_revenue": next_obs.total_revenue,
87
+ "brand_score": next_obs.brand_score,
88
+ })
89
+ obs = next_obs
90
+
91
+ grader_score = env.get_grader_score(env.state.episode_id)
92
+ msg = (
93
+ f"Trained policy ({checkpoint_path})" if loaded
94
+ else f"Untrained random policy (no checkpoint at {checkpoint_path})"
95
+ )
96
+
97
+ return {
98
+ "task_id": task_id,
99
+ "checkpoint_loaded": loaded,
100
+ "grader_score": grader_score,
101
+ "total_revenue": float(obs.total_revenue),
102
+ "total_conversions": int(obs.total_conversions),
103
+ "average_cac": float(obs.average_cac),
104
+ "brand_score": float(obs.brand_score),
105
+ "actions": actions,
106
+ "message": msg,
107
+ }
108
+
109
+
110
+ def main() -> None:
111
+ parser = argparse.ArgumentParser()
112
+ parser.add_argument("--task", required=True, choices=["channel_optimizer", "growth_strategist", "market_dominator"])
113
+ parser.add_argument("--checkpoint", default=None)
114
+ parser.add_argument("--seed", type=int, default=None)
115
+ parser.add_argument("--json", action="store_true", help="Print full JSON output")
116
+ args = parser.parse_args()
117
+
118
+ result = run_inference(args.task, checkpoint_path=args.checkpoint, seed=args.seed)
119
+
120
+ if args.json:
121
+ print(json.dumps(result, indent=2, default=str))
122
+ return
123
+
124
+ print(f"Task: {result['task_id']}")
125
+ print(f"Checkpoint loaded: {result['checkpoint_loaded']}")
126
+ print(f"Grader score: {result['grader_score']}")
127
+ print(f"Total revenue: ${result['total_revenue']:,.2f}")
128
+ print(f"Total conversions: {result['total_conversions']}")
129
+ print(f"Average CAC: ${result['average_cac']:.2f}")
130
+ print(f"Brand score: {result['brand_score']:.1f}")
131
+ print()
132
+ print("Weekly actions:")
133
+ for a in result["actions"]:
134
+ budget_str = ", ".join(f"{k}={v:.2f}" for k, v in a["budget_allocation"].items())
135
+ print(f" Week {a['week']:2d}: budget=[{budget_str}] reward={a['weekly_reward']:.3f}")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
rl/policy.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Actor-critic MLP policy for the GTM environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Tuple
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from torch.distributions import Categorical, Dirichlet
11
+
12
+
13
+ class GTMActorCritic(nn.Module):
14
+ """Small actor-critic network with task-specific action heads.
15
+
16
+ Heads:
17
+ budget β€” Dirichlet over channels (simplex)
18
+ segment β€” Dirichlet over segments (simplex)
19
+ messaging β€” Dirichlet over 6 dimensions (simplex)
20
+ experimentβ€” Categorical (incl. "none")
21
+ pricing β€” Categorical (incl. "none")
22
+
23
+ Concentrations for the Dirichlet heads are produced by softplus(linear)+1
24
+ so they are always positive and start near a uniform distribution.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ obs_dim: int,
30
+ n_channels: int,
31
+ n_segments: int,
32
+ n_messaging: int,
33
+ n_experiments: int,
34
+ n_pricing: int,
35
+ hidden_dim: int = 128,
36
+ ):
37
+ super().__init__()
38
+ self.obs_dim = obs_dim
39
+ self.n_channels = n_channels
40
+ self.n_segments = n_segments
41
+ self.n_messaging = n_messaging
42
+ self.n_experiments = n_experiments
43
+ self.n_pricing = n_pricing
44
+
45
+ self.trunk = nn.Sequential(
46
+ nn.Linear(obs_dim, hidden_dim),
47
+ nn.Tanh(),
48
+ nn.Linear(hidden_dim, hidden_dim),
49
+ nn.Tanh(),
50
+ )
51
+
52
+ self.budget_head = nn.Linear(hidden_dim, n_channels)
53
+ self.segment_head = nn.Linear(hidden_dim, n_segments)
54
+ self.messaging_head = nn.Linear(hidden_dim, n_messaging)
55
+ self.experiment_head = nn.Linear(hidden_dim, n_experiments)
56
+ self.pricing_head = nn.Linear(hidden_dim, n_pricing)
57
+ self.value_head = nn.Linear(hidden_dim, 1)
58
+
59
+ def forward(self, obs: torch.Tensor) -> Tuple[Dict[str, torch.distributions.Distribution], torch.Tensor]:
60
+ h = self.trunk(obs)
61
+
62
+ budget_alpha = F.softplus(self.budget_head(h)) + 1.0
63
+ segment_alpha = F.softplus(self.segment_head(h)) + 1.0
64
+ messaging_alpha = F.softplus(self.messaging_head(h)) + 1.0
65
+
66
+ # Avoid sampling exact zeros which would break Dirichlet log_prob
67
+ budget_alpha = budget_alpha.clamp(min=1e-3)
68
+ segment_alpha = segment_alpha.clamp(min=1e-3)
69
+ messaging_alpha = messaging_alpha.clamp(min=1e-3)
70
+
71
+ dists: Dict[str, torch.distributions.Distribution] = {
72
+ "budget": Dirichlet(budget_alpha),
73
+ "segment": Dirichlet(segment_alpha),
74
+ "messaging": Dirichlet(messaging_alpha),
75
+ "experiment": Categorical(logits=self.experiment_head(h)),
76
+ "pricing": Categorical(logits=self.pricing_head(h)),
77
+ }
78
+ value = self.value_head(h).squeeze(-1)
79
+ return dists, value
80
+
81
+ def act(
82
+ self,
83
+ obs: torch.Tensor,
84
+ deterministic: bool = False,
85
+ ) -> Tuple[Dict[str, torch.Tensor], torch.Tensor, torch.Tensor]:
86
+ """Sample (or pick the mode of) an action from the policy.
87
+
88
+ Returns: (sample_dict, total_log_prob, value)
89
+ """
90
+ dists, value = self.forward(obs)
91
+ sample: Dict[str, torch.Tensor] = {}
92
+ log_probs = []
93
+ for name, dist in dists.items():
94
+ if deterministic:
95
+ if isinstance(dist, Dirichlet):
96
+ # mean of a Dirichlet
97
+ s = dist.concentration / dist.concentration.sum(dim=-1, keepdim=True)
98
+ else:
99
+ s = dist.probs.argmax(dim=-1)
100
+ else:
101
+ s = dist.sample()
102
+ sample[name] = s
103
+ log_probs.append(dist.log_prob(s))
104
+ total_log_prob = torch.stack(log_probs, dim=0).sum(dim=0)
105
+ return sample, total_log_prob, value
106
+
107
+ def evaluate_actions(
108
+ self,
109
+ obs: torch.Tensor,
110
+ actions: Dict[str, torch.Tensor],
111
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
112
+ """Recompute log-probs, entropy, and value for stored (obs, action) pairs."""
113
+ dists, value = self.forward(obs)
114
+ log_probs = []
115
+ entropies = []
116
+ for name, dist in dists.items():
117
+ log_probs.append(dist.log_prob(actions[name]))
118
+ entropies.append(dist.entropy())
119
+ total_log_prob = torch.stack(log_probs, dim=0).sum(dim=0)
120
+ total_entropy = torch.stack(entropies, dim=0).sum(dim=0)
121
+ return total_log_prob, total_entropy, value
rl/train.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PPO trainer for the GTM Strategy Optimizer.
2
+
3
+ Usage:
4
+ python -m rl.train --task channel_optimizer --total-steps 200000 --seed 0
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Dict, List
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.optim as optim
19
+
20
+ # Make repo root importable when run as a module
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from server.environment import GTMEnvironment
24
+ from server.tasks import get_task
25
+
26
+ from rl.env_adapter import (
27
+ compute_action_dims,
28
+ compute_obs_dim,
29
+ obs_to_tensor,
30
+ policy_sample_to_action,
31
+ )
32
+ from rl.policy import GTMActorCritic
33
+
34
+
35
+ # ── Hyperparameters ────────────────────────────────────────────────────────
36
+
37
+ LR = 3e-4
38
+ GAMMA = 0.99
39
+ GAE_LAMBDA = 0.95
40
+ CLIP_EPS = 0.2
41
+ VF_COEF = 0.5
42
+ ENT_COEF = 0.01
43
+ N_STEPS = 2048
44
+ N_EPOCHS = 10
45
+ BATCH_SIZE = 64
46
+ MAX_GRAD_NORM = 0.5
47
+
48
+
49
+ # ── Rollout buffer ─────────────────────────────────────────────────────────
50
+
51
+
52
+ @dataclass
53
+ class RolloutStep:
54
+ obs: torch.Tensor
55
+ action: Dict[str, torch.Tensor]
56
+ log_prob: torch.Tensor
57
+ reward: float
58
+ value: torch.Tensor
59
+ done: bool
60
+
61
+
62
+ def compute_gae(
63
+ rollout: List[RolloutStep],
64
+ last_value: torch.Tensor,
65
+ ) -> tuple[torch.Tensor, torch.Tensor]:
66
+ """Generalized Advantage Estimation."""
67
+ advantages = torch.zeros(len(rollout), dtype=torch.float32)
68
+ gae = 0.0
69
+ next_value = last_value.item()
70
+ for t in reversed(range(len(rollout))):
71
+ nonterminal = 0.0 if rollout[t].done else 1.0
72
+ delta = rollout[t].reward + GAMMA * next_value * nonterminal - rollout[t].value.item()
73
+ gae = delta + GAMMA * GAE_LAMBDA * nonterminal * gae
74
+ advantages[t] = gae
75
+ next_value = rollout[t].value.item()
76
+ returns = advantages + torch.tensor([s.value.item() for s in rollout], dtype=torch.float32)
77
+ return advantages, returns
78
+
79
+
80
+ # ── Training loop ──────────────────────────────────────────────────────────
81
+
82
+
83
+ def train(
84
+ task_id: str,
85
+ total_steps: int,
86
+ seed: int = 0,
87
+ checkpoint_dir: str = "checkpoints",
88
+ log_every: int = 1,
89
+ ) -> None:
90
+ torch.manual_seed(seed)
91
+
92
+ env = GTMEnvironment()
93
+ task = get_task(task_id)
94
+ obs_dim = compute_obs_dim(task)
95
+ action_dims = compute_action_dims(task)
96
+
97
+ policy = GTMActorCritic(
98
+ obs_dim=obs_dim,
99
+ n_channels=action_dims["budget"],
100
+ n_segments=action_dims["segment"],
101
+ n_messaging=action_dims["messaging"],
102
+ n_experiments=action_dims["experiment"],
103
+ n_pricing=action_dims["pricing"],
104
+ )
105
+ optimizer = optim.Adam(policy.parameters(), lr=LR)
106
+
107
+ obs = env.reset(task_id=task_id, seed=seed)
108
+ obs_t = obs_to_tensor(obs, task)
109
+
110
+ global_step = 0
111
+ update_idx = 0
112
+ best_mean_return = -float("inf")
113
+ episode_returns: List[float] = []
114
+ current_return = 0.0
115
+ start_time = time.time()
116
+
117
+ os.makedirs(checkpoint_dir, exist_ok=True)
118
+ checkpoint_path = os.path.join(checkpoint_dir, f"{task_id}.pt")
119
+
120
+ while global_step < total_steps:
121
+ # ── Collect rollout ──────────────────────────────────────
122
+ rollout: List[RolloutStep] = []
123
+ for _ in range(N_STEPS):
124
+ with torch.no_grad():
125
+ sample, log_prob, value = policy.act(obs_t.unsqueeze(0))
126
+ sample_squeezed = {k: v.squeeze(0) for k, v in sample.items()}
127
+
128
+ action = policy_sample_to_action(sample_squeezed, task)
129
+ next_obs = env.step(action)
130
+ reward = float(next_obs.reward) if next_obs.reward is not None else 0.0
131
+ done = bool(next_obs.done)
132
+ current_return += reward
133
+
134
+ rollout.append(
135
+ RolloutStep(
136
+ obs=obs_t,
137
+ action=sample_squeezed,
138
+ log_prob=log_prob.squeeze(0).detach(),
139
+ reward=reward,
140
+ value=value.squeeze(0).detach(),
141
+ done=done,
142
+ )
143
+ )
144
+
145
+ global_step += 1
146
+ if done:
147
+ episode_returns.append(current_return)
148
+ current_return = 0.0
149
+ next_obs = env.reset(task_id=task_id, seed=seed + global_step)
150
+ obs_t = obs_to_tensor(next_obs, task)
151
+
152
+ # bootstrap final value
153
+ with torch.no_grad():
154
+ _, _, last_value = policy.act(obs_t.unsqueeze(0))
155
+ last_value = last_value.squeeze(0).detach()
156
+
157
+ advantages, returns = compute_gae(rollout, last_value)
158
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
159
+
160
+ # Stack rollout into tensors for minibatching
161
+ obs_batch = torch.stack([s.obs for s in rollout])
162
+ old_log_probs = torch.stack([s.log_prob for s in rollout])
163
+ action_batch = {
164
+ k: torch.stack([s.action[k] for s in rollout]) for k in rollout[0].action
165
+ }
166
+
167
+ # ── PPO update ───────────────────────────────────────────
168
+ n = len(rollout)
169
+ indices = list(range(n))
170
+ for _ in range(N_EPOCHS):
171
+ # shuffle
172
+ perm = torch.randperm(n).tolist()
173
+ for start in range(0, n, BATCH_SIZE):
174
+ mb = perm[start : start + BATCH_SIZE]
175
+ mb_obs = obs_batch[mb]
176
+ mb_actions = {k: v[mb] for k, v in action_batch.items()}
177
+ mb_old_lp = old_log_probs[mb]
178
+ mb_adv = advantages[mb]
179
+ mb_ret = returns[mb]
180
+
181
+ new_log_probs, entropy, value_pred = policy.evaluate_actions(mb_obs, mb_actions)
182
+ ratio = torch.exp(new_log_probs - mb_old_lp)
183
+ surr1 = ratio * mb_adv
184
+ surr2 = torch.clamp(ratio, 1.0 - CLIP_EPS, 1.0 + CLIP_EPS) * mb_adv
185
+ policy_loss = -torch.min(surr1, surr2).mean()
186
+ value_loss = 0.5 * (value_pred - mb_ret).pow(2).mean()
187
+ entropy_loss = -entropy.mean()
188
+
189
+ loss = policy_loss + VF_COEF * value_loss + ENT_COEF * entropy_loss
190
+
191
+ optimizer.zero_grad()
192
+ loss.backward()
193
+ nn.utils.clip_grad_norm_(policy.parameters(), MAX_GRAD_NORM)
194
+ optimizer.step()
195
+
196
+ update_idx += 1
197
+
198
+ # ── Logging + checkpoint ─────────────────────────────────
199
+ recent = episode_returns[-20:] if episode_returns else [current_return]
200
+ mean_return = sum(recent) / len(recent)
201
+ elapsed = time.time() - start_time
202
+ if update_idx % log_every == 0:
203
+ print(
204
+ f"[{task_id}] update={update_idx} step={global_step}/{total_steps} "
205
+ f"episodes={len(episode_returns)} mean_return(last20)={mean_return:.3f} "
206
+ f"policy_loss={policy_loss.item():.4f} value_loss={value_loss.item():.4f} "
207
+ f"entropy={entropy.mean().item():.3f} elapsed={elapsed:.0f}s"
208
+ )
209
+
210
+ if mean_return > best_mean_return and len(episode_returns) >= 5:
211
+ best_mean_return = mean_return
212
+ torch.save(
213
+ {
214
+ "model_state": policy.state_dict(),
215
+ "task_id": task_id,
216
+ "obs_dim": obs_dim,
217
+ "action_dims": action_dims,
218
+ "best_mean_return": best_mean_return,
219
+ "step": global_step,
220
+ },
221
+ checkpoint_path,
222
+ )
223
+ print(f" ↳ saved checkpoint (mean_return={best_mean_return:.3f}) β†’ {checkpoint_path}")
224
+
225
+ print(f"Done. Best mean return: {best_mean_return:.3f}. Checkpoint: {checkpoint_path}")
226
+
227
+
228
+ def main() -> None:
229
+ parser = argparse.ArgumentParser()
230
+ parser.add_argument("--task", required=True, choices=["channel_optimizer", "growth_strategist", "market_dominator"])
231
+ parser.add_argument("--total-steps", type=int, default=200000)
232
+ parser.add_argument("--seed", type=int, default=0)
233
+ parser.add_argument("--checkpoint-dir", default="checkpoints")
234
+ args = parser.parse_args()
235
+ train(
236
+ task_id=args.task,
237
+ total_steps=args.total_steps,
238
+ seed=args.seed,
239
+ checkpoint_dir=args.checkpoint_dir,
240
+ )
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
server/app.py CHANGED
@@ -19,9 +19,16 @@ from models import GTMAction, GTMObservation
19
  from server.environment import GTMEnvironment
20
  from server.tasks import TASKS
21
  from server.simulation import MESSAGING_DIMS
 
22
 
23
- # Create the core OpenEnv app (with Gradio web UI at /web when ENABLE_WEB_INTERFACE=true)
24
- app = create_app(GTMEnvironment, GTMAction, GTMObservation, env_name="gtm_strategy_optimizer")
 
 
 
 
 
 
25
 
26
 
27
  # ── Root endpoint for HF Spaces ──────────────────────────────────────────
@@ -32,7 +39,7 @@ def root():
32
  return {
33
  "name": "GTM Strategy Optimizer",
34
  "status": "running",
35
- "endpoints": ["/tasks", "/reset", "/step", "/state", "/baseline", "/grader", "/health", "/docs"],
36
  }
37
 
38
 
@@ -187,3 +194,34 @@ def run_baseline() -> BaselineResponse:
187
  scores=scores,
188
  message="Baseline (equal-allocation heuristic) scores for all tasks",
189
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from server.environment import GTMEnvironment
20
  from server.tasks import TASKS
21
  from server.simulation import MESSAGING_DIMS
22
+ from ui.dashboard import build_dashboard
23
 
24
+ # Create the core OpenEnv app (with custom Gradio dashboard at /web)
25
+ app = create_app(
26
+ GTMEnvironment,
27
+ GTMAction,
28
+ GTMObservation,
29
+ env_name="gtm_strategy_optimizer",
30
+ gradio_builder=build_dashboard,
31
+ )
32
 
33
 
34
  # ── Root endpoint for HF Spaces ──────────────────────────────────────────
 
39
  return {
40
  "name": "GTM Strategy Optimizer",
41
  "status": "running",
42
+ "endpoints": ["/tasks", "/reset", "/step", "/state", "/baseline", "/grader", "/infer", "/health", "/docs"],
43
  }
44
 
45
 
 
194
  scores=scores,
195
  message="Baseline (equal-allocation heuristic) scores for all tasks",
196
  )
197
+
198
+
199
+ # ── RL inference endpoint ──────────────────────────────────────────────────
200
+
201
+
202
+ class InferRequest(BaseModel):
203
+ task_id: str
204
+ seed: Optional[int] = None
205
+
206
+
207
+ class InferResponse(BaseModel):
208
+ task_id: str
209
+ checkpoint_loaded: bool
210
+ grader_score: Optional[float]
211
+ total_revenue: float
212
+ total_conversions: int
213
+ average_cac: float
214
+ brand_score: float
215
+ actions: list[dict]
216
+ message: str
217
+
218
+
219
+ @app.post("/infer")
220
+ def run_infer(req: InferRequest) -> InferResponse:
221
+ """Run a trained RL policy on a task and return the action trajectory."""
222
+ if req.task_id not in TASKS:
223
+ raise HTTPException(status_code=400, detail=f"Unknown task_id: {req.task_id}")
224
+ from rl.infer import run_inference
225
+
226
+ result = run_inference(req.task_id, seed=req.seed)
227
+ return InferResponse(**result)
ui/__init__.py ADDED
File without changes
ui/dashboard.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom Gradio dashboard for the GTM Strategy Optimizer.
2
+
3
+ Mounted into the OpenEnv app via `create_app(..., gradio_builder=build_dashboard)`.
4
+ Shows a side-by-side comparison of three strategies: trained RL, equal-allocation
5
+ heuristic, and uniform random β€” over the same task and seed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random as _random
12
+ import sys
13
+ from typing import Any, Callable, Dict, List, Optional
14
+
15
+ import gradio as gr
16
+ import pandas as pd
17
+ import plotly.graph_objects as go
18
+
19
+ # Make repo root importable when this module is loaded by uvicorn from /app
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+
22
+ from models import GTMAction
23
+ from rl.infer import run_inference
24
+ from server.environment import GTMEnvironment
25
+ from server.simulation import MESSAGING_DIMS
26
+ from server.tasks import TASKS, get_task
27
+
28
+ # ── Color palette ──────────────────────────────────────────────────────────
29
+
30
+ RL_COLOR = "#7c3aed" # purple
31
+ HEUR_COLOR = "#3b82f6" # blue
32
+ RAND_COLOR = "#94a3b8" # slate
33
+ CHANNEL_PALETTE = [
34
+ "#7c3aed", "#3b82f6", "#10b981", "#f59e0b",
35
+ "#ef4444", "#06b6d4", "#ec4899",
36
+ ]
37
+
38
+ # ── Strategy runners ───────────────────────────────────────────────────────
39
+
40
+
41
+ def _equal_action(task) -> GTMAction:
42
+ return GTMAction(
43
+ budget_allocation={c.name: 1.0 / len(task.channels) for c in task.channels},
44
+ segment_targeting={s.name: 1.0 / len(task.segments) for s in task.segments},
45
+ messaging={d: 1.0 / len(MESSAGING_DIMS) for d in MESSAGING_DIMS},
46
+ )
47
+
48
+
49
+ def _random_action(task, rng: _random.Random) -> GTMAction:
50
+ def _simplex(n: int) -> List[float]:
51
+ xs = [rng.random() + 1e-6 for _ in range(n)]
52
+ s = sum(xs)
53
+ return [x / s for x in xs]
54
+
55
+ bs = _simplex(len(task.channels))
56
+ ss = _simplex(len(task.segments))
57
+ ms = _simplex(len(MESSAGING_DIMS))
58
+ return GTMAction(
59
+ budget_allocation={c.name: bs[i] for i, c in enumerate(task.channels)},
60
+ segment_targeting={s.name: ss[i] for i, s in enumerate(task.segments)},
61
+ messaging={d: ms[i] for i, d in enumerate(MESSAGING_DIMS)},
62
+ )
63
+
64
+
65
+ def _run_with_action_fn(task_id: str, action_fn: Callable, seed: int) -> Dict[str, Any]:
66
+ """Run one episode driven by action_fn(task) -> GTMAction. Returns trajectory."""
67
+ env = GTMEnvironment()
68
+ task = get_task(task_id)
69
+ obs = env.reset(task_id=task_id, seed=seed)
70
+ weeks: List[Dict[str, Any]] = []
71
+ while not obs.done:
72
+ action = action_fn(task)
73
+ obs = env.step(action)
74
+ weeks.append({
75
+ "week": obs.week,
76
+ "total_revenue": float(obs.total_revenue),
77
+ "brand_score": float(obs.brand_score),
78
+ "budget_allocation": dict(action.budget_allocation),
79
+ "reward": float(obs.reward) if obs.reward is not None else 0.0,
80
+ })
81
+ grader_score = env.get_grader_score(env.state.episode_id)
82
+ return {
83
+ "weeks": weeks,
84
+ "grader_score": grader_score,
85
+ "total_revenue": float(obs.total_revenue),
86
+ "total_conversions": int(obs.total_conversions),
87
+ "brand_score": float(obs.brand_score),
88
+ }
89
+
90
+
91
+ def run_heuristic(task_id: str, seed: int) -> Dict[str, Any]:
92
+ return _run_with_action_fn(task_id, _equal_action, seed)
93
+
94
+
95
+ def run_random(task_id: str, seed: int) -> Dict[str, Any]:
96
+ rng = _random.Random(seed)
97
+ return _run_with_action_fn(task_id, lambda t: _random_action(t, rng), seed)
98
+
99
+
100
+ def run_trained_rl(task_id: str, seed: int) -> Dict[str, Any]:
101
+ """Wraps rl.infer.run_inference and normalizes its output to the common shape."""
102
+ result = run_inference(task_id, seed=seed)
103
+ weeks = [
104
+ {
105
+ "week": a["week"],
106
+ "total_revenue": float(a["total_revenue"]),
107
+ "brand_score": float(a["brand_score"]),
108
+ "budget_allocation": a["budget_allocation"],
109
+ "reward": float(a["weekly_reward"]) if a["weekly_reward"] is not None else 0.0,
110
+ }
111
+ for a in result["actions"]
112
+ ]
113
+ return {
114
+ "weeks": weeks,
115
+ "grader_score": result["grader_score"],
116
+ "total_revenue": float(result["total_revenue"]),
117
+ "total_conversions": int(result["total_conversions"]),
118
+ "brand_score": float(result["brand_score"]),
119
+ "checkpoint_loaded": result["checkpoint_loaded"],
120
+ }
121
+
122
+
123
+ # ── Plot builders ──────────────────────────────────────────────────────────
124
+
125
+
126
+ def build_revenue_plot(rl: Dict, heur: Dict, rand: Dict) -> go.Figure:
127
+ fig = go.Figure()
128
+ for label, data, color, dash in [
129
+ ("πŸ€– Trained RL", rl, RL_COLOR, "solid"),
130
+ ("πŸ“Š Heuristic", heur, HEUR_COLOR, "dash"),
131
+ ("🎲 Random", rand, RAND_COLOR, "dot"),
132
+ ]:
133
+ weeks = data["weeks"]
134
+ fig.add_trace(go.Scatter(
135
+ x=[w["week"] for w in weeks],
136
+ y=[w["total_revenue"] for w in weeks],
137
+ mode="lines+markers",
138
+ name=label,
139
+ line=dict(color=color, width=3, dash=dash),
140
+ marker=dict(size=7),
141
+ hovertemplate=f"<b>{label}</b><br>Week %{{x}}<br>Revenue $%{{y:,.0f}}<extra></extra>",
142
+ ))
143
+ fig.update_layout(
144
+ title="Cumulative Revenue Over Weeks",
145
+ xaxis_title="Week",
146
+ yaxis_title="Revenue ($)",
147
+ template="plotly_white",
148
+ height=420,
149
+ hovermode="x unified",
150
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
151
+ margin=dict(l=60, r=20, t=80, b=50),
152
+ )
153
+ return fig
154
+
155
+
156
+ def build_budget_plot(rl: Dict) -> go.Figure:
157
+ weeks_data = rl["weeks"]
158
+ if not weeks_data:
159
+ return go.Figure()
160
+ weeks = [w["week"] for w in weeks_data]
161
+ channels = list(weeks_data[0]["budget_allocation"].keys())
162
+ fig = go.Figure()
163
+ for i, ch in enumerate(channels):
164
+ fig.add_trace(go.Scatter(
165
+ x=weeks,
166
+ y=[w["budget_allocation"].get(ch, 0.0) for w in weeks_data],
167
+ mode="lines",
168
+ name=ch,
169
+ stackgroup="one",
170
+ line=dict(width=0.5, color=CHANNEL_PALETTE[i % len(CHANNEL_PALETTE)]),
171
+ fillcolor=CHANNEL_PALETTE[i % len(CHANNEL_PALETTE)],
172
+ hovertemplate=f"<b>{ch}</b><br>Week %{{x}}<br>%{{y:.0%}}<extra></extra>",
173
+ ))
174
+ fig.update_layout(
175
+ title="RL Budget Allocation per Week",
176
+ xaxis_title="Week",
177
+ yaxis_title="Fraction of weekly budget",
178
+ yaxis_tickformat=".0%",
179
+ template="plotly_white",
180
+ height=380,
181
+ hovermode="x unified",
182
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
183
+ margin=dict(l=60, r=20, t=80, b=50),
184
+ )
185
+ return fig
186
+
187
+
188
+ def build_brand_plot(rl: Dict, heur: Dict, rand: Dict) -> go.Figure:
189
+ fig = go.Figure()
190
+ for label, data, color, dash in [
191
+ ("πŸ€– Trained RL", rl, RL_COLOR, "solid"),
192
+ ("πŸ“Š Heuristic", heur, HEUR_COLOR, "dash"),
193
+ ("🎲 Random", rand, RAND_COLOR, "dot"),
194
+ ]:
195
+ weeks = data["weeks"]
196
+ fig.add_trace(go.Scatter(
197
+ x=[w["week"] for w in weeks],
198
+ y=[w["brand_score"] for w in weeks],
199
+ mode="lines+markers",
200
+ name=label,
201
+ line=dict(color=color, width=3, dash=dash),
202
+ marker=dict(size=7),
203
+ hovertemplate=f"<b>{label}</b><br>Week %{{x}}<br>Brand %{{y:.0f}}/100<extra></extra>",
204
+ ))
205
+ fig.update_layout(
206
+ title="Brand Health Over Weeks",
207
+ xaxis_title="Week",
208
+ yaxis_title="Brand Score (0-100)",
209
+ template="plotly_white",
210
+ yaxis=dict(range=[0, 100]),
211
+ height=380,
212
+ hovermode="x unified",
213
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
214
+ margin=dict(l=60, r=20, t=80, b=50),
215
+ )
216
+ return fig
217
+
218
+
219
+ def build_action_table(rl: Dict) -> pd.DataFrame:
220
+ rows = []
221
+ for w in rl["weeks"]:
222
+ row: Dict[str, Any] = {"Week": w["week"]}
223
+ for ch, frac in w["budget_allocation"].items():
224
+ row[ch] = round(frac, 3)
225
+ row["Brand"] = round(w["brand_score"], 1)
226
+ row["Reward"] = round(w["reward"], 3)
227
+ rows.append(row)
228
+ return pd.DataFrame(rows)
229
+
230
+
231
+ # ── Score card markdown ────────────────────────────────────────────────────
232
+
233
+
234
+ def _score_card(label: str, emoji: str, color_hex: str, result: Dict, is_winner: bool = False) -> str:
235
+ score = result.get("grader_score")
236
+ score_str = f"{score:.3f}" if score is not None else "β€”"
237
+ crown = " πŸ‘‘" if is_winner else ""
238
+ return (
239
+ f"<div style='border-left:6px solid {color_hex};padding:14px 18px;background:#f8fafc;border-radius:8px;'>"
240
+ f"<div style='font-size:14px;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:0.05em'>{emoji} {label}{crown}</div>"
241
+ f"<div style='font-size:36px;font-weight:700;color:#0f172a;margin:4px 0'>{score_str}</div>"
242
+ f"<div style='font-size:13px;color:#475569'>"
243
+ f"πŸ’΅ ${result['total_revenue']:,.0f} &nbsp;&nbsp; "
244
+ f"🎯 {result['total_conversions']} convs &nbsp;&nbsp; "
245
+ f"⭐ Brand {result['brand_score']:.0f}/100"
246
+ f"</div></div>"
247
+ )
248
+
249
+
250
+ # ── Main callback ──────────────────────────────────────────────────────────
251
+
252
+
253
+ def run_comparison(task_id: str, seed_value: float):
254
+ seed = int(seed_value) if seed_value is not None else 42
255
+
256
+ rl = run_trained_rl(task_id, seed)
257
+ heur = run_heuristic(task_id, seed)
258
+ rand = run_random(task_id, seed)
259
+
260
+ # Pick a winner (highest grader score) for the crown emoji
261
+ scores = {
262
+ "rl": rl.get("grader_score") or -1,
263
+ "heur": heur.get("grader_score") or -1,
264
+ "rand": rand.get("grader_score") or -1,
265
+ }
266
+ winner = max(scores, key=scores.get)
267
+
268
+ revenue_fig = build_revenue_plot(rl, heur, rand)
269
+ budget_fig = build_budget_plot(rl)
270
+ brand_fig = build_brand_plot(rl, heur, rand)
271
+ action_df = build_action_table(rl)
272
+
273
+ rl_card = _score_card("Trained RL", "πŸ€–", RL_COLOR, rl, is_winner=(winner == "rl"))
274
+ heur_card = _score_card("Heuristic", "πŸ“Š", HEUR_COLOR, heur, is_winner=(winner == "heur"))
275
+ rand_card = _score_card("Random", "🎲", RAND_COLOR, rand, is_winner=(winner == "rand"))
276
+
277
+ ckpt_warning = ""
278
+ if not rl.get("checkpoint_loaded"):
279
+ ckpt_warning = (
280
+ "<div style='padding:10px 14px;background:#fef3c7;border-left:4px solid #f59e0b;border-radius:6px;color:#78350f;font-size:13px'>"
281
+ "⚠️ No trained checkpoint found for this task β€” RL is using a random-init policy. "
282
+ "Run <code>python -m rl.train --task " + task_id + "</code> locally and commit "
283
+ "<code>checkpoints/" + task_id + ".pt</code>."
284
+ "</div>"
285
+ )
286
+
287
+ return revenue_fig, budget_fig, brand_fig, action_df, rl_card, heur_card, rand_card, ckpt_warning
288
+
289
+
290
+ # ── Gradio Blocks builder (called by openenv) ──────────────────────────────
291
+
292
+
293
+ def build_dashboard(*_args, **_kwargs) -> gr.Blocks:
294
+ """Entry point used as `gradio_builder` in `create_app`.
295
+
296
+ The OpenEnv hook passes (web_manager, action_fields, metadata, is_chat_env,
297
+ title, quick_start_md) which we ignore β€” we build a fully custom layout.
298
+ """
299
+ with gr.Blocks(title="GTM Strategy Optimizer") as demo:
300
+ gr.Markdown(
301
+ "# πŸ“ˆ GTM Strategy Optimizer\n"
302
+ "### RL agent vs heuristic vs random β€” head-to-head on marketing budget allocation\n"
303
+ "A custom PPO policy trained on a Go-To-Market simulator allocates marketing budget "
304
+ "across channels, segments, and messaging dimensions. Pick a task and run all three "
305
+ "strategies on the same seed to see how the trained policy compares."
306
+ )
307
+
308
+ with gr.Row():
309
+ task_dd = gr.Dropdown(
310
+ choices=list(TASKS.keys()),
311
+ value="channel_optimizer",
312
+ label="Task",
313
+ info="Increasing difficulty: more channels, segments, and adversarial dynamics",
314
+ scale=3,
315
+ )
316
+ seed_in = gr.Number(value=42, label="Seed", precision=0, scale=1)
317
+ run_btn = gr.Button("β–Ά Run Comparison", variant="primary", scale=1, size="lg")
318
+
319
+ ckpt_warning_html = gr.HTML(value="")
320
+
321
+ with gr.Row():
322
+ rl_card_md = gr.HTML(_score_card("Trained RL", "πŸ€–", RL_COLOR, _empty_result()))
323
+ heur_card_md = gr.HTML(_score_card("Heuristic", "πŸ“Š", HEUR_COLOR, _empty_result()))
324
+ rand_card_md = gr.HTML(_score_card("Random", "🎲", RAND_COLOR, _empty_result()))
325
+
326
+ revenue_plot = gr.Plot(label="Cumulative Revenue", show_label=False)
327
+
328
+ with gr.Row():
329
+ budget_plot = gr.Plot(label="Budget Allocation (RL)", show_label=False)
330
+ brand_plot = gr.Plot(label="Brand Health", show_label=False)
331
+
332
+ gr.Markdown("### πŸ“‹ Per-week actions (Trained RL)")
333
+ action_df = gr.Dataframe(
334
+ label="Weekly action log",
335
+ interactive=False,
336
+ wrap=True,
337
+ )
338
+
339
+ gr.Markdown(
340
+ "---\n"
341
+ "**How it works.** The trained policy is a small PyTorch actor-critic with "
342
+ "Dirichlet heads for budget/segment/messaging simplices and categorical heads for "
343
+ "experiments and pricing actions. Trained with a custom lightweight PPO loop "
344
+ "(`rl/train.py`) against the simulator's per-step reward "
345
+ "(`server/environment.py:_compute_reward`)."
346
+ )
347
+
348
+ run_btn.click(
349
+ fn=run_comparison,
350
+ inputs=[task_dd, seed_in],
351
+ outputs=[
352
+ revenue_plot, budget_plot, brand_plot, action_df,
353
+ rl_card_md, heur_card_md, rand_card_md, ckpt_warning_html,
354
+ ],
355
+ )
356
+
357
+ return demo
358
+
359
+
360
+ def _empty_result() -> Dict[str, Any]:
361
+ return {
362
+ "grader_score": None,
363
+ "total_revenue": 0.0,
364
+ "total_conversions": 0,
365
+ "brand_score": 0.0,
366
+ }