Spaces:
Sleeping
Sleeping
Commit Β·
ce3d6c7
1
Parent(s): 364ec8e
task 4
Browse files- baseline.py +31 -11
- environment.py +181 -6
- models.py +11 -4
- openenv.yaml +18 -0
- 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="
|
| 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":
|
| 115 |
-
"medium_pacing":
|
| 116 |
-
"hard_assembly":
|
|
|
|
| 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}
|
| 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"
|
| 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":
|
| 943 |
"medium_pacing": 50.0,
|
| 944 |
-
"hard_assembly":
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 (
|
| 197 |
generated_caption: Optional[str] = Field(default=None,
|
| 198 |
-
description="Free-form caption generated by the agent
|
| 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)
|