sanjayvk21 commited on
Commit
ce3d6c7
Β·
1 Parent(s): 364ec8e
Files changed (5) hide show
  1. baseline.py +31 -11
  2. environment.py +181 -6
  3. models.py +11 -4
  4. openenv.yaml +18 -0
  5. test_sequencing.py +168 -0
baseline.py CHANGED
@@ -12,7 +12,7 @@ Usage
12
 
13
  Optional env vars
14
  -----------------
15
- TASK = easy_headline | medium_pacing | hard_assembly | all (default: all)
16
  USE_LLM_SIMULATOR = 1 to enable Llama-3 User Simulator (requires GPU + model)
17
  """
18
 
@@ -29,7 +29,7 @@ from models import Action
29
 
30
  client = OpenAI(
31
  base_url="https://openrouter.ai/api/v1",
32
- api_key="KEY",
33
  )
34
 
35
  # ---------------------------------------------------------------------------
@@ -110,10 +110,25 @@ Also choose headline_id and creative_id that reinforce the caption theme.
110
  Bid competitively ($0.60–$1.50) since revenue matters too.
111
  """.strip()
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  SYSTEM_PROMPTS = {
114
- "easy_headline": SYSTEM_EASY,
115
- "medium_pacing": SYSTEM_MEDIUM,
116
- "hard_assembly": SYSTEM_HARD,
 
117
  }
118
 
119
 
@@ -128,9 +143,11 @@ def build_user_prompt(task_id: str, obs, catalog: str) -> str:
128
  f"Viral Trend: '{obs.viral_trend}'\n"
129
  f"Budget remaining: ${obs.remaining_budget} | "
130
  f"Market pressure: {obs.market_pressure:.2f} | "
131
- f"Fatigue level: {obs.fatigue_level:.2f}\n\n"
132
- f"{catalog}\n\n"
133
  )
 
 
 
134
 
135
  schema = (
136
  'Respond ONLY with a JSON object:\n'
@@ -209,6 +226,10 @@ def run_task(task_id: str) -> float:
209
  elif task_id == "hard_assembly":
210
  print(f" CLIP Similarity Score : {final_info.clip_similarity_score:.4f}")
211
  print(f" Revenue Factor : ${final_info.total_revenue:.2f}")
 
 
 
 
212
  print(f"{'─'*52}\n")
213
 
214
  return final_info.task_score
@@ -222,11 +243,11 @@ if __name__ == "__main__":
222
  task_arg = os.environ.get("TASK", "all").lower()
223
 
224
  if task_arg == "all":
225
- tasks = ["easy_headline", "medium_pacing", "hard_assembly"]
226
- elif task_arg in ("easy_headline", "medium_pacing", "hard_assembly"):
227
  tasks = [task_arg]
228
  else:
229
- print(f"Unknown TASK='{task_arg}'. Choose: easy_headline | medium_pacing | hard_assembly | all")
230
  sys.exit(1)
231
 
232
  scores = {}
@@ -243,4 +264,3 @@ if __name__ == "__main__":
243
  print(f"β• {'═'*50}β•£")
244
  print(f"β•‘ {'AVERAGE':<20} {'β–ˆ'*int(avg*20) + 'β–‘'*(20-int(avg*20))} {avg:.4f} β•‘")
245
  print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
246
-
 
12
 
13
  Optional env vars
14
  -----------------
15
+ TASK = easy_headline | medium_pacing | hard_assembly | hard_sequencing | all (default: all)
16
  USE_LLM_SIMULATOR = 1 to enable Llama-3 User Simulator (requires GPU + model)
17
  """
18
 
 
29
 
30
  client = OpenAI(
31
  base_url="https://openrouter.ai/api/v1",
32
+ api_key="key",
33
  )
34
 
35
  # ---------------------------------------------------------------------------
 
110
  Bid competitively ($0.60–$1.50) since revenue matters too.
111
  """.strip()
112
 
113
+ SYSTEM_HARD_SEQ = """
114
+ You are focused on CROSS-CONTEXT CAMPAIGN SEQUENCING.
115
+
116
+ Rules:
117
+ - Plan ad placements across 24 hours to maximise conversions.
118
+ - Winning an auction boosts CTR for the next 3 hours (carry-over effect):
119
+ +15% at t+1, +10% at t+2, +5% at t+3.
120
+ - Cover at least 3 of 4 contexts (Fitness, Tech, Fashion, Gaming) for a 20% bonus.
121
+ - Skip expensive hours; bid aggressively in cheap hours to trigger carry-over.
122
+ - Use carryover_boost from observation to time high-value bids.
123
+ - Keep β‰₯15% budget for hours 18–22 (peak engagement).
124
+ - Your score is compared against a DP oracle that finds the hindsight-optimal plan.
125
+ """.strip()
126
+
127
  SYSTEM_PROMPTS = {
128
+ "easy_headline": SYSTEM_EASY,
129
+ "medium_pacing": SYSTEM_MEDIUM,
130
+ "hard_assembly": SYSTEM_HARD,
131
+ "hard_sequencing": SYSTEM_HARD_SEQ,
132
  }
133
 
134
 
 
143
  f"Viral Trend: '{obs.viral_trend}'\n"
144
  f"Budget remaining: ${obs.remaining_budget} | "
145
  f"Market pressure: {obs.market_pressure:.2f} | "
146
+ f"Fatigue level: {obs.fatigue_level:.2f}"
 
147
  )
