Laksh718 commited on
Commit
64c9eee
Β·
verified Β·
1 Parent(s): 27556e5

deploy v5 (long mode, a100-large)

Browse files
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ HF_HOME=/app/.cache \
6
+ TRANSFORMERS_CACHE=/app/.cache \
7
+ HF_HUB_ENABLE_HF_TRANSFER=1 \
8
+ TOKENIZERS_PARALLELISM=false
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ git build-essential ca-certificates curl && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ # Step 1: Install PyTorch with CUDA 12.1 support FIRST (order matters for unsloth).
17
+ # The extra-index-url provides cu121 wheels; the default PyPI would give CPU-only.
18
+ RUN pip install --upgrade pip && \
19
+ pip install \
20
+ torch==2.4.1 \
21
+ torchvision==0.19.1 \
22
+ torchaudio==2.4.1 \
23
+ --index-url https://download.pytorch.org/whl/cu121
24
+
25
+ # Step 2: Install the rest of the dependencies (no torch here β€” already installed).
26
+ COPY requirements.txt .
27
+ RUN pip install -r requirements.txt
28
+
29
+ COPY . .
30
+
31
+ CMD ["python", "-u", "train_hf.py"]
README.md CHANGED
@@ -1,10 +1,41 @@
1
  ---
2
- title: Daedalus Training Space
3
- emoji: πŸŒ–
4
  colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DAEDALUS Training
3
+ emoji: πŸ›
4
  colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ hardware: a100-large
9
  ---
10
 