148
+ if task_id == "hard_sequencing":
149
+ base += f" | Carryover boost: {obs.carryover_boost:.2f}"
150
+ base += f"\n\n{catalog}\n\n"
151
 
152
  schema = (
153
  'Respond ONLY with a JSON object:\n'
 
226
  elif task_id == "hard_assembly":
227
  print(f" CLIP Similarity Score : {final_info.clip_similarity_score:.4f}")
228
  print(f" Revenue Factor : ${final_info.total_revenue:.2f}")
229
+ elif task_id == "hard_sequencing":
230
+ print(f" Sequencing Score : {final_info.sequencing_score:.4f}")
231
+ print(f" Contexts Covered : {final_info.contexts_covered}/4")
232
+ print(f" Diversity Multiplier : {final_info.diversity_multiplier:.2f}")
233
  print(f"{'─'*52}\n")
234
 
235
  return final_info.task_score
 
243
  task_arg = os.environ.get("TASK", "all").lower()
244
 
245
  if task_arg == "all":
246
+ tasks = ["easy_headline", "medium_pacing", "hard_assembly", "hard_sequencing"]
247
+ elif task_arg in ("easy_headline", "medium_pacing", "hard_assembly", "hard_sequencing"):
248
  tasks = [task_arg]
249
  else:
250
+ print(f"Unknown TASK='{task_arg}'. Choose: easy_headline | medium_pacing | hard_assembly | hard_sequencing | all")
251
  sys.exit(1)
252
 
253
  scores = {}
 
264
  print(f"β• {'═'*50}β•£")
265
  print(f"β•‘ {'AVERAGE':<20} {'β–ˆ'*int(avg*20) + 'β–‘'*(20-int(avg*20))} {avg:.4f} β•‘")
266
  print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
 
environment.py CHANGED
@@ -78,7 +78,7 @@ DATA_DIR = pathlib.Path(__file__).parent.resolve() / "Datasets"
78
  IPINYOU_PATH = DATA_DIR
79
 
80
  # MIND (Microsoft News Dataset) β€” replaces Criteo + Pitt
81
- MIND_PATH = DATA_DIR / "MINDlarge_train" / "MINDlarge_train"
82
  MIND_BEHAVIOURS = MIND_PATH / "behaviors.tsv" # impression click log
83
  MIND_NEWS = MIND_PATH / "news.tsv" # news article metadata
84
 
@@ -913,6 +913,129 @@ class HardAssemblyGrader:
913
  self._scores.clear()
914
 
915
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916
  # ===========================================================================
917
  # Main Environment
918
  # ===========================================================================
@@ -939,9 +1062,10 @@ class OpenEnvAuctioneer:
939
 
940
  # Per-task initial budgets
941
  _BUDGETS = {
942
- "easy_headline": 100.0,
943
  "medium_pacing": 50.0,
944
- "hard_assembly": 100.0,
 
945
  }
946
 
947
  def __init__(self, task_id: str = "easy_headline") -> None:
@@ -964,6 +1088,7 @@ class OpenEnvAuctioneer:
964
  self._easy_grader = EasyHeadlineGrader(self._user_sim, self._pool)
965
  self._medium_grader = MediumPacingGrader()
966
  self._hard_grader = HardAssemblyGrader(self._user_sim._smodel)
 
967
 
968
  # ── Episode state (populated by reset) ────────────────────────────
969
  self._step = 0
@@ -976,6 +1101,8 @@ class OpenEnvAuctioneer:
976
  self._context = ""
977
  self._trend = ""
978
  self._prev_remaining = 0.0
 
 
979
 
980
  self._contexts = ["Fitness", "Tech", "Fashion", "Gaming"]
981
  self._trends = ["Quiet Luxury", "Eco-Friendly", "Cyberpunk", "Minimalism"]
@@ -994,6 +1121,9 @@ class OpenEnvAuctioneer:
994
  self._easy_grader.reset()
995
  self._medium_grader.reset()
996
  self._hard_grader.reset()
 
 
 
997
 
998
  self._update_context()
999
  return self._make_obs()
@@ -1004,6 +1134,10 @@ class OpenEnvAuctioneer:
1004
  if self._remaining <= 0:
1005
  return self._make_obs(), Reward(value=0.0), True, self._make_info(0.0, 0.0, False, 0.0, 0.0, 0.0)
1006
 
 
 
 
 
1007
  clearing_price = self._market.sample_clearing_price(self._step)
1008
  step_reward = 0.0
1009
  auction_won = False
@@ -1022,9 +1156,22 @@ class OpenEnvAuctioneer:
1022
 
1023
  raw_ctr, adjusted_ctr = self._user_sim.compute_ctr(
1024
  action, self._context, self._trend, self._fatigue)
1025
- self._last_ctr = adjusted_ctr
1026
 
1027
- revenue = adjusted_ctr * self.CONVERSION_VALUE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
  self._total_revenue += revenue
1029
  step_reward = revenue - clearing_price
1030
  self._fatigue = min(1.0, self._fatigue + self.FATIGUE_STEP)
@@ -1056,6 +1203,13 @@ class OpenEnvAuctioneer:
1056
  else:
1057
  clip_score = 0.0
1058
 
 
 
 
 
 
 
 
1059
  # ── Advance time ─────────────────────────────────────────────────
1060
  self._prev_remaining = self._remaining
1061
  self._step += 1
@@ -1093,6 +1247,7 @@ class OpenEnvAuctioneer:
1093
  min(self._step, 23)),
1094
  ads_shown_this_session= self._ads_shown,
1095
  fatigue_level = round(self._fatigue, 3),
 
1096
  last_ctr = self._last_ctr,
1097
  cumulative_revenue = round(self._total_revenue, 2),
1098
  )
@@ -1113,7 +1268,7 @@ class OpenEnvAuctioneer:
1113
  pacing_score = task_score
1114
  clip_sim_score = 0.0
1115
 
1116
- else: # hard_assembly
1117
  clip_sim_score = self._hard_grader.episode_score()
1118
  revenue_factor = float(np.clip(self._total_revenue / 45.0, 0.0, 1.0))
1119
  # Blend: 60% viral alignment + 40% revenue
@@ -1121,6 +1276,23 @@ class OpenEnvAuctioneer:
1121
  headline_score = 0.0
1122
  pacing_score = 0.0
1123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
  return Info(
1125
  task_id = self.task_id,
1126
  current_step = self._step,
@@ -1133,4 +1305,7 @@ class OpenEnvAuctioneer:
1133
  headline_alignment_score= headline_score,
1134
  pacing_score = pacing_score,
1135
  clip_similarity_score = clip_sim_score,
 
 
 
1136
  )
 
78
  IPINYOU_PATH = DATA_DIR
79
 
80
  # MIND (Microsoft News Dataset) β€” replaces Criteo + Pitt
81
+ MIND_PATH = DATA_DIR / "MINDlarge_train"
82
  MIND_BEHAVIOURS = MIND_PATH / "behaviors.tsv" # impression click log
83
  MIND_NEWS = MIND_PATH / "news.tsv" # news article metadata
84
 
 
913
  self._scores.clear()
914
 
915
 