11
+ # DAEDALUS Training Space (v5)
12
+
13
+ One-shot Docker Space that trains the DAEDALUS mechanism designer using
14
+ Unsloth + Qwen2.5-0.5B-Instruct + two-phase SFT β†’ GRPO:
15
+
16
+ 1. **Load** `unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit` (4-bit, ~2Γ— faster startup).
17
+ 2. **Attach a single LoRA** (rank 16, all attn + MLP, `lora_dropout=0` for Unsloth fast path).
18
+ 3. **SFT** β€” teach the JSON output schema on synthetic `(observation, valid_mechanism)` pairs.
19
+ 4. **GRPO** β€” five reward signals on the SAME LoRA:
20
+ - `reward_format` β€” schema coverage ∈ [βˆ’1, 1]
21
+ - `reward_welfare` β€” social welfare ratio W ∈ [0, 1]
22
+ - `reward_fairness` β€” 1 βˆ’ Gini(surplus) ∈ [0, 1]
23
+ - `reward_stability` β€” 1 βˆ’ 3Οƒ(welfare) ∈ [0, 1]
24
+ - `reward_composite` β€” full R = W Γ— F Γ— P Γ— S Γ— anti_collusion
25
+ 5. **Merge & push** the full 16-bit model to `Laksh718/daedalus-designer`.
26
+
27
+ Sentinel line in container logs: `[grpo v5] five-reward single-adapter`
28
+
29
+ | `TRAIN_MODE` | SFT examples | GRPO steps | A100 time | Use case |
30
+ |--------------|--------------|------------|-----------|-------------------------------|
31
+ | `smoke` | 24 | 4 | ~3 min | shake out build errors |
32
+ | `short` | 320 | 120 | ~10 min | quick sanity check |
33
+ | `long` | 800 | 300 | ~30 min | solid model (default) |
34
+ | `full` | 2000 | 500 | ~75 min | best quality |
35
+
36
+ Load the trained model:
37
+ ```python
38
+ from transformers import AutoModelForCausalLM, AutoTokenizer
39
+ model = AutoModelForCausalLM.from_pretrained("Laksh718/daedalus-designer")
40
+ tok = AutoTokenizer.from_pretrained("Laksh718/daedalus-designer")
41
+ ```
daedalus/__init__.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DAEDALUS Environment Package - Mechanism Design via Adversarial RL.
2
+
3
+ Public API:
4
+
5
+ OpenEnv-compliant (preferred for training, evaluation, judges):
6
+ from daedalus import (
7
+ DaedalusOpenEnv, DaedalusAction, DaedalusObservation,
8
+ DaedalusState, DaedalusEnvClient
9
+ )
10
+
11
+ Legacy tuple-returning API (for the demo dashboard and old notebooks):
12
+ from daedalus import DaedalusEnvironment
13
+ """
14
+
15
+ from .env import DaedalusEnvironment
16
+ from .models import MechanismConfig, MarketOutcome, AgentState
17
+
18
+ try:
19
+ from .openenv_env import DaedalusOpenEnv
20
+ from .openenv_client import DaedalusEnvClient
21
+ from .openenv_models import (
22
+ DaedalusAction,
23
+ DaedalusObservation,
24
+ DaedalusState,
25
+ )
26
+
27
+ _OPENENV_AVAILABLE = True
28
+ except Exception: # noqa: BLE001 - openenv-core not installed
29
+ _OPENENV_AVAILABLE = False
30
+
31
+ __version__ = "2.0.0"
32
+
33
+ __all__ = [
34
+ "DaedalusEnvironment",
35
+ "MechanismConfig",
36
+ "MarketOutcome",
37
+ "AgentState",
38
+ ]
39
+
40
+ if _OPENENV_AVAILABLE:
41
+ __all__ += [
42
+ "DaedalusOpenEnv",
43
+ "DaedalusEnvClient",
44
+ "DaedalusAction",
45
+ "DaedalusObservation",
46
+ "DaedalusState",
47
+ ]
daedalus/agents.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Sub-Agents β€” Strategic agent implementations.
3
+ Each agent type exploits a different aspect of the mechanism.
4
+ """
5
+ import math
6
+ import random
7
+ from typing import List, Optional
8
+ from .models import AgentState, MechanismConfig
9
+
10
+
11
+ class SubAgent:
12
+ """Base class for all sub-agents."""
13
+
14
+ def __init__(self, state: AgentState):
15
+ self.state = state
16
+
17
+ def generate_valuation(self) -> float:
18
+ """Draw a new private valuation from the agent's distribution."""
19
+ raise NotImplementedError
20
+
21
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
22
+ """Compute bid given the current mechanism and market history."""
23
+ raise NotImplementedError
24
+
25
+ def adapt(self, won: bool, mech: MechanismConfig, history: dict):
26
+ """Adapt strategy based on outcome."""
27
+ pass
28
+
29
+ def check_participation(self, mech: MechanismConfig) -> bool:
30
+ """Check if agent still wants to participate."""
31
+ return self.state.active
32
+
33
+
34
+ class TruthfulBidder(SubAgent):
35
+ """Bids true private valuation. No strategic model."""
36
+
37
+ def generate_valuation(self) -> float:
38
+ self.state.valuation = 0.3 + random.random() * 0.6
39
+ return self.state.valuation
40
+
41
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
42
+ # Truthful: bid = valuation (dominant strategy under second-price)
43
+ self.state.bid = self.state.valuation
44
+ return self.state.bid
45
+
46
+ def check_participation(self, mech: MechanismConfig) -> bool:
47
+ return self.state.valuation >= mech.reserve_price
48
+
49
+
50
+ class BidShader(SubAgent):
51
+ """
52
+ First-order strategic agent. Shades bid below valuation.
53
+ In first-price auctions with n symmetric bidders drawing uniformly on [0,1],
54
+ the Bayes-Nash equilibrium bid is v Γ— (n-1)/n.
55
+ """
56
+
57
+ def generate_valuation(self) -> float:
58
+ self.state.valuation = 0.4 + random.random() * 0.5
59
+ return self.state.valuation
60
+
61
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
62
+ v = self.state.valuation
63
+
64
+ if mech.auction_type == "first_price":
65
+ # BNE shading: bid = v Γ— (n-1)/n
66
+ shade_factor = (n_active - 1) / max(n_active, 2)
67
+
68
+ # Adapt based on revealed information
69
+ if mech.reveal_clearing_price:
70
+ shade_factor -= 0.03 # Can calibrate more precisely
71
+ if mech.reveal_competing_bids:
72
+ shade_factor -= 0.05 # Full info enables strategic shading
73
+
74
+ # Learn from price history
75
+ prices = history.get("clearing_prices", [])
76
+ if len(prices) > 2:
77
+ avg_price = sum(prices[-5:]) / min(len(prices), 5)
78
+ if avg_price < v * 0.7:
79
+ shade_factor -= 0.05 # Shade more aggressively
80
+
81
+ shade_factor = max(0.5, min(shade_factor, 0.95))
82
+ self.state.shade_factor = 1 - shade_factor
83
+ self.state.bid = v * shade_factor
84
+ else:
85
+ # Second-price / VCG: truthful is dominant, minor bounded rationality noise
86
+ self.state.shade_factor = 0.02 + random.random() * 0.03
87
+ self.state.bid = v * (1 - self.state.shade_factor)
88
+
89
+ return self.state.bid
90
+
91
+ def adapt(self, won: bool, mech: MechanismConfig, history: dict):
92
+ if won:
93
+ self.state.shade_factor = min(self.state.shade_factor + 0.01, 0.35)
94
+ else:
95
+ self.state.shade_factor = max(self.state.shade_factor - 0.005, 0.02)
96
+
97
+ def check_participation(self, mech: MechanismConfig) -> bool:
98
+ return self.state.valuation >= mech.reserve_price * 1.1
99
+
100
+
101
+ class Colluder(SubAgent):
102
+ """
103
+ Coalition agent that coordinates with a partner.
104
+ One bids high, partner bids very low, they rotate winning.
105
+ Exploits information transparency for cartel enforcement.
106
+ """
107
+
108
+ def generate_valuation(self) -> float:
109
+ self.state.valuation = 0.35 + random.random() * 0.5
110
+ return self.state.valuation
111
+
112
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
113
+ v = self.state.valuation
114
+ should_win = self.state.collusion_turn
115
+ reserve = mech.reserve_price
116
+
117
+ # Check if collusion is viable
118
+ can_collude = mech.coalition_policy == "allow" or (
119
+ mech.coalition_policy == "restrict" and random.random() > 0.5
120
+ )
121
+ penalty_risk = 0.4 if mech.collusion_penalty > 1 else 0.0
122
+
123
+ if can_collude and random.random() > penalty_risk:
124
+ if mech.reveal_winner_identity:
125
+ # Strong collusion when winner is revealed (cartel can enforce)
126
+ self.state.bid = reserve + 0.01 if should_win else reserve * 0.3
127
+ else:
128
+ if should_win:
129
+ target = reserve + 0.02 + random.random() * 0.05
130
+ self.state.bid = min(v, max(target, v * 0.5))
131
+ else:
132
+ self.state.bid = reserve * 0.5
133
+ else:
134
+ # Compete honestly when collusion is too risky
135
+ factor = 0.85 if mech.auction_type == "first_price" else 0.98
136
+ self.state.bid = v * factor
137
+
138
+ return self.state.bid
139
+
140
+ def adapt(self, won: bool, mech: MechanismConfig, history: dict):
141
+ # If facing high collusion penalties, gradually abandon collusion
142
+ if mech.collusion_penalty > 1.5:
143
+ self.state.shade_factor = min(self.state.shade_factor + 0.02, 0.5)
144
+
145
+ def check_participation(self, mech: MechanismConfig) -> bool:
146
+ return self.state.valuation >= mech.reserve_price * 0.8
147
+
148
+
149
+ class StrategicDropout(SubAgent):
150
+ """
151
+ Agent with participation threshold. Exits if expected surplus
152
+ falls below outside option (reserve utility).
153
+ """
154
+
155
+ def generate_valuation(self) -> float:
156
+ self.state.valuation = 0.15 + random.random() * 0.45
157
+ return self.state.valuation
158
+
159
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
160
+ v = self.state.valuation
161
+ expected_surplus = v - mech.reserve_price - 0.05
162
+
163
+ # Update cumulative surplus tracker
164
+ if expected_surplus > 0:
165
+ self.state.cumulative_surplus += expected_surplus * 0.1
166
+ else:
167
+ self.state.cumulative_surplus -= 0.02
168
+
169
+ # Check dropout condition
170
+ if (self.state.cumulative_surplus < -self.state.dropout_threshold or
171
+ mech.reserve_price > v * 0.9):
172
+ self.state.active = False
173
+ self.state.bid = 0.0
174
+ return 0.0
175
+
176
+ factor = 0.9 if mech.auction_type == "first_price" else 1.0
177
+ self.state.bid = v * factor
178
+ return self.state.bid
179
+
180
+ def adapt(self, won: bool, mech: MechanismConfig, history: dict):
181
+ # Re-entry: if conditions improve, come back
182
+ if not self.state.active:
183
+ participation = history.get("participation_rates", [])
184
+ if len(participation) > 3:
185
+ recent_avg = sum(participation[-3:]) / 3
186
+ if recent_avg > 0.6 and mech.reserve_price < 0.3:
187
+ self.state.active = True
188
+ self.state.cumulative_surplus = 0.0
189
+
190
+ def check_participation(self, mech: MechanismConfig) -> bool:
191
+ return self.state.active
192
+
193
+
194
+ class BudgetExploiter(SubAgent):
195
+ """
196
+ Budget-constrained agent. Submits strategic bids within budget.
197
+ Tests payment rule edge cases and attempts to exhaust market liquidity.
198
+ """
199
+
200
+ def generate_valuation(self) -> float:
201
+ self.state.valuation = 0.2 + random.random() * 0.4
202
+ return self.state.valuation
203
+
204
+ def compute_bid(self, mech: MechanismConfig, n_active: int, history: dict) -> float:
205
+ if self.state.budget <= 0:
206
+ self.state.active = False
207
+ self.state.bid = 0.0
208
+ return 0.0
209
+
210
+ v = self.state.valuation
211
+ aggressiveness = 0.6 if self.state.budget > 1.5 else 0.3
212
+ self.state.bid = min(v * aggressiveness, self.state.budget * 0.3)
213
+ return self.state.bid
214
+
215
+ def adapt(self, won: bool, mech: MechanismConfig, history: dict):
216
+ # Budget gets deducted upon winning in the env step
217
+ if self.state.budget <= 0:
218
+ self.state.active = False
219
+
220
+ def check_participation(self, mech: MechanismConfig) -> bool:
221
+ return self.state.active and self.state.budget > 0
222
+
223
+
224
+ # ── Factory ──────────────────────────────────────────
225
+ AGENT_CLASSES = {
226
+ "truthful": TruthfulBidder,
227
+ "shader": BidShader,
228
+ "colluder": Colluder,
229
+ "dropout": StrategicDropout,
230
+ "exploiter": BudgetExploiter,
231
+ }
232
+
233
+
234
+ def create_default_population(stage: int = 0) -> List[SubAgent]:
235
+ """
236
+ Create an 8-agent population based on the curriculum stage.
237
+ Stage 0 is easiest (truthful baseline); Stage 4 is full adversarial.
238
+ """
239
+ # Base: 8 truthful agents
240
+ agents = [
241
+ TruthfulBidder(AgentState(agent_id=i, agent_type="truthful"))
242
+ for i in range(8)
243
+ ]
244
+
245
+ # Stage 1: Add 2 Shaders
246
+ if stage >= 1:
247
+ agents[2] = BidShader(AgentState(agent_id=2, agent_type="shader"))
248
+ agents[3] = BidShader(AgentState(agent_id=3, agent_type="shader"))
249
+
250
+ # Stage 2: Add 2 strategic Dropouts
251
+ if stage >= 2:
252
+ agents[6] = StrategicDropout(AgentState(agent_id=6, agent_type="dropout", dropout_threshold=0.08))
253
+ # Replacement at index 1
254
+ agents[1] = StrategicDropout(AgentState(agent_id=1, agent_type="dropout", dropout_threshold=0.12))
255
+
256
+ # Stage 3: Add 2 Colluders
257
+ if stage >= 3:
258
+ agents[4] = Colluder(AgentState(agent_id=4, agent_type="colluder", partner_id=5, collusion_turn=False))
259
+ agents[5] = Colluder(AgentState(agent_id=5, agent_type="colluder", partner_id=4, collusion_turn=True))
260
+
261
+ # Stage 4: Add 2 Budget Exploiters
262
+ if stage >= 4:
263
+ agents[7] = BudgetExploiter(AgentState(agent_id=7, agent_type="exploiter", budget=2.5))
264
+ agents[0] = BudgetExploiter(AgentState(agent_id=0, agent_type="exploiter", budget=2.0))
265
+
266
+ return agents
daedalus/env.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Environment β€” OpenEnv-compliant RL environment.
3
+ Trains an LLM to design market mechanisms against adversarial sub-agents.
4
+
5
+ OpenEnv API:
6
+ env = DaedalusEnvironment()
7
+ obs = env.reset()
8
+ obs, reward, done, info = env.step(action)
9
+ state = env.state()
10
+ """
11
+ import json
12
+ import math
13
+ import random
14
+ from typing import Tuple, Dict, Any, List, Optional
15
+
16
+ from .models import MechanismConfig, MarketOutcome, Observation, AgentState
17
+ from .agents import (
18
+ SubAgent, create_default_population,
19
+ TruthfulBidder, BidShader, Colluder, StrategicDropout, BudgetExploiter,
20
+ )
21
+ from .rewards import (
22
+ compute_welfare_ratio, compute_fairness, compute_gini,
23
+ compute_participation_rate, compute_stability,
24
+ compute_collusion_signal, compute_composite_reward,
25
+ )
26
+
27
+
28
+ class DaedalusEnvironment:
29
+ """
30
+ DAEDALUS: Mechanism Design via Adversarial RL
31
+
32
+ An OpenEnv-compliant environment where an LLM agent designs
33
+ market mechanisms while strategic sub-agents probe for exploits.
34
+ Reward is the product of welfare, fairness, participation, and stability.
35
+
36
+ Themes:
37
+ #1 Multi-Agent Interactions (primary)
38
+ #4 Self-Improvement via curriculum (secondary)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ n_agents: int = 8,
44
+ episode_length: int = 50,
45
+ rounds_per_step: int = 5,
46
+ curriculum_stage: int = 0,
47
+ ):
48
+ self.n_agents = n_agents
49
+ self.episode_length = episode_length
50
+ self.rounds_per_step = rounds_per_step
51
+ self.curriculum_stage = curriculum_stage
52
+
53
+ # Internal state
54
+ self.agents: List[SubAgent] = []
55
+ self.mechanism = MechanismConfig()
56
+ self.round = 0
57
+ self.history: List[MarketOutcome] = []
58
+ self.welfare_history: List[float] = []
59
+ self.participation_history: List[float] = []
60
+ self.clearing_prices: List[float] = []
61
+ self.collusion_rotation = 0
62
+ self.done = False
63
+
64
+ def reset(self) -> dict:
65
+ """
66
+ Start a fresh episode.
67
+ Samples new population, resets mechanism to default, clears history.
68
+
69
+ Returns:
70
+ Observation dict for the LLM agent.
71
+ """
72
+ self.agents = create_default_population(stage=self.curriculum_stage)
73
+ self.mechanism = MechanismConfig()
74
+ self.round = 0
75
+ self.history = []
76
+ self.welfare_history = []
77
+ self.participation_history = []
78
+ self.clearing_prices = []
79
+ self.collusion_rotation = 0
80
+ self.done = False
81
+
82
+ # Generate initial valuations
83
+ for agent in self.agents:
84
+ agent.generate_valuation()
85
+
86
+ # Run warm-up rounds with default mechanism
87
+ for _ in range(3):
88
+ self._run_market_round()
89
+
90
+ return self._get_observation().to_dict()
91
+
92
+ def step(self, action: Any) -> Tuple[dict, float, bool, dict]:
93
+ """
94
+ Apply a mechanism configuration action and run market rounds.
95
+
96
+ Args:
97
+ action: Either a MechanismConfig, dict, or JSON string.
98
+
99
+ Returns:
100
+ (observation, reward, done, info)
101
+ """
102
+ if self.done:
103
+ return self._get_observation().to_dict(), 0.0, True, {"error": "Episode finished"}
104
+
105
+ # Parse action
106
+ if isinstance(action, MechanismConfig):
107
+ self.mechanism = action
108
+ elif isinstance(action, dict):
109
+ self.mechanism = MechanismConfig.from_dict(action)
110
+ elif isinstance(action, str):
111
+ try:
112
+ self.mechanism = MechanismConfig.from_json(action)
113
+ except (json.JSONDecodeError, TypeError):
114
+ return self._get_observation().to_dict(), 0.0, False, {"error": "Invalid JSON"}
115
+ else:
116
+ return self._get_observation().to_dict(), 0.0, False, {"error": "Invalid action type"}
117
+
118
+ self.round += 1
119
+
120
+ # Run market rounds
121
+ round_outcomes = []
122
+ for _ in range(self.rounds_per_step):
123
+ outcome = self._run_market_round()
124
+ round_outcomes.append(outcome)
125
+
126
+ # Compute reward from post-adaptation rounds (rounds 3-5)
127
+ eval_outcomes = round_outcomes[2:] if len(round_outcomes) >= 3 else round_outcomes
128
+ avg_reward = sum(o.composite_reward for o in eval_outcomes) / max(len(eval_outcomes), 1)
129
+
130
+ # Check termination
131
+ self.done = self.round >= self.episode_length
132
+ if self._check_collapse():
133
+ self.done = True
134
+
135
+ # Build info
136
+ latest = round_outcomes[-1] if round_outcomes else MarketOutcome()
137
+ info = {
138
+ "round": self.round,
139
+ "mechanism": self.mechanism.to_dict(),
140
+ "welfare_ratio": latest.welfare_ratio,
141
+ "gini_coefficient": latest.gini_coefficient,
142
+ "participation_rate": latest.participation_rate,
143
+ "stability_score": latest.stability_score,
144
+ "composite_reward": avg_reward,
145
+ "collusion_signal": latest.collusion_signal,
146
+ "active_agents": sum(1 for a in self.agents if a.state.active),
147
+ "dropout_count": latest.dropout_count,
148
+ }
149
+
150
+ return self._get_observation().to_dict(), avg_reward, self.done, info
151
+
152
+ def state(self) -> dict:
153
+ """Return current observable state (POMDP observation)."""
154
+ return self._get_observation().to_dict()
155
+
156
+ def _run_market_round(self) -> MarketOutcome:
157
+ """Execute one auction round and return outcome."""
158
+ mech = self.mechanism
159
+ active_agents = [a for a in self.agents if a.state.active]
160
+ n_active = len(active_agents)
161
+ n_total = len(self.agents)
162
+
163
+ # Generate new valuations
164
+ for agent in active_agents:
165
+ agent.generate_valuation()
166
+
167
+ # Collect bids
168
+ history = {
169
+ "clearing_prices": self.clearing_prices,
170
+ "participation_rates": self.participation_history,
171
+ }
172
+ for agent in self.agents:
173
+ if agent.state.active:
174
+ agent.compute_bid(mech, n_active, history)
175
+ else:
176
+ agent.state.bid = 0.0
177
+
178
+ # Filter valid bids (above reserve)
179
+ valid_bidders = [
180
+ a for a in active_agents
181
+ if a.state.bid >= mech.reserve_price
182
+ ]
183
+ valid_bidders.sort(key=lambda a: a.state.bid, reverse=True)
184
+
185
+ # Allocation & Payment
186
+ winner = None
187
+ payment = 0.0
188
+ winner_valuation = 0.0
189
+
190
+ if valid_bidders:
191
+ winner = valid_bidders[0]
192
+ winner_valuation = winner.state.valuation
193
+
194
+ if mech.auction_type == "first_price":
195
+ payment = winner.state.bid
196
+ elif mech.auction_type in ("second_price", "vcg"):
197
+ payment = valid_bidders[1].state.bid if len(valid_bidders) > 1 else mech.reserve_price
198
+
199
+ winner.state.surplus = winner.state.valuation - payment
200
+ winner.state.wins += 1
201
+
202
+ # Budget deduction for exploiter
203
+ if winner.state.agent_type == "exploiter":
204
+ winner.state.budget -= payment
205
+
206
+ # Collusion penalty
207
+ if mech.collusion_penalty > 0 and winner.state.agent_type == "colluder":
208
+ partner = self._get_partner(winner)
209
+ if partner and partner.state.active and partner.state.bid < mech.reserve_price * 0.8:
210
+ penalty_amount = payment * mech.collusion_penalty * 0.3
211
+ winner.state.surplus -= penalty_amount
212
+
213
+ self.collusion_rotation += 1
214
+ # Toggle collusion turns
215
+ for a in self.agents:
216
+ if a.state.agent_type == "colluder":
217
+ a.state.collusion_turn = not a.state.collusion_turn
218
+
219
+ # Adapt agents
220
+ for agent in self.agents:
221
+ won = winner is not None and agent.state.agent_id == winner.state.agent_id
222
+ agent.adapt(won, mech, history)
223
+
224
+ # Compute metrics
225
+ all_valuations = [a.state.valuation for a in self.agents]
226
+ surplus_dist = [max(a.state.surplus, 0) for a in active_agents]
227
+ bids = [a.state.bid for a in active_agents if a.state.bid > 0]
228
+ wins = [a.state.wins for a in self.agents]
229
+
230
+ welfare = compute_welfare_ratio(winner_valuation, all_valuations)
231
+ fairness = compute_fairness(surplus_dist)
232
+ participation = compute_participation_rate(n_active, n_total)
233
+ self.welfare_history.append(welfare)
234
+ self.participation_history.append(participation)
235
+ self.clearing_prices.append(payment)
236
+ stability = compute_stability(self.welfare_history)
237
+ collusion = compute_collusion_signal(bids, wins)
238
+
239
+ # Anti-collusion multiplier
240
+ collusion_mult = max(0.5, 1.0 - collusion * 0.5) if collusion > 0.5 else 1.0
241
+
242
+ composite = compute_composite_reward(
243
+ welfare, fairness, participation, stability,
244
+ collusion_penalty=collusion_mult,
245
+ )
246
+
247
+ outcome = MarketOutcome(
248
+ welfare_ratio=welfare,
249
+ gini_coefficient=compute_gini(surplus_dist),
250
+ participation_rate=participation,
251
+ clearing_price_mean=payment,
252
+ clearing_price_std=0.0,
253
+ dropout_count=n_total - n_active,
254
+ collusion_signal=collusion,
255
+ stability_score=stability,
256
+ composite_reward=composite,
257
+ )
258
+ self.history.append(outcome)
259
+ return outcome
260
+
261
+ def _get_observation(self) -> Observation:
262
+ """Build observation for the LLM agent."""
263
+ active = [a for a in self.agents if a.state.active]
264
+ bids = [a.state.bid for a in active if a.state.bid > 0]
265
+
266
+ # Population proxies (observable aggregate statistics)
267
+ proxies = {
268
+ "active_count": len(active),
269
+ "total_agents": len(self.agents),
270
+ "bid_mean": sum(bids) / max(len(bids), 1),
271
+ "bid_std": (sum((b - sum(bids)/max(len(bids),1))**2 for b in bids) / max(len(bids),1)) ** 0.5 if bids else 0,
272
+ "bid_correlation": compute_collusion_signal(
273
+ bids, [a.state.wins for a in self.agents]
274
+ ),
275
+ "rotation_entropy": self._compute_rotation_entropy(),
276
+ "dropout_rate": 1 - len(active) / max(len(self.agents), 1),
277
+ "budget_exhaustion_count": sum(
278
+ 1 for a in self.agents
279
+ if a.state.agent_type == "exploiter" and a.state.budget <= 0
280
+ ),
281
+ "clearing_price_trend": self._compute_trend(self.clearing_prices),
282
+ }
283
+
284
+ return Observation(
285
+ mechanism_config=self.mechanism.to_dict(),
286
+ market_outcomes=[o.to_dict() for o in self.history[-20:]],
287
+ population_proxies=proxies,
288
+ round_number=self.round,
289
+ episode_length=self.episode_length,
290
+ curriculum_stage=self.curriculum_stage,
291
+ )
292
+
293
+ def _get_partner(self, agent: SubAgent) -> Optional[SubAgent]:
294
+ """Get collusion partner for a colluder agent."""
295
+ if agent.state.partner_id is not None:
296
+ for a in self.agents:
297
+ if a.state.agent_id == agent.state.partner_id:
298
+ return a
299
+ return None
300
+
301
+ def _compute_rotation_entropy(self) -> float:
302
+ """Compute entropy of winner distribution (cartel detection)."""
303
+ wins = [a.state.wins for a in self.agents]
304
+ total = sum(wins)
305
+ if total <= 0:
306
+ return 1.0
307
+ entropy = 0.0
308
+ for w in wins:
309
+ if w > 0:
310
+ p = w / total
311
+ entropy -= p * math.log2(p)
312
+ max_entropy = math.log2(len(wins)) if len(wins) > 1 else 1
313
+ return entropy / max_entropy if max_entropy > 0 else 1.0
314
+
315
+ def _compute_trend(self, values: List[float], window: int = 5) -> float:
316
+ """Compute first derivative (trend) of recent values."""
317
+ if len(values) < 2:
318
+ return 0.0
319
+ recent = values[-window:]
320
+ if len(recent) < 2:
321
+ return 0.0
322
+ return (recent[-1] - recent[0]) / len(recent)
323
+
324
+ def _check_collapse(self) -> bool:
325
+ """Check if mechanism has collapsed (zero participation)."""
326
+ active = sum(1 for a in self.agents if a.state.active)
327
+ return active == 0
daedalus/models.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Data Models β€” Typed dataclasses for mechanism design environment.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal, List, Optional
6
+ import json
7
+
8
+
9
+ @dataclass
10
+ class MechanismConfig:
11
+ """Full mechanism specification β€” the designer's action."""
12
+ auction_type: Literal["first_price", "second_price", "vcg"] = "second_price"
13
+ reveal_reserve: bool = False
14
+ reveal_competing_bids: bool = False
15
+ reveal_winner_identity: bool = False
16
+ reveal_clearing_price: bool = True
17
+ reveal_bid_distribution: bool = False
18
+ reserve_price: float = 0.10
19
+ shill_penalty: float = 0.0
20
+ withdrawal_penalty: float = 0.0
21
+ collusion_penalty: float = 0.0
22
+ coalition_policy: Literal["allow", "restrict", "penalize_suspected", "penalize_confirmed"] = "allow"
23
+
24
+ def to_dict(self):
25
+ return {
26
+ "auction_type": self.auction_type,
27
+ "reveal_reserve": self.reveal_reserve,
28
+ "reveal_competing_bids": self.reveal_competing_bids,
29
+ "reveal_winner_identity": self.reveal_winner_identity,
30
+ "reveal_clearing_price": self.reveal_clearing_price,
31
+ "reveal_bid_distribution": self.reveal_bid_distribution,
32
+ "reserve_price": self.reserve_price,
33
+ "shill_penalty": self.shill_penalty,
34
+ "withdrawal_penalty": self.withdrawal_penalty,
35
+ "collusion_penalty": self.collusion_penalty,
36
+ "coalition_policy": self.coalition_policy,
37
+ }
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict) -> "MechanismConfig":
41
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
42
+
43
+ @classmethod
44
+ def from_json(cls, s: str) -> "MechanismConfig":
45
+ return cls.from_dict(json.loads(s))
46
+
47
+ def to_json(self) -> str:
48
+ return json.dumps(self.to_dict(), indent=2)
49
+
50
+
51
+ @dataclass
52
+ class MarketOutcome:
53
+ """Aggregate market outcome from one step (5 market rounds)."""
54
+ welfare_ratio: float = 0.0
55
+ gini_coefficient: float = 0.0
56
+ participation_rate: float = 1.0
57
+ clearing_price_mean: float = 0.0
58
+ clearing_price_std: float = 0.0
59
+ dropout_count: int = 0
60
+ collusion_signal: float = 0.0
61
+ stability_score: float = 1.0
62
+ composite_reward: float = 0.0
63
+
64
+ def to_dict(self):
65
+ return {
66
+ "welfare_ratio": self.welfare_ratio,
67
+ "gini_coefficient": self.gini_coefficient,
68
+ "participation_rate": self.participation_rate,
69
+ "clearing_price_mean": self.clearing_price_mean,
70
+ "clearing_price_std": self.clearing_price_std,
71
+ "dropout_count": self.dropout_count,
72
+ "collusion_signal": self.collusion_signal,
73
+ "stability_score": self.stability_score,
74
+ "composite_reward": self.composite_reward,
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class AgentState:
80
+ """Internal state of a sub-agent (hidden from the designer)."""
81
+ agent_id: int = 0
82
+ agent_type: Literal["truthful", "shader", "colluder", "dropout", "exploiter"] = "truthful"
83
+ valuation: float = 0.0
84
+ bid: float = 0.0
85
+ surplus: float = 0.0
86
+ active: bool = True
87
+ budget: float = float("inf")
88
+ shade_factor: float = 0.0
89
+ wins: int = 0
90
+ # Colluder-specific
91
+ partner_id: Optional[int] = None
92
+ collusion_turn: bool = False
93
+ # Dropout-specific
94
+ dropout_threshold: float = 0.08
95
+ cumulative_surplus: float = 0.0
96
+
97
+
98
+ @dataclass
99
+ class Observation:
100
+ """What the designer agent sees at each timestep."""
101
+ mechanism_config: dict = field(default_factory=dict)
102
+ market_outcomes: List[dict] = field(default_factory=list)
103
+ population_proxies: dict = field(default_factory=dict)
104
+ round_number: int = 0
105
+ episode_length: int = 50
106
+ curriculum_stage: int = 0
107
+
108
+ def to_dict(self):
109
+ return {
110
+ "mechanism_config": self.mechanism_config,
111
+ "market_outcomes": self.market_outcomes,
112
+ "population_proxies": self.population_proxies,
113
+ "round_number": self.round_number,
114
+ "episode_length": self.episode_length,
115
+ "curriculum_stage": self.curriculum_stage,
116
+ }
117
+
118
+ def to_prompt(self) -> str:
119
+ """Convert observation to natural language prompt for LLM."""
120
+ lines = [
121
+ "You are a mechanism designer. Analyze the current market state and propose an optimal mechanism configuration.",
122
+ "",
123
+ f"Round: {self.round_number} / {self.episode_length}",
124
+ f"Curriculum Stage: {self.curriculum_stage}",
125
+ "",
126
+ "Current Mechanism:",
127
+ f" Auction Type: {self.mechanism_config.get('auction_type', 'second_price')}",
128
+ f" Reserve Price: {self.mechanism_config.get('reserve_price', 0.1):.3f}",
129
+ f" Coalition Policy: {self.mechanism_config.get('coalition_policy', 'allow')}",
130
+ "",
131
+ "Recent Market Outcomes:",
132
+ ]
133
+
134
+ for i, outcome in enumerate(self.market_outcomes[-5:]):
135
+ lines.append(
136
+ f" Round {self.round_number - len(self.market_outcomes) + i + 1}: "
137
+ f"W={outcome.get('welfare_ratio', 0):.3f} "
138
+ f"F={1 - outcome.get('gini_coefficient', 0):.3f} "
139
+ f"P={outcome.get('participation_rate', 1):.3f} "
140
+ f"R={outcome.get('composite_reward', 0):.3f}"
141
+ )
142
+
143
+ proxies = self.population_proxies
144
+ if proxies:
145
+ lines.extend([
146
+ "",
147
+ "Population Signals:",
148
+ f" Active Bidders: {proxies.get('active_count', 8)}",
149
+ f" Bid Correlation: {proxies.get('bid_correlation', 0):.3f}",
150
+ f" Winner Rotation Entropy: {proxies.get('rotation_entropy', 1):.3f}",
151
+ f" Dropout Rate: {proxies.get('dropout_rate', 0):.3f}",
152
+ ])
153
+
154
+ lines.extend([
155
+ "",
156
+ "Respond with a JSON mechanism configuration:",
157
+ '{"auction_type": "first_price|second_price|vcg", "reserve_price": float, '
158
+ '"reveal_reserve": bool, "reveal_competing_bids": bool, "reveal_winner_identity": bool, '
159
+ '"reveal_clearing_price": bool, "reveal_bid_distribution": bool, '
160
+ '"shill_penalty": float, "withdrawal_penalty": float, "collusion_penalty": float, '
161
+ '"coalition_policy": "allow|restrict|penalize_suspected|penalize_confirmed"}',
162
+ ])
163
+ return "\n".join(lines)
daedalus/openenv_client.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS OpenEnv Client β€” Typed interface for the DAEDALUS environment.
3
+ Allows easy connection to the hosted HF Space.
4
+ """
5
+ from typing import Optional
6
+ from openenv.core.env_client import EnvClient
7
+ from .openenv_models import DaedalusAction, DaedalusObservation, DaedalusState
8
+
9
+ class DaedalusEnvClient(EnvClient[DaedalusAction, DaedalusObservation, DaedalusState]):
10
+ """
11
+ Typed client for the DAEDALUS Mechanism Design environment.
12
+
13
+ Usage:
14
+ client = DaedalusEnvClient(base_url="https://kabilesh-c-daedalus-env.hf.space")
15
+ obs = client.reset(seed=42).sync()
16
+ obs = client.step(DaedalusAction(auction_type="second_price", ...)).sync()
17
+ """
18
+ def __init__(self, base_url: str, token: Optional[str] = None):
19
+ super().__init__(
20
+ base_url=base_url,
21
+ token=token,
22
+ action_type=DaedalusAction,
23
+ observation_type=DaedalusObservation,
24
+ state_type=DaedalusState
25
+ )
daedalus/openenv_env.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS OpenEnv-compliant environment.
3
+
4
+ This is the canonical class to use. It inherits from
5
+ `openenv.core.env_server.interfaces.Environment`, exposes the standard
6
+ Gymnasium-style API (`reset`, `step`, `state`), and returns typed
7
+ Pydantic observations whose `.reward` and `.done` fields replace the
8
+ legacy `(obs, reward, done, info)` tuple.
9
+
10
+ The legacy `DaedalusEnvironment` (in `daedalus.env`) is preserved for
11
+ the demo dashboard and earlier notebooks so existing code keeps working.
12
+ This class wraps the legacy env so there is exactly one source of truth
13
+ for the auction / sub-agent / reward logic.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ import uuid
20
+ from typing import Any, Optional
21
+
22
+ from openenv.core.env_server.interfaces import Environment
23
+
24
+ from .env import DaedalusEnvironment as _LegacyEnv
25
+ from .openenv_models import DaedalusAction, DaedalusObservation, DaedalusState
26
+ from .rubrics import DaedalusCompositeRubric
27
+
28
+
29
+ class DaedalusOpenEnv(Environment[DaedalusAction, DaedalusObservation, DaedalusState]):
30
+ """
31
+ OpenEnv-compliant DAEDALUS mechanism design environment.
32
+
33
+ Theme #1 - Multi-Agent Interactions (primary):
34
+ An LLM designer agent picks auction rules; a population of
35
+ adaptive sub-agents (truthful bidders, shaders, colluders,
36
+ strategic dropouts, budget exploiters) probe the mechanism for
37
+ exploits. The composite reward
38
+ R = welfare * fairness * participation * stability
39
+ forces multi-objective tradeoffs that single-objective gaming
40
+ cannot satisfy.
41
+
42
+ Theme #4 - Self-Improvement (secondary):
43
+ A 5-stage curriculum gradually mixes harder sub-agent types
44
+ into the population so the designer self-improves against
45
+ progressively stronger adversaries.
46
+
47
+ Usage::
48
+
49
+ from daedalus import DaedalusOpenEnv, DaedalusAction
50
+
51
+ env = DaedalusOpenEnv(n_agents=8, episode_length=20)
52
+ obs = env.reset(seed=42)
53
+
54
+ action = DaedalusAction(
55
+ auction_type="second_price",
56
+ reserve_price=0.15,
57
+ collusion_penalty=1.5,
58
+ coalition_policy="penalize_suspected",
59
+ )
60
+ obs = env.step(action)
61
+ print(obs.reward, obs.done)
62
+
63
+ print(env.state.step_count, env.state.n_active_agents)
64
+ """
65
+
66
+ SUPPORTS_CONCURRENT_SESSIONS = False
67
+
68
+ def __init__(
69
+ self,
70
+ n_agents: int = 8,
71
+ episode_length: int = 50,
72
+ rounds_per_step: int = 5,
73
+ curriculum_stage: int = 0,
74
+ **kwargs: Any,
75
+ ):
76
+ super().__init__(rubric=DaedalusCompositeRubric(), **kwargs)
77
+ self._env = _LegacyEnv(
78
+ n_agents=n_agents,
79
+ episode_length=episode_length,
80
+ rounds_per_step=rounds_per_step,
81
+ curriculum_stage=curriculum_stage,
82
+ )
83
+ self._episode_id: Optional[str] = None
84
+ self._last_reward: float = 0.0
85
+
86
+ # -------------------------------------------------------------------
87
+ # OpenEnv abstract API
88
+ # -------------------------------------------------------------------
89
+ def reset(
90
+ self,
91
+ seed: Optional[int] = None,
92
+ episode_id: Optional[str] = None,
93
+ **kwargs: Any,
94
+ ) -> DaedalusObservation:
95
+ if seed is not None:
96
+ random.seed(seed)
97
+ self._episode_id = episode_id or str(uuid.uuid4())
98
+ self._last_reward = 0.0
99
+ obs_dict = self._env.reset()
100
+ return self._make_observation(obs_dict, reward=None, done=False)
101
+
102
+ def step(
103
+ self,
104
+ action: DaedalusAction,
105
+ timeout_s: Optional[float] = None,
106
+ **kwargs: Any,
107
+ ) -> DaedalusObservation:
108
+ action_dict = self._action_to_dict(action)
109
+ obs_dict, reward, done, info = self._env.step(action_dict)
110
+ self._last_reward = float(reward)
111
+ observation = self._make_observation(
112
+ obs_dict,
113
+ reward=float(reward),
114
+ done=bool(done),
115
+ info=info,
116
+ )
117
+ return observation
118
+
119
+ @property
120
+ def state(self) -> DaedalusState:
121
+ active = sum(1 for a in self._env.agents if a.state.active)
122
+ total = len(self._env.agents)
123
+ cumulative_welfare = sum(self._env.welfare_history)
124
+ return DaedalusState(
125
+ episode_id=self._episode_id,
126
+ step_count=self._env.round,
127
+ n_active_agents=active,
128
+ n_total_agents=total,
129
+ cumulative_welfare=cumulative_welfare,
130
+ last_composite_reward=self._last_reward,
131
+ curriculum_stage=self._env.curriculum_stage,
132
+ )
133
+
134
+ # -------------------------------------------------------------------
135
+ # Convenience: legacy tuple-style step for demo / notebook code.
136
+ # NOT part of the OpenEnv contract; kept so existing scripts keep
137
+ # working without rewriting them.
138
+ # -------------------------------------------------------------------
139
+ def step_tuple(self, action: Any):
140
+ if isinstance(action, DaedalusAction):
141
+ action_dict = self._action_to_dict(action)
142
+ else:
143
+ action_dict = action
144
+ return self._env.step(action_dict)
145
+
146
+ # -------------------------------------------------------------------
147
+ # Helpers
148
+ # -------------------------------------------------------------------
149
+ @staticmethod
150
+ def _action_to_dict(action: Any) -> dict:
151
+ if isinstance(action, DaedalusAction):
152
+ return action.model_dump(exclude={"metadata"})
153
+ if isinstance(action, dict):
154
+ return action
155
+ if hasattr(action, "to_dict"):
156
+ return action.to_dict()
157
+ raise TypeError(
158
+ f"DaedalusOpenEnv.step expected DaedalusAction or dict, "
159
+ f"got {type(action).__name__}"
160
+ )
161
+
162
+ def _make_observation(
163
+ self,
164
+ obs_dict: dict,
165
+ *,
166
+ reward: Optional[float],
167
+ done: bool,
168
+ info: Optional[dict] = None,
169
+ ) -> DaedalusObservation:
170
+ # Copy so we don't mutate the caller's dict; add derived keys rubrics need.
171
+ metadata = dict(info or {})
172
+ if "gini_coefficient" in metadata and "fairness" not in metadata:
173
+ metadata["fairness"] = max(0.0, 1.0 - float(metadata["gini_coefficient"]))
174
+ return DaedalusObservation(
175
+ mechanism_config=obs_dict.get("mechanism_config", {}),
176
+ market_outcomes=obs_dict.get("market_outcomes", []),
177
+ population_proxies=obs_dict.get("population_proxies", {}),
178
+ round_number=int(obs_dict.get("round_number", 0)),
179
+ episode_length=int(obs_dict.get("episode_length", 50)),
180
+ curriculum_stage=int(obs_dict.get("curriculum_stage", 0)),
181
+ reward=reward,
182
+ done=done,
183
+ metadata=metadata,
184
+ )
185
+
186
+ def close(self) -> None:
187
+ self._env = None
daedalus/openenv_models.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS OpenEnv-compliant data models.
3
+
4
+ These Pydantic models subclass the official OpenEnv types from
5
+ `openenv.core.env_server.types`, so the environment satisfies the
6
+ hackathon's "Use OpenEnv (latest release). Build on top of the
7
+ framework" requirement properly.
8
+
9
+ `DaedalusObservation.reward` and `.done` are inherited from the OpenEnv
10
+ `Observation` base, so a single object captures everything a Gymnasium-
11
+ style step would normally return as a tuple.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Literal
17
+
18
+ from pydantic import Field
19
+
20
+ from openenv.core.env_server.types import Action, Observation, State
21
+
22
+
23
+ AuctionType = Literal["first_price", "second_price", "vcg"]
24
+ CoalitionPolicy = Literal[
25
+ "allow",
26
+ "restrict",
27
+ "penalize_suspected",
28
+ "penalize_confirmed",
29
+ ]
30
+
31
+
32
+ class DaedalusAction(Action):
33
+ """The mechanism the LLM designer is proposing for the next 5-round window."""
34
+
35
+ auction_type: AuctionType = Field(
36
+ default="second_price",
37
+ description="Pricing rule for clearing the auction.",
38
+ )
39
+ reserve_price: float = Field(
40
+ default=0.10, ge=0.0, le=0.9,
41
+ description="Reserve price below which bids are rejected.",
42
+ )
43
+
44
+ reveal_reserve: bool = Field(default=False)
45
+ reveal_competing_bids: bool = Field(default=False)
46
+ reveal_winner_identity: bool = Field(default=False)
47
+ reveal_clearing_price: bool = Field(default=True)
48
+ reveal_bid_distribution: bool = Field(default=False)
49
+
50
+ shill_penalty: float = Field(default=0.0, ge=0.0, le=3.0)
51
+ withdrawal_penalty: float = Field(default=0.0, ge=0.0, le=3.0)
52
+ collusion_penalty: float = Field(default=0.0, ge=0.0, le=3.0)
53
+
54
+ coalition_policy: CoalitionPolicy = Field(default="allow")
55
+
56
+
57
+ class DaedalusObservation(Observation):
58
+ """
59
+ What the LLM designer agent sees at every step.
60
+
61
+ `reward` and `done` are inherited from the OpenEnv `Observation`
62
+ base, so this single object replaces the Gym-style
63
+ `(obs, reward, done, info)` tuple.
64
+ """
65
+
66
+ mechanism_config: Dict[str, Any] = Field(default_factory=dict)
67
+ market_outcomes: List[Dict[str, Any]] = Field(default_factory=list)
68
+ population_proxies: Dict[str, Any] = Field(default_factory=dict)
69
+ round_number: int = Field(default=0, ge=0)
70
+ episode_length: int = Field(default=50, ge=1)
71
+ curriculum_stage: int = Field(default=0, ge=0)
72
+
73
+
74
+ class DaedalusState(State):
75
+ """Server-side state, exposed via Environment.state for introspection."""
76
+
77
+ n_active_agents: int = Field(default=0, ge=0)
78
+ n_total_agents: int = Field(default=0, ge=0)
79
+ cumulative_welfare: float = Field(default=0.0)
80
+ last_composite_reward: float = Field(default=0.0)
81
+ curriculum_stage: int = Field(default=0, ge=0)
daedalus/rewards.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Reward Functions β€” Multiple independent reward signals.
3
+ Multiplicative composite prevents single-objective gaming.
4
+ """
5
+ import math
6
+ from typing import List, Dict
7
+
8
+
9
+ def compute_welfare_ratio(winner_valuation: float, all_valuations: List[float]) -> float:
10
+ """
11
+ W(t) β€” Social Welfare Ratio
12
+ Total allocated utility / theoretical maximum welfare.
13
+ Are goods going to agents who value them most?
14
+
15
+ Range: [0, 1]
16
+ """
17
+ if not all_valuations:
18
+ return 0.0
19
+ theoretical_max = max(all_valuations)
20
+ if theoretical_max <= 0:
21
+ return 0.0
22
+ return min(winner_valuation / theoretical_max, 1.0)
23
+
24
+
25
+ def compute_gini(values: List[float]) -> float:
26
+ """Compute Gini coefficient of a distribution."""
27
+ if not values:
28
+ return 0.0
29
+ sorted_vals = sorted(values)
30
+ n = len(sorted_vals)
31
+ total = sum(sorted_vals)
32
+ if total <= 0:
33
+ return 0.0
34
+
35
+ numerator = 0.0
36
+ for i, v in enumerate(sorted_vals):
37
+ numerator += (2 * (i + 1) - n - 1) * v
38
+ return numerator / (n * total)
39
+
40
+
41
+ def compute_fairness(payment_distribution: List[float]) -> float:
42
+ """
43
+ F(t) β€” Fairness Score
44
+ 1 - Gini coefficient of payment/surplus distribution.
45
+ Prevents one agent from capturing all surplus.
46
+
47
+ Range: [0, 1]
48
+ """
49
+ gini = compute_gini(payment_distribution)
50
+ return max(0.0, 1.0 - gini)
51
+
52
+
53
+ def compute_participation_rate(active_agents: int, total_agents: int) -> float:
54
+ """
55
+ P(t) β€” Participation Rate
56
+ Fraction of eligible agents who submitted bids.
57
+ Near zero drives composite reward to zero regardless of other terms.
58
+
59
+ Range: [0, 1]
60
+ """
61
+ if total_agents <= 0:
62
+ return 0.0
63
+ return active_agents / total_agents
64
+
65
+
66
+ def compute_stability(welfare_history: List[float], window: int = 5) -> float:
67
+ """
68
+ S(t) β€” Stability Bonus
69
+ 1 - normalized standard deviation of welfare over last N rounds.
70
+ Rewards consistent performance over high-variance mechanisms.
71
+
72
+ Range: [0, 1]
73
+ """
74
+ if len(welfare_history) < window:
75
+ return 1.0 # Give benefit of the doubt early on
76
+
77
+ recent = welfare_history[-window:]
78
+ mean = sum(recent) / len(recent)
79
+ variance = sum((x - mean) ** 2 for x in recent) / len(recent)
80
+ std_dev = math.sqrt(variance)
81
+ return max(0.0, 1.0 - std_dev * 3) # Normalize
82
+
83
+
84
+ def compute_collusion_signal(bids: List[float], wins: List[int]) -> float:
85
+ """
86
+ Detect collusion from bid correlation and winner rotation patterns.
87
+ Returns a signal in [0, 1] where higher = more suspicious.
88
+ """
89
+ if len(bids) < 2:
90
+ return 0.0
91
+
92
+ # Check bid variance β€” cartels suppress bid variance
93
+ mean_bid = sum(bids) / len(bids)
94
+ if mean_bid <= 0:
95
+ return 0.0
96
+ variance = sum((b - mean_bid) ** 2 for b in bids) / len(bids)
97
+ # Low variance relative to mean is suspicious
98
+ cv = math.sqrt(variance) / mean_bid if mean_bid > 0 else 0
99
+ variance_signal = max(0.0, 1.0 - cv * 3)
100
+
101
+ # Check winner rotation β€” unnaturally even distribution
102
+ if wins and max(wins) > 0:
103
+ win_entropy = 0.0
104
+ total_wins = sum(wins)
105
+ if total_wins > 0:
106
+ for w in wins:
107
+ if w > 0:
108
+ p = w / total_wins
109
+ win_entropy -= p * math.log2(p)
110
+ # High entropy = even rotation = suspicious
111
+ max_entropy = math.log2(len(wins)) if len(wins) > 1 else 1
112
+ rotation_signal = win_entropy / max_entropy if max_entropy > 0 else 0
113
+ else:
114
+ rotation_signal = 0.0
115
+ else:
116
+ rotation_signal = 0.0
117
+
118
+ return (variance_signal + rotation_signal) / 2
119
+
120
+
121
+ def compute_composite_reward(
122
+ welfare: float,
123
+ fairness: float,
124
+ participation: float,
125
+ stability: float,
126
+ exploration_bonus: float = 1.0,
127
+ collusion_penalty: float = 1.0,
128
+ ) -> float:
129
+ """
130
+ R(t) = W(t) Γ— F(t) Γ— P(t) Γ— S(t) Γ— E(t) Γ— anti_collusion
131
+
132
+ Multiplicative structure: all terms must be jointly positive.
133
+ You cannot sacrifice one objective to maximize another.
134
+
135
+ Returns: float in [0, ~1.2] (exploration can push slightly above 1)
136
+ """
137
+ reward = welfare * fairness * participation * stability
138
+ reward *= exploration_bonus
139
+ reward *= collusion_penalty
140
+ return max(0.0, reward)
141
+
142
+
143
+ # ── Reward Function Registry (for TRL) ──────────────
144
+ def reward_welfare(output: str, env_state: dict, **kwargs) -> float:
145
+ """Standalone welfare reward for TRL multi-reward setup."""
146
+ return env_state.get("welfare_ratio", 0.0)
147
+
148
+
149
+ def reward_fairness(output: str, env_state: dict, **kwargs) -> float:
150
+ """Standalone fairness reward for TRL."""
151
+ return 1.0 - env_state.get("gini_coefficient", 0.0)
152
+
153
+
154
+ def reward_participation(output: str, env_state: dict, **kwargs) -> float:
155
+ """Standalone participation reward for TRL."""
156
+ return env_state.get("participation_rate", 1.0)
157
+
158
+
159
+ def reward_stability(output: str, env_state: dict, **kwargs) -> float:
160
+ """Standalone stability reward for TRL."""
161
+ return env_state.get("stability_score", 0.8)
162
+
163
+
164
+ def reward_composite(output: str, env_state: dict, **kwargs) -> float:
165
+ """Composite multiplicative reward for TRL."""
166
+ w = reward_welfare(output, env_state)
167
+ f = reward_fairness(output, env_state)
168
+ p = reward_participation(output, env_state)
169
+ s = reward_stability(output, env_state)
170
+ return w * f * p * s
171
+
172
+
173
+ # Registry for easy import
174
+ REWARD_FUNCTIONS = {
175
+ "welfare": reward_welfare,
176
+ "fairness": reward_fairness,
177
+ "participation": reward_participation,
178
+ "stability": reward_stability,
179
+ "composite": reward_composite,
180
+ }
daedalus/rubrics.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Rubrics β€” OpenEnv-compliant reward rubrics.
3
+ Standardizes the multiplicative composite reward for mechanism design.
4
+ """
5
+ from typing import Any, Dict, Optional
6
+ from openenv.core.env_server.rubric import Rubric, RubricResult
7
+
8
+ from .rewards import (
9
+ compute_welfare_ratio,
10
+ compute_fairness,
11
+ compute_participation_rate,
12
+ compute_stability,
13
+ compute_collusion_signal,
14
+ )
15
+
16
+ class WelfareRubric(Rubric):
17
+ """Measures allocative efficiency."""
18
+ def __init__(self):
19
+ super().__init__(name="welfare", weight=1.0)
20
+
21
+ def evaluate(self, action: Any, observation: Any) -> RubricResult:
22
+ # Note: In our environment, welfare is computed inside the step logic
23
+ # and cached in the observation metadata.
24
+ val = observation.metadata.get("welfare_ratio", 0.0)
25
+ return RubricResult(
26
+ score=float(val),
27
+ reason=f"Social welfare ratio: {val:.3f}",
28
+ metadata={"raw_value": val}
29
+ )
30
+
31
+ class FairnessRubric(Rubric):
32
+ """Measures payment equity (1 - Gini)."""
33
+ def __init__(self):
34
+ super().__init__(name="fairness", weight=1.0)
35
+
36
+ def evaluate(self, action: Any, observation: Any) -> RubricResult:
37
+ val = observation.metadata.get("fairness", 0.0)
38
+ return RubricResult(
39
+ score=float(val),
40
+ reason=f"Payment fairness score: {val:.3f}",
41
+ metadata={"raw_value": val}
42
+ )
43
+
44
+ class ParticipationRubric(Rubric):
45
+ """Measures market liquidity."""
46
+ def __init__(self):
47
+ super().__init__(name="participation", weight=1.0)
48
+
49
+ def evaluate(self, action: Any, observation: Any) -> RubricResult:
50
+ val = observation.metadata.get("participation_rate", 0.0)
51
+ return RubricResult(
52
+ score=float(val),
53
+ reason=f"Market participation rate: {val:.3f}",
54
+ metadata={"raw_value": val}
55
+ )
56
+
57
+ class StabilityRubric(Rubric):
58
+ """Measures consistency over time."""
59
+ def __init__(self):
60
+ super().__init__(name="stability", weight=1.0)
61
+
62
+ def evaluate(self, action: Any, observation: Any) -> RubricResult:
63
+ val = observation.metadata.get("stability_score", 0.0)
64
+ return RubricResult(
65
+ score=float(val),
66
+ reason=f"Economic stability score: {val:.3f}",
67
+ metadata={"raw_value": val}
68
+ )
69
+
70
+ class DaedalusCompositeRubric(Rubric):
71
+ """
72
+ Multiplicative Composite Reward: R = W * F * P * S * CollusionPenalty.
73
+ This is the core DAEDALUS reward logic wrapped for OpenEnv.
74
+ """
75
+ def __init__(self):
76
+ super().__init__(name="daedalus_composite", weight=1.0)
77
+ self.welfare = WelfareRubric()
78
+ self.fairness = FairnessRubric()
79
+ self.participation = ParticipationRubric()
80
+ self.stability = StabilityRubric()
81
+
82
+ def evaluate(self, action: Any, observation: Any) -> RubricResult:
83
+ w = self.welfare.evaluate(action, observation).score
84
+ f = self.fairness.evaluate(action, observation).score
85
+ p = self.participation.evaluate(action, observation).score
86
+ s = self.stability.evaluate(action, observation).score
87
+
88
+ # Pull collusion multiplier from info
89
+ collusion_signal = observation.metadata.get("collusion_signal", 0.0)
90
+ collusion_mult = max(0.5, 1.0 - collusion_signal * 0.5) if collusion_signal > 0.5 else 1.0
91
+
92
+ composite = w * f * p * s * collusion_mult
93
+
94
+ return RubricResult(
95
+ score=float(composite),
96
+ reason=f"Multiplicative Reward: {composite:.4f}",
97
+ metadata={
98
+ "welfare": w,
99
+ "fairness": f,
100
+ "participation": p,
101
+ "stability": s,
102
+ "collusion_penalty": collusion_mult
103
+ }
104
+ )
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unsloth β€” must come after torch (Dockerfile installs torch first)
2
+ unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git
3
+ huggingface_hub>=0.26.0
4
+ hf_transfer>=0.1.8
5
+ transformers>=4.45.0
6
+ datasets>=3.0.0
7
+ accelerate>=0.34.0
8
+ peft>=0.13.0
9
+ trl>=0.12.0
10
+ bitsandbytes>=0.44.0
11
+ sentencepiece>=0.2.0
12
+ protobuf>=4.25.0
13
+ python-dotenv>=1.0.0
train_hf.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAEDALUS Training v5 β€” Unsloth + Qwen2.5-0.5B + GRPO (single-adapter pipeline)
3
+ ================================================================================
4
+
5
+ Changes vs v4:
6
+ * reward_fairness now correctly computes 1 βˆ’ gini_coefficient (never stability).
7
+ * reward_stability added as a separate fifth reward signal.
8
+ * GRPO reward_funcs updated to [format, welfare, fairness, stability, composite].
9
+ * Training scales up automatically when A100-class GPU is detected:
10
+ - 'long' mode doubles SFT/GRPO counts and uses larger batches.
11
+ - 'full' mode is the maximum A100 training budget.
12
+ * HUB_REPO defaults to HUB_MODEL_ID env var (set by deploy script).
13
+ * format_prompt includes Curriculum Stage line to match server.py inference.
14
+ * Sentinel: [grpo v5] five-reward single-adapter
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import gc
21
+ import json
22
+ import os
23
+ import random
24
+ import sys
25
+ import time
26
+ import traceback
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from dotenv import load_dotenv
30
+
31
+ load_dotenv()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Auth
36
+ # ---------------------------------------------------------------------------
37
+ HF_TOKEN = os.environ.get("HF_TOKEN")
38
+ SPACE_ID = os.environ.get("SPACE_ID")
39
+ if HF_TOKEN:
40
+ try:
41
+ from huggingface_hub import login
42
+ login(token=HF_TOKEN, add_to_git_credential=False)
43
+ print("[auth] logged into Hugging Face from HF_TOKEN env var")
44
+ except Exception as e:
45
+ print(f"[auth] login failed: {e}")
46
+ else:
47
+ print("[auth] WARNING: HF_TOKEN env var is not set; push_to_hub will fail")
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Unsloth import (must happen before TRL so unsloth can patch).
52
+ # ---------------------------------------------------------------------------
53
+ from unsloth import FastLanguageModel # noqa: E402
54
+
55
+ import torch # noqa: E402
56
+
57
+ # Ampere+ (A100/A10G/3090+) supports bfloat16; T4 (Turing) only fp16.
58
+ USE_BF16 = bool(torch.cuda.is_available() and torch.cuda.is_bf16_supported())
59
+ USE_FP16 = bool(torch.cuda.is_available() and not USE_BF16)
60
+ print(f"[precision] bf16={USE_BF16} fp16={USE_FP16} cuda={torch.cuda.is_available()}")
61
+
62
+ if torch.cuda.is_available():
63
+ gpu_name = torch.cuda.get_device_name(0)
64
+ gpu_mem_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
65
+ print(f"[gpu] {gpu_name} VRAM={gpu_mem_gb:.0f} GB")
66
+ else:
67
+ gpu_name = "CPU"
68
+ gpu_mem_gb = 0.0
69
+
70
+ # Detect A100-class GPU (>=40 GB VRAM) for automatic scaling.
71
+ IS_HIGH_VRAM = gpu_mem_gb >= 40.0
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Make daedalus/ importable regardless of cwd.
76
+ # ---------------------------------------------------------------------------
77
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
78
+ for candidate in (SCRIPT_DIR, "/workspace", "/app"):
79
+ if os.path.isdir(os.path.join(candidate, "daedalus")) and candidate not in sys.path:
80
+ sys.path.insert(0, candidate)
81
+
82
+ # Training uses the legacy env directly β€” no openenv-core dep on the Space.
83
+ from daedalus.env import DaedalusEnvironment # noqa: E402
84
+
85
+
86
+ def _to_dict(obs: Any) -> dict:
87
+ if obs is None:
88
+ return {}
89
+ if isinstance(obs, dict):
90
+ return obs
91
+ if hasattr(obs, "model_dump"):
92
+ return obs.model_dump()
93
+ if hasattr(obs, "to_dict"):
94
+ return obs.to_dict()
95
+ return dict(obs)
96
+
97
+
98
+ def make_env(stage: int = 0) -> DaedalusEnvironment:
99
+ return DaedalusEnvironment(curriculum_stage=stage)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Config
104
+ # ---------------------------------------------------------------------------
105
+ TRAIN_MODE = os.environ.get("TRAIN_MODE", "short").lower()
106
+
107
+ MODEL_ID = os.environ.get("BASE_MODEL", "unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit")
108
+ HUB_REPO = os.environ.get("HUB_MODEL_ID", "Laksh718/daedalus-designer")
109
+ PUSH_MERGED = os.environ.get("PUSH_MERGED", "1") not in ("0", "false", "False")
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Training scale: auto-boost when a high-VRAM GPU is detected.
113
+ # ---------------------------------------------------------------------------
114
+ if TRAIN_MODE == "full":
115
+ # Maximum quality β€” designed for A100 80 GB. ~60-90 min.
116
+ N_SFT_EXAMPLES = 2000
117
+ SFT_EPOCHS = 3
118
+ N_GRPO_PROMPTS = 1200
119
+ GRPO_STEPS = 500
120
+ SFT_BATCH = 16 if IS_HIGH_VRAM else 8
121
+ GRPO_BATCH = 8 if IS_HIGH_VRAM else 4
122
+ GRPO_GENERATIONS = 8 if IS_HIGH_VRAM else 4
123
+ elif TRAIN_MODE == "long":
124
+ # Good quality run β€” A100 ~25-40 min, T4 ~35-50 min.
125
+ N_SFT_EXAMPLES = 800 if IS_HIGH_VRAM else 400
126
+ SFT_EPOCHS = 2
127
+ N_GRPO_PROMPTS = 500 if IS_HIGH_VRAM else 240
128
+ GRPO_STEPS = 300 if IS_HIGH_VRAM else 160
129
+ SFT_BATCH = 12 if IS_HIGH_VRAM else 8
130
+ GRPO_BATCH = 6 if IS_HIGH_VRAM else 4
131
+ GRPO_GENERATIONS = 8 if IS_HIGH_VRAM else 4
132
+ elif TRAIN_MODE == "smoke":
133
+ # CI smoke test β€” exercises every code path in ~3-5 min.
134
+ N_SFT_EXAMPLES = 24
135
+ SFT_EPOCHS = 1
136
+ N_GRPO_PROMPTS = 16
137
+ GRPO_STEPS = 4
138
+ SFT_BATCH = 4
139
+ GRPO_BATCH = 2
140
+ GRPO_GENERATIONS = 4
141
+ else:
142
+ # "short" β€” quick but useful run (~10-15 min on A100, ~15-25 min on T4).
143
+ N_SFT_EXAMPLES = 320 if IS_HIGH_VRAM else 160
144
+ SFT_EPOCHS = 1
145
+ N_GRPO_PROMPTS = 200 if IS_HIGH_VRAM else 96
146
+ GRPO_STEPS = 120 if IS_HIGH_VRAM else 60
147
+ SFT_BATCH = 10 if IS_HIGH_VRAM else 8
148
+ GRPO_BATCH = 5 if IS_HIGH_VRAM else 4
149
+ GRPO_GENERATIONS = 8 if IS_HIGH_VRAM else 4
150
+
151
+ OUT_DIR = "./daedalus-lora"
152
+ MAX_SEQ_LEN = 1024
153
+
154
+ REQUIRED_KEYS = {
155
+ "auction_type", "reserve_price", "reveal_reserve",
156
+ "reveal_competing_bids", "reveal_winner_identity",
157
+ "reveal_clearing_price", "reveal_bid_distribution",
158
+ "shill_penalty", "withdrawal_penalty", "collusion_penalty",
159
+ "coalition_policy",
160
+ }
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Prompt β€” MUST stay identical to server.py::_build_prompt
165
+ # ---------------------------------------------------------------------------
166
+ def format_prompt(obs: dict) -> str:
167
+ lines = [
168
+ "You are a mechanism designer for a market auction system.",
169
+ "Analyze the current market state and design an optimal mechanism.",
170
+ "",
171
+ f"Round: {obs.get('round_number', 0)} / {obs.get('episode_length', 50)}",
172
+ f"Curriculum Stage: {obs.get('curriculum_stage', 0)}",
173
+ "",
174
+ "Your goal is to maximize the composite reward R = W x F x P x S",
175
+ ]
176
+ outcomes = obs.get("market_outcomes", [])
177
+ if outcomes:
178
+ lines.append("Recent Market Outcomes:")
179
+ for o in outcomes[-5:]:
180
+ lines.append(
181
+ f" W={o.get('welfare_ratio', 0):.3f} "
182
+ f"F={1 - o.get('gini_coefficient', 0):.3f} "
183
+ f"P={o.get('participation_rate', 1):.3f} "
184
+ f"S={o.get('stability_score', 1):.3f} "
185
+ f"R={o.get('composite_reward', 0):.3f}"
186
+ )
187
+ proxies = obs.get("population_proxies", {})
188
+ if proxies:
189
+ lines.extend([
190
+ "",
191
+ "Population Signals:",
192
+ f" Active Bidders: {proxies.get('active_count', 8)} / {proxies.get('total_agents', 8)}",
193
+ f" Bid Correlation (collusion proxy): {proxies.get('bid_correlation', 0):.3f}",
194
+ f" Winner Rotation Entropy: {proxies.get('rotation_entropy', 1):.3f}",
195
+ f" Dropout Rate: {proxies.get('dropout_rate', 0):.3f}",
196
+ ])
197
+ lines.extend([
198
+ "",
199
+ "Respond with ONLY a JSON mechanism configuration with these exact keys:",
200
+ " auction_type: \"first_price\" | \"second_price\" | \"vcg\"",
201
+ " reserve_price: float [0.0, 0.9]",
202
+ " reveal_reserve: bool",
203
+ " reveal_competing_bids: bool",
204
+ " reveal_winner_identity: bool",
205
+ " reveal_clearing_price: bool",
206
+ " reveal_bid_distribution: bool",
207
+ " shill_penalty: float [0.0, 3.0]",
208
+ " withdrawal_penalty: float [0.0, 3.0]",
209
+ " collusion_penalty: float [0.0, 3.0]",
210
+ " coalition_policy: \"allow\" | \"restrict\" | \"penalize_suspected\" | \"penalize_confirmed\"",
211
+ "",
212
+ "Output strictly a single JSON object, no commentary.",
213
+ ])
214
+ return "\n".join(lines)
215
+
216
+
217
+ def random_valid_mechanism() -> dict:
218
+ return {
219
+ "auction_type": random.choice(["first_price", "second_price", "vcg"]),
220
+ "reserve_price": round(random.uniform(0.05, 0.5), 3),
221
+ "reveal_reserve": random.choice([True, False]),
222
+ "reveal_competing_bids": random.random() < 0.3,
223
+ "reveal_winner_identity": random.choice([True, False]),
224
+ "reveal_clearing_price": random.random() < 0.7,
225
+ "reveal_bid_distribution": random.random() < 0.3,
226
+ "shill_penalty": round(random.uniform(0.0, 2.0), 3),
227
+ "withdrawal_penalty": round(random.uniform(0.0, 1.0), 3),
228
+ "collusion_penalty": round(random.uniform(0.0, 2.0), 3),
229
+ "coalition_policy": random.choice(
230
+ ["allow", "restrict", "penalize_suspected", "penalize_confirmed"]
231
+ ),
232
+ }
233
+
234
+
235
+ def generate_sft_examples(n: int) -> List[Dict[str, Any]]:
236
+ pairs: List[Dict[str, Any]] = []
237
+ for stage in range(5):
238
+ n_stage = max(1, n // 5)
239
+ env = make_env(stage)
240
+ while len(pairs) < (stage + 1) * n_stage:
241
+ obs_dict = _to_dict(env.reset())
242
+ for _ in range(3):
243
+ mech = random_valid_mechanism()
244
+ pairs.append({
245
+ "messages": [
246
+ {"role": "user", "content": format_prompt(obs_dict)},
247
+ {"role": "assistant", "content": json.dumps(mech)},
248
+ ]
249
+ })
250
+ obs_dict, _, done, _ = env.step(mech)
251
+ obs_dict = _to_dict(obs_dict)
252
+ if done:
253
+ break
254
+ return pairs[:n]
255
+
256
+
257
+ def generate_grpo_prompts(n: int) -> List[Dict[str, str]]:
258
+ prompts: List[Dict[str, str]] = []
259
+ for stage in range(5):
260
+ n_stage = max(1, n // 5)
261
+ env = make_env(stage)
262
+ while len(prompts) < (stage + 1) * n_stage:
263
+ obs_dict = _to_dict(env.reset())
264
+ prompts.append({"prompt": format_prompt(obs_dict)})
265
+ for _ in range(3):
266
+ obs_dict, _, done, _ = env.step(random_valid_mechanism())
267
+ prompts.append({"prompt": format_prompt(_to_dict(obs_dict))})
268
+ if done:
269
+ break
270
+ return prompts[:n]
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Reward functions
275
+ # ---------------------------------------------------------------------------
276
+ _reward_env: Optional[DaedalusEnvironment] = None
277
+
278
+
279
+ def _get_reward_env() -> DaedalusEnvironment:
280
+ global _reward_env
281
+ if _reward_env is None:
282
+ _reward_env = make_env(stage=0)
283
+ return _reward_env
284
+
285
+
286
+ def _completion_text(c: Any) -> str:
287
+ """Normalize a TRL completion to plain string regardless of its format."""
288
+ if isinstance(c, str):
289
+ return c
290
+ if isinstance(c, list) and c:
291
+ last = c[-1]
292
+ if isinstance(last, dict):
293
+ return str(last.get("content", ""))
294
+ if isinstance(c, dict):
295
+ return str(c.get("content", c))
296
+ return str(c)
297
+
298
+
299
+ @functools.lru_cache(maxsize=4096)
300
+ def _get_env_outcome(text: str) -> Optional[dict]:
301
+ """Run one env step for the JSON inside `text`. Cached to avoid re-simulation."""
302
+ j_start = text.find("{")
303
+ j_end = text.rfind("}") + 1
304
+ if j_start < 0 or j_end <= j_start:
305
+ return None
306
+ try:
307
+ mech = json.loads(text[j_start:j_end])
308
+ if not isinstance(mech, dict):
309
+ return None
310
+ env = _get_reward_env()
311
+ env.reset()
312
+ obs_dict, reward, done, info = env.step(mech)
313
+ return {
314
+ "obs": _to_dict(obs_dict),
315
+ "reward": float(reward),
316
+ "done": bool(done),
317
+ "info": dict(info or {}),
318
+ }
319
+ except Exception:
320
+ return None
321
+
322
+
323
+ def reward_format(completions=None, **kwargs) -> List[float]:
324
+ """Schema coverage: 0.5 + 0.5*coverage ∈ [βˆ’1, 1]."""
325
+ rewards = []
326
+ for raw in (completions or []):
327
+ content = _completion_text(raw)
328
+ j_start = content.find("{")
329
+ j_end = content.rfind("}") + 1
330
+ if j_start < 0 or j_end <= j_start:
331
+ rewards.append(-1.0)
332
+ continue
333
+ try:
334
+ mech = json.loads(content[j_start:j_end])
335
+ if not isinstance(mech, dict):
336
+ rewards.append(-0.5)
337
+ continue
338
+ coverage = len(set(mech.keys()) & REQUIRED_KEYS) / len(REQUIRED_KEYS)
339
+ rewards.append(0.5 + 0.5 * coverage)
340
+ except Exception:
341
+ rewards.append(-0.5)
342
+ return rewards
343
+
344
+
345
+ def reward_welfare(completions=None, **kwargs) -> List[float]:
346
+ """Social welfare ratio W ∈ [0, 1]."""
347
+ out = []
348
+ for raw in (completions or []):
349
+ outcome = _get_env_outcome(_completion_text(raw))
350
+ if outcome:
351
+ out.append(float(outcome["info"].get("welfare_ratio", 0.0)))
352
+ else:
353
+ out.append(0.0)
354
+ return out
355
+
356
+
357
+ def reward_fairness(completions=None, **kwargs) -> List[float]:
358
+ """Fairness = 1 βˆ’ Gini(surplus) ∈ [0, 1].
359
+ Always computed from gini_coefficient β€” never reads stability_score.
360
+ """
361
+ out = []
362
+ for raw in (completions or []):
363
+ outcome = _get_env_outcome(_completion_text(raw))
364
+ if outcome:
365
+ info = outcome["info"]
366
+ gini = float(info.get("gini_coefficient", 0.0))
367
+ out.append(max(0.0, 1.0 - gini))
368
+ else:
369
+ out.append(0.0)
370
+ return out
371
+
372
+
373
+ def reward_stability(completions=None, **kwargs) -> List[float]:
374
+ """Stability = 1 βˆ’ 3Β·Οƒ(recent welfare) ∈ [0, 1]."""
375
+ out = []
376
+ for raw in (completions or []):
377
+ outcome = _get_env_outcome(_completion_text(raw))
378
+ if outcome:
379
+ out.append(float(outcome["info"].get("stability_score", 1.0)))
380
+ else:
381
+ out.append(1.0) # Neutral default β€” don't punish parse failures twice
382
+ return out
383
+
384
+
385
+ def reward_composite(completions=None, **kwargs) -> List[float]:
386
+ """Full composite R = W Γ— F Γ— P Γ— S Γ— anti_collusion ∈ [0, 1]."""
387
+ out = []
388
+ for raw in (completions or []):
389
+ outcome = _get_env_outcome(_completion_text(raw))
390
+ if outcome:
391
+ out.append(float(outcome.get("reward", 0.0)))
392
+ else:
393
+ out.append(0.0)
394
+ return out
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Model + LoRA
399
+ # ---------------------------------------------------------------------------
400
+ def build_model_and_tokenizer():
401
+ print("[model] loading base via Unsloth (pre-quantized 4-bit)...")
402
+ model, tokenizer = FastLanguageModel.from_pretrained(
403
+ model_name=MODEL_ID,
404
+ max_seq_length=MAX_SEQ_LEN,
405
+ load_in_4bit=True,
406
+ dtype=None, # Unsloth picks bf16/fp16 automatically
407
+ )
408
+ print("[model] attaching LoRA (r=16, all attn + MLP)...")
409
+ model = FastLanguageModel.get_peft_model(
410
+ model,
411
+ r=16,
412
+ target_modules=[
413
+ "q_proj", "k_proj", "v_proj", "o_proj",
414
+ "gate_proj", "up_proj", "down_proj",
415
+ ],
416
+ lora_alpha=32,
417
+ lora_dropout=0, # Must be 0 for Unsloth's fast kernel path
418
+ bias="none",
419
+ use_gradient_checkpointing="unsloth",
420
+ random_state=42,
421
+ )
422
+ return model, tokenizer
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Phase 1: SFT β€” teach the JSON schema
427
+ # ---------------------------------------------------------------------------
428
+ def run_sft(model, tokenizer):
429
+ from datasets import Dataset
430
+ from trl import SFTConfig, SFTTrainer
431
+
432
+ print(f"[sft] generating {N_SFT_EXAMPLES} synthetic (prompt, mechanism) pairs ...")
433
+ dataset = Dataset.from_list(generate_sft_examples(N_SFT_EXAMPLES))
434
+
435
+ cfg = SFTConfig(
436
+ output_dir=OUT_DIR + "-sft",
437
+ num_train_epochs=SFT_EPOCHS,
438
+ per_device_train_batch_size=SFT_BATCH,
439
+ gradient_accumulation_steps=2,
440
+ learning_rate=2e-4,
441
+ lr_scheduler_type="cosine",
442
+ warmup_steps=max(5, N_SFT_EXAMPLES // 20),
443
+ logging_steps=5,
444
+ save_strategy="no",
445
+ bf16=USE_BF16,
446
+ fp16=USE_FP16,
447
+ max_seq_length=MAX_SEQ_LEN,
448
+ report_to="none",
449
+ seed=42,
450
+ dataloader_pin_memory=False,
451
+ )
452
+
453
+ trainer = SFTTrainer(
454
+ model=model,
455
+ processing_class=tokenizer,
456
+ train_dataset=dataset,
457
+ args=cfg,
458
+ )
459
+ print(f"[sft] training (batch={SFT_BATCH}, epochs={SFT_EPOCHS}) ...")
460
+ trainer.train()
461
+
462
+ del trainer
463
+ gc.collect()
464
+ torch.cuda.empty_cache()
465
+ return model
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Phase 2: GRPO β€” reinforce with 5 reward signals on the SAME LoRA
470
+ # ---------------------------------------------------------------------------
471
+ def run_grpo(model, tokenizer):
472
+ from datasets import Dataset
473
+ from trl import GRPOConfig, GRPOTrainer
474
+
475
+ print("[grpo v5] five-reward single-adapter on base + push merged")
476
+ print(f"[grpo] generating {N_GRPO_PROMPTS} prompts ...")
477
+ dataset = Dataset.from_list(generate_grpo_prompts(N_GRPO_PROMPTS))
478
+
479
+ grpo_warmup = max(1, GRPO_STEPS // 10)
480
+ cfg = GRPOConfig(
481
+ output_dir=OUT_DIR,
482
+ max_steps=GRPO_STEPS,
483
+ per_device_train_batch_size=GRPO_BATCH,
484
+ gradient_accumulation_steps=2,
485
+ num_generations=GRPO_GENERATIONS,
486
+ max_completion_length=400, # Full mechanism JSON ~100-150 tokens; 400 is safe
487
+ learning_rate=5e-6,
488
+ lr_scheduler_type="cosine",
489
+ warmup_steps=grpo_warmup,
490
+ logging_steps=2,
491
+ save_steps=max(10, GRPO_STEPS // 10),
492
+ save_total_limit=2,
493
+ bf16=USE_BF16,
494
+ fp16=USE_FP16,
495
+ report_to="none",
496
+ seed=42,
497
+ dataloader_pin_memory=False,
498
+ )
499
+
500
+ trainer = GRPOTrainer(
501
+ model=model,
502
+ processing_class=tokenizer,
503
+ reward_funcs=[
504
+ reward_format,
505
+ reward_welfare,
506
+ reward_fairness,
507
+ reward_stability,
508
+ reward_composite,
509
+ ],
510
+ args=cfg,
511
+ train_dataset=dataset,
512
+ )
513
+ print(
514
+ f"[grpo] training "
515
+ f"(batch={GRPO_BATCH}, generations={GRPO_GENERATIONS}, steps={GRPO_STEPS}) ..."
516
+ )
517
+ trainer.train()
518
+
519
+ # Save training history for make_plots.py
520
+ os.makedirs(OUT_DIR, exist_ok=True)
521
+ history_path = os.path.join(OUT_DIR, "training_history.json")
522
+ with open(history_path, "w") as f:
523
+ json.dump({"history": trainer.state.log_history}, f, indent=2)
524
+ print(f"[grpo] saved log history β†’ {history_path}")
525
+
526
+ # Also save a copy in /app root so it's easy to find in Space logs
527
+ try:
528
+ import shutil
529
+ shutil.copy(history_path, "./training_history.json")
530
+ except Exception:
531
+ pass
532
+
533
+ return trainer.model
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Push merged 16-bit model to Hub
538
+ # ---------------------------------------------------------------------------
539
+ def push_to_hub(model, tokenizer):
540
+ if not HF_TOKEN:
541
+ print("[push] skipped β€” HF_TOKEN not set")
542
+ return
543
+
544
+ if PUSH_MERGED:
545
+ print(f"[push] merging LoRA and uploading full 16-bit model β†’ {HUB_REPO} ...")
546
+ try:
547
+ model.push_to_hub_merged(
548
+ HUB_REPO,
549
+ tokenizer,
550
+ save_method="merged_16bit",
551
+ token=HF_TOKEN,
552
+ private=False,
553
+ )
554
+ print(f"[done] merged model live at https://huggingface.co/{HUB_REPO}")
555
+ return
556
+ except Exception as e:
557
+ traceback.print_exc()
558
+ print(f"[push] merged upload failed ({e}); falling back to LoRA-only push")
559
+
560
+ print(f"[push] uploading LoRA adapter β†’ {HUB_REPO} ...")
561
+ model.push_to_hub(HUB_REPO, token=HF_TOKEN, private=False)
562
+ tokenizer.push_to_hub(HUB_REPO, token=HF_TOKEN, private=False)
563
+ print(f"[done] adapter live at https://huggingface.co/{HUB_REPO}")
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # Space auto-pause (stops billing when training completes)
568
+ # ---------------------------------------------------------------------------
569
+ def pause_self() -> None:
570
+ if not SPACE_ID or not HF_TOKEN:
571
+ print("[pause] not in a Space (or no token) β€” skipping")
572
+ return
573
+ try:
574
+ from huggingface_hub import HfApi
575
+ HfApi(token=HF_TOKEN).pause_space(SPACE_ID)
576
+ print(f"[pause] Space {SPACE_ID} paused")
577
+ except Exception as e:
578
+ print(f"[pause] pause_space failed: {e}")
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Main
583
+ # ---------------------------------------------------------------------------
584
+ def main():
585
+ t0 = time.time()
586
+ print(
587
+ f"[main] TRAIN_MODE={TRAIN_MODE} gpu={gpu_name} "
588
+ f"SFT={N_SFT_EXAMPLES}Γ—{SFT_EPOCHS}ep "
589
+ f"GRPO={GRPO_STEPS} steps Γ— {GRPO_GENERATIONS} gen "
590
+ f"β†’ {HUB_REPO}"
591
+ )
592
+ model, tokenizer = build_model_and_tokenizer()
593
+ model = run_sft(model, tokenizer)
594
+ model = run_grpo(model, tokenizer)
595
+ push_to_hub(model, tokenizer)
596
+ print(f"[done] total wall time: {(time.time() - t0) / 60:.1f} min")
597
+
598
+
599
+ if __name__ == "__main__":
600
+ try:
601
+ main()
602
+ except Exception:
603
+ traceback.print_exc()
604
+ finally:
605
+ pause_self()