916
+ # ---------------------------------------------------------------------------
917
+
918
+ class HardSequencingGrader:
919
+ """
920
+ Level 4: Cross-Context Campaign Sequencing
921
+
922
+ Tracks per-step data (clearing prices, CTRs, contexts, costs) and at
923
+ episode end runs a DP oracle to find the optimal bid/skip sequence.
924
+
925
+ Score = min(1.0, (agent_conversions / oracle_conversions) x diversity_mult)
926
+ """
927
+
928
+ CARRYOVER_BOOSTS = [0.15, 0.10, 0.05] # t+1, t+2, t+3
929
+ DIVERSITY_THRESHOLD = 3
930
+ DIVERSITY_BONUS = 1.20
931
+
932
+ def __init__(self) -> None:
933
+ self._step_log: List[dict] = []
934
+ self._contexts_won: set = set()
935
+
936
+ def record_step(self, step: int, context: str, clearing_price: float,
937
+ base_ctr: float, auction_won: bool, cost: float,
938
+ conversion_value: float) -> None:
939
+ self._step_log.append({
940
+ "step": step, "context": context,
941
+ "clearing_price": clearing_price,
942
+ "base_ctr": base_ctr,
943
+ "auction_won": auction_won,
944
+ "cost": cost,
945
+ "conversion_value": conversion_value,
946
+ })
947
+ if auction_won:
948
+ self._contexts_won.add(context)
949
+
950
+ # ------------------------------------------------------------------
951
+ def _diversity_multiplier(self) -> float:
952
+ return (self.DIVERSITY_BONUS
953
+ if len(self._contexts_won) >= self.DIVERSITY_THRESHOLD
954
+ else 1.0)
955
+
956
+ # ------------------------------------------------------------------
957
+ def _agent_conversions(self) -> float:
958
+ """Total weighted conversions the agent actually achieved."""
959
+ total = 0.0
960
+ for idx, s in enumerate(self._step_log):
961
+ if not s["auction_won"]:
962
+ continue
963
+ boost = 0.0
964
+ for j, bv in enumerate(self.CARRYOVER_BOOSTS):
965
+ prev = idx - 1 - j
966
+ if prev >= 0 and self._step_log[prev]["auction_won"]:
967
+ boost += bv
968
+ eff_ctr = s["base_ctr"] * (1.0 + boost)
969
+ total += eff_ctr * s["conversion_value"]
970
+ return total
971
+
972
+ # ------------------------------------------------------------------
973
+ def _oracle_conversions(self, budget: float) -> float:
974
+ """
975
+ DP over N steps.
976
+ State = (step_index, budget_units, carry_state).
977
+ carry_state is a 3-bit int encoding wins at t-1, t-2, t-3.
978
+ """
979
+ steps = self._step_log
980
+ n = len(steps)
981
+ if n == 0:
982
+ return 0.0
983
+
984
+ RES = 0.10
985
+ budget_units = int(budget / RES) + 1
986
+
987
+ # cur maps (budget_units, carry_state_3bit) -> best cumulative value
988
+ cur: Dict[tuple, float] = {(budget_units, 0): 0.0}
989
+
990
+ for i in range(n):
991
+ s = steps[i]
992
+ cp = s["clearing_price"]
993
+ base = s["base_ctr"]
994
+ cv = s["conversion_value"]
995
+ cost_u = max(1, int(round(cp / RES)))
996
+
997
+ nxt: Dict[tuple, float] = {}
998
+
999
+ for (bu, cs), val in cur.items():
1000
+ w1 = (cs >> 2) & 1
1001
+ w2 = (cs >> 1) & 1
1002
+ w3 = cs & 1
1003
+ boost = 0.15 * w1 + 0.10 * w2 + 0.05 * w3
1004
+
1005
+ # Option A: skip
1006
+ ns_skip = ((w1 << 1) | w2) & 0b111
1007
+ key_s = (bu, ns_skip)
1008
+ if key_s not in nxt or val > nxt[key_s]:
1009
+ nxt[key_s] = val
1010
+
1011
+ # Option B: bid (if affordable)
1012
+ if bu >= cost_u:
1013
+ eff = base * (1.0 + boost)
1014
+ nv = val + eff * cv
1015
+ nb = bu - cost_u
1016
+ ns_bid = ((1 << 2) | (w1 << 1) | w2) & 0b111
1017
+ key_b = (nb, ns_bid)
1018
+ if key_b not in nxt or nv > nxt[key_b]:
1019
+ nxt[key_b] = nv
1020
+
1021
+ cur = nxt
1022
+
1023
+ return max(cur.values()) if cur else 0.0
1024
+
1025
+ # ------------------------------------------------------------------
1026
+ def episode_score(self, initial_budget: float) -> float:
1027
+ oracle = self._oracle_conversions(initial_budget)
1028
+ if oracle <= 0:
1029
+ return 0.0
1030
+ agent = self._agent_conversions()
1031
+ div = self._diversity_multiplier()
1032
+ return min(1.0, round((agent / oracle) * div, 4))
1033
+
1034
+ def reset(self) -> None:
1035
+ self._step_log.clear()
1036
+ self._contexts_won.clear()
1037
+
1038
+
1039
  # ===========================================================================
1040
  # Main Environment
1041
  # ===========================================================================
 
1062
 
1063
  # Per-task initial budgets
1064
  _BUDGETS = {
1065
+ "easy_headline": 100.0,
1066
  "medium_pacing": 50.0,
1067
+ "hard_assembly": 100.0,
1068
+ "hard_sequencing": 100.0,
1069
  }
1070
 
1071
  def __init__(self, task_id: str = "easy_headline") -> None:
 
1088
  self._easy_grader = EasyHeadlineGrader(self._user_sim, self._pool)
1089
  self._medium_grader = MediumPacingGrader()
1090
  self._hard_grader = HardAssemblyGrader(self._user_sim._smodel)
1091
+ self._seq_grader = HardSequencingGrader()
1092
 
1093
  # ── Episode state (populated by reset) ────────────────────────────
1094
  self._step = 0
 
1101
  self._context = ""
1102
  self._trend = ""
1103
  self._prev_remaining = 0.0
1104
+ self._carryover_boost = 0.0
1105
+ self._carryover_schedule: Dict[int, float] = {}
1106
 
1107
  self._contexts = ["Fitness", "Tech", "Fashion", "Gaming"]
1108
  self._trends = ["Quiet Luxury", "Eco-Friendly", "Cyberpunk", "Minimalism"]
 
1121
  self._easy_grader.reset()
1122
  self._medium_grader.reset()
1123
  self._hard_grader.reset()
1124
+ self._seq_grader.reset()
1125
+ self._carryover_boost = 0.0
1126
+ self._carryover_schedule = {}
1127
 
1128
  self._update_context()
1129
  return self._make_obs()
 
1134
  if self._remaining <= 0:
1135
  return self._make_obs(), Reward(value=0.0), True, self._make_info(0.0, 0.0, False, 0.0, 0.0, 0.0)
1136
 
1137
+ # ── Read carry-over boost for hard_sequencing ─────────────────
1138
+ if self.task_id == "hard_sequencing":
1139
+ self._carryover_boost = self._carryover_schedule.get(self._step, 0.0)
1140
+
1141
  clearing_price = self._market.sample_clearing_price(self._step)
1142
  step_reward = 0.0
1143
  auction_won = False
 
1156
 
1157
  raw_ctr, adjusted_ctr = self._user_sim.compute_ctr(
1158
  action, self._context, self._trend, self._fatigue)
 
1159
 
1160
+ # Apply carry-over boost for hard_sequencing
1161
+ if self.task_id == "hard_sequencing":
1162
+ effective_ctr = round(adjusted_ctr * (1.0 + self._carryover_boost), 4)
1163
+ self._last_ctr = effective_ctr
1164
+ revenue = effective_ctr * self.CONVERSION_VALUE
1165
+ # Schedule carry-over boosts for future steps
1166
+ for ci, bv in enumerate(HardSequencingGrader.CARRYOVER_BOOSTS):
1167
+ fs = self._step + 1 + ci
1168
+ if fs < 24:
1169
+ self._carryover_schedule[fs] = (
1170
+ self._carryover_schedule.get(fs, 0.0) + bv)
1171
+ else:
1172
+ self._last_ctr = adjusted_ctr
1173
+ revenue = adjusted_ctr * self.CONVERSION_VALUE
1174
+
1175
  self._total_revenue += revenue
1176
  step_reward = revenue - clearing_price
1177
  self._fatigue = min(1.0, self._fatigue + self.FATIGUE_STEP)
 
1203
  else:
1204
  clip_score = 0.0
1205
 
1206
+ if self.task_id == "hard_sequencing":
1207
+ self._seq_grader.record_step(
1208
+ step=self._step, context=self._context,
1209
+ clearing_price=clearing_price, base_ctr=adjusted_ctr,
1210
+ auction_won=auction_won, cost=spent,
1211
+ conversion_value=self.CONVERSION_VALUE)
1212
+
1213
  # ── Advance time ─────────────────────────────────────────────────
1214
  self._prev_remaining = self._remaining
1215
  self._step += 1
 
1247
  min(self._step, 23)),
1248
  ads_shown_this_session= self._ads_shown,
1249
  fatigue_level = round(self._fatigue, 3),
1250
+ carryover_boost = round(min(self._carryover_boost, 0.30), 4),
1251
  last_ctr = self._last_ctr,
1252
  cumulative_revenue = round(self._total_revenue, 2),
1253
  )
 
1268
  pacing_score = task_score
1269
  clip_sim_score = 0.0
1270
 
1271
+ elif self.task_id == "hard_assembly":
1272
  clip_sim_score = self._hard_grader.episode_score()
1273
  revenue_factor = float(np.clip(self._total_revenue / 45.0, 0.0, 1.0))
1274
  # Blend: 60% viral alignment + 40% revenue
 
1276
  headline_score = 0.0
1277
  pacing_score = 0.0
1278
 
1279
+ else: # hard_sequencing
1280
+ seq_sc = self._seq_grader.episode_score(self._budget_init)
1281
+ task_score = seq_sc
1282
+ headline_score = 0.0
1283
+ pacing_score = 0.0
1284
+ clip_sim_score = 0.0
1285
+
1286
+ # Sequencing sub-scores (non-zero only for hard_sequencing)
1287
+ if self.task_id == "hard_sequencing":
1288
+ _seq_score = task_score
1289
+ _ctx_cov = len(self._seq_grader._contexts_won)
1290
+ _div_mult = self._seq_grader._diversity_multiplier()
1291
+ else:
1292
+ _seq_score = 0.0
1293
+ _ctx_cov = 0
1294
+ _div_mult = 1.0
1295
+
1296
  return Info(
1297
  task_id = self.task_id,
1298
  current_step = self._step,
 
1305
  headline_alignment_score= headline_score,
1306
  pacing_score = pacing_score,
1307
  clip_similarity_score = clip_sim_score,
1308
+ sequencing_score = _seq_score,
1309
+ contexts_covered = _ctx_cov,
1310
+ diversity_multiplier = _div_mult,
1311
  )
models.py CHANGED
@@ -172,6 +172,8 @@ class Observation(BaseModel):
172
  description="Accumulated user-fatigue penalty (0 = fresh, 1 = fully fatigued).")
173
 
174
  # ── Performance Feedback (delayed by 1 step) ───────────────────────────
 
 
175
  last_ctr: float = Field(default=0.0, ge=0.0, le=1.0,
176
  description="CTR returned by the User Simulator on the previous step.")
177
  cumulative_revenue: float = Field(default=0.0,
@@ -193,10 +195,9 @@ class Action(BaseModel):
193
  creative_id: int = Field(..., ge=0, le=5,
194
  description="Index into the Creatives Catalog (0–5).")
195
 
196
- # Optional free-text caption (used in hard_assembly grading via CLIP/semantic sim)
197
  generated_caption: Optional[str] = Field(default=None,
198
- description="Free-form caption generated by the agent for hard_assembly. "
199
- "Leave None for easy/medium tasks.")
200
 
201
 
202
  # ---------------------------------------------------------------------------
@@ -226,4 +227,10 @@ class Info(BaseModel):
226
  pacing_score: float = Field(default=0.0, ge=0.0, le=1.0,
227
  description="[medium_pacing] Budget-smoothness and peak-hour survival bonus.")
228
  clip_similarity_score: float = Field(default=0.0, ge=0.0, le=1.0,
229
- description="[hard_assembly] Cosine similarity between caption and viral token.")
 
 
 
 
 
 
 
172
  description="Accumulated user-fatigue penalty (0 = fresh, 1 = fully fatigued).")
173
 
174
  # ── Performance Feedback (delayed by 1 step) ───────────────────────────
175
+ carryover_boost: float = Field(default=0.0, ge=0.0, le=0.30,
176
+ description="CTR boost from brand-recall carry-over (decaying from prior ad wins).")
177
  last_ctr: float = Field(default=0.0, ge=0.0, le=1.0,
178
  description="CTR returned by the User Simulator on the previous step.")
179
  cumulative_revenue: float = Field(default=0.0,
 
195
  creative_id: int = Field(..., ge=0, le=5,
196
  description="Index into the Creatives Catalog (0–5).")
197
 
198
+ # Optional free-text caption (retained for backwards compatibility). No effect in hard_sequencing.
199
  generated_caption: Optional[str] = Field(default=None,
200
+ description="Free-form caption generated by the agent in old hard_assembly mode.")
 
201
 
202
 
203
  # ---------------------------------------------------------------------------
 
227
  pacing_score: float = Field(default=0.0, ge=0.0, le=1.0,
228
  description="[medium_pacing] Budget-smoothness and peak-hour survival bonus.")
229
  clip_similarity_score: float = Field(default=0.0, ge=0.0, le=1.0,
230
+ description="[hard_assembly] Cosine similarity between caption and viral token.")
231
+ sequencing_score: float = Field(default=0.0, ge=0.0, le=1.0,
232
+ description="[hard_sequencing] Agent conversions / Oracle conversions Γ— diversity bonus.")
233
+ contexts_covered: int = Field(default=0, ge=0, le=4,
234
+ description="[hard_sequencing] Number of distinct contexts that received β‰₯1 ad.")
235
+ diversity_multiplier: float = Field(default=1.0,
236
+ description="[hard_sequencing] 1.2 if β‰₯3 contexts covered, else 1.0.")
openenv.yaml CHANGED
@@ -53,6 +53,24 @@ tasks:
53
  score_range: [0.0, 1.0]
54
  target_score: 0.65
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # ─────────────────────────────────────────────────────────────────────────────
57
  # Datasets
58
  # ─────────────────────────────────────────────────────────────────────────────
 
53
  score_range: [0.0, 1.0]
54
  target_score: 0.65
55
 
56
+ - id: "hard_sequencing"
57
+ difficulty: "hard"
58
+ initial_budget_usd: 100.0
59
+ description: >
60
+ Plan a 24-hour sequence of ad placements across Fitness, Tech, Fashion,
61
+ and Gaming contexts. Winning an auction triggers carry-over brand-recall
62
+ boosts (+15%/+10%/+5% CTR) for the next 3 hours. Cover >=3 contexts for
63
+ a 20% diversity bonus. Graded against DP oracle.
64
+ grader:
65
+ type: "HardSequencingGrader"
66
+ formula: "min(1.0, (agent_conversions / oracle_conversions) x diversity_multiplier)"
67
+ components:
68
+ agent_conversions: "sum(adjusted_ctr x conversion_value) for won auctions"
69
+ oracle_conversions: "DP-optimal bid/skip sequence with carry-over"
70
+ diversity_multiplier: "1.2 if >=3 distinct contexts served, else 1.0"
71
+ score_range: [0.0, 1.0]
72
+ target_score: 0.60
73
+
74
  # ─────────────────────────────────────────────────────────────────────────────
75
  # Datasets
76
  # ─────────────────────────────────────────────────────────────────────────────
test_sequencing.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_sequencing.py β€” Unit tests for the HardSequencingGrader and DP oracle.
3
+
4
+ Run: python test_sequencing.py
5
+ """
6
+
7
+ import sys
8
+ sys.path.insert(0, ".")
9
+
10
+ from environment import HardSequencingGrader
11
+
12
+
13
+ def test_dp_oracle_simple():
14
+ """Fixed scenario: 4 steps, known optimal is to bid on steps 0 and 2."""
15
+ g = HardSequencingGrader()
16
+
17
+ # Step 0: cheap (clears at $1), good CTR 0.10
18
+ g.record_step(step=0, context="Fitness", clearing_price=1.0,
19
+ base_ctr=0.10, auction_won=True, cost=1.0,
20
+ conversion_value=15.0)
21
+ # Step 1: expensive (clears at $8), low CTR 0.02
22
+ g.record_step(step=1, context="Tech", clearing_price=8.0,
23
+ base_ctr=0.02, auction_won=False, cost=0.0,
24
+ conversion_value=15.0)
25
+ # Step 2: cheap (clears at $1), good CTR 0.10
26
+ g.record_step(step=2, context="Fashion", clearing_price=1.0,
27
+ base_ctr=0.10, auction_won=True, cost=1.0,
28
+ conversion_value=15.0)
29
+ # Step 3: cheap (clears at $1), good CTR 0.08
30
+ g.record_step(step=3, context="Gaming", clearing_price=1.0,
31
+ base_ctr=0.08, auction_won=True, cost=1.0,
32
+ conversion_value=15.0)
33
+
34
+ oracle_val = g._oracle_conversions(budget=10.0)
35
+ assert oracle_val > 0, f"Oracle should find positive conversions, got {oracle_val}"
36
+ print(f" DP oracle conversions: {oracle_val:.4f} βœ“")
37
+
38
+ score = g.episode_score(initial_budget=10.0)
39
+ assert 0.0 <= score <= 1.0, f"Score out of range: {score}"
40
+ print(f" Episode score: {score:.4f} βœ“")
41
+
42
+
43
+ def test_never_bid_score_zero():
44
+ """Agent never bids β†’ score should be 0.0."""
45
+ g = HardSequencingGrader()
46
+ for i in range(24):
47
+ ctx = ["Fitness", "Tech", "Fashion", "Gaming"][i % 4]
48
+ g.record_step(step=i, context=ctx, clearing_price=1.0,
49
+ base_ctr=0.05, auction_won=False, cost=0.0,
50
+ conversion_value=15.0)
51
+
52
+ score = g.episode_score(initial_budget=100.0)
53
+ assert score == 0.0, f"Expected 0.0 for never-bid agent, got {score}"
54
+ print(f" Never-bid score: {score} βœ“")
55
+
56
+
57
+ def test_diversity_multiplier():
58
+ """Check 1.2Γ— when β‰₯3 contexts, 1.0 otherwise."""
59
+ g = HardSequencingGrader()
60
+
61
+ # Only 2 contexts
62
+ g.record_step(step=0, context="Fitness", clearing_price=1.0,
63
+ base_ctr=0.10, auction_won=True, cost=1.0,
64
+ conversion_value=15.0)
65
+ g.record_step(step=1, context="Tech", clearing_price=1.0,
66
+ base_ctr=0.10, auction_won=True, cost=1.0,
67
+ conversion_value=15.0)
68
+ assert g._diversity_multiplier() == 1.0, "Should be 1.0 with only 2 contexts"
69
+
70
+ # Add 3rd context
71
+ g.record_step(step=2, context="Fashion", clearing_price=1.0,
72
+ base_ctr=0.10, auction_won=True, cost=1.0,
73
+ conversion_value=15.0)
74
+ assert g._diversity_multiplier() == 1.20, "Should be 1.2 with 3 contexts"
75
+ print(f" Diversity multiplier: correct βœ“")
76
+
77
+
78
+ def test_carryover_schedule():
79
+ """Verify carry-over boosts decay correctly: +15%/+10%/+5%."""
80
+ g = HardSequencingGrader()
81
+
82
+ # Win at step 0, skip rest
83
+ g.record_step(step=0, context="Fitness", clearing_price=1.0,
84
+ base_ctr=0.10, auction_won=True, cost=1.0,
85
+ conversion_value=15.0)
86
+ for i in range(1, 5):
87
+ g.record_step(step=i, context="Tech", clearing_price=1.0,
88
+ base_ctr=0.10, auction_won=False, cost=0.0,
89
+ conversion_value=15.0)
90
+
91
+ agent_conv = g._agent_conversions()
92
+ # Only step 0 won, no prior wins β†’ no boost, base conversion = 0.10 * 15 = 1.5
93
+ expected = 0.10 * 15.0
94
+ assert abs(agent_conv - expected) < 0.001, \
95
+ f"Expected {expected}, got {agent_conv}"
96
+ print(f" Carry-over schedule: correct βœ“")
97
+
98
+ # Now test with consecutive wins
99
+ g.reset()
100
+ g.record_step(step=0, context="Fitness", clearing_price=1.0,
101
+ base_ctr=0.10, auction_won=True, cost=1.0,
102
+ conversion_value=15.0)
103
+ g.record_step(step=1, context="Tech", clearing_price=1.0,
104
+ base_ctr=0.10, auction_won=True, cost=1.0,
105
+ conversion_value=15.0)
106
+
107
+ agent_conv = g._agent_conversions()
108
+ # step 0: no boost β†’ 0.10 * 15 = 1.5
109
+ # step 1: boost from step 0 win = +15% β†’ 0.10 * 1.15 * 15 = 1.725
110
+ expected = 1.5 + 0.10 * 1.15 * 15.0
111
+ assert abs(agent_conv - expected) < 0.001, \
112
+ f"Expected {expected}, got {agent_conv}"
113
+ print(f" Consecutive carry-over: correct βœ“")
114
+
115
+
116
+ def test_episode_score_bounded():
117
+ """Score should always be in [0.0, 1.0]."""
118
+ g = HardSequencingGrader()
119
+
120
+ # Agent bids on everything (cheap auctions)
121
+ for i in range(24):
122
+ ctx = ["Fitness", "Tech", "Fashion", "Gaming"][i % 4]
123
+ g.record_step(step=i, context=ctx, clearing_price=0.50,
124
+ base_ctr=0.08, auction_won=True, cost=0.50,
125
+ conversion_value=15.0)
126
+
127
+ score = g.episode_score(initial_budget=100.0)
128
+ assert 0.0 <= score <= 1.0, f"Score out of bounds: {score}"
129
+ print(f" All-bid score: {score:.4f} (bounded) βœ“")
130
+
131
+
132
+ def test_empty_episode():
133
+ """Empty episode β†’ score 0.0."""
134
+ g = HardSequencingGrader()
135
+ score = g.episode_score(initial_budget=100.0)
136
+ assert score == 0.0, f"Expected 0.0 for empty episode, got {score}"
137
+ print(f" Empty episode: {score} βœ“")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ tests = [
142
+ ("DP Oracle (simple scenario)", test_dp_oracle_simple),
143
+ ("Never-bid agent β†’ 0.0", test_never_bid_score_zero),
144
+ ("Diversity multiplier", test_diversity_multiplier),
145
+ ("Carry-over schedule", test_carryover_schedule),
146
+ ("Score bounded [0, 1]", test_episode_score_bounded),
147
+ ("Empty episode", test_empty_episode),
148
+ ]
149
+
150
+ print("=" * 52)
151
+ print(" HardSequencingGrader Unit Tests")
152
+ print("=" * 52)
153
+
154
+ passed = 0
155
+ for name, fn in tests:
156
+ try:
157
+ print(f"\nβ–Έ {name}")
158
+ fn()
159
+ passed += 1
160
+ except Exception as e:
161
+ print(f" βœ— FAILED: {e}")
162
+
163
+ print(f"\n{'─'*52}")
164
+ print(f" {passed}/{len(tests)} tests passed")
165
+ print(f"{'─'*52}")
166
+
167
+ if passed < len(tests):
168
+ sys.exit(1)