deploy v5 (long mode, a100-large)
Browse files- Dockerfile +31 -0
- README.md +35 -4
- daedalus/__init__.py +47 -0
- daedalus/agents.py +266 -0
- daedalus/env.py +327 -0
- daedalus/models.py +163 -0
- daedalus/openenv_client.py +25 -0
- daedalus/openenv_env.py +187 -0
- daedalus/openenv_models.py +81 -0
- daedalus/rewards.py +180 -0
- daedalus/rubrics.py +104 -0
- requirements.txt +13 -0
- train_hf.py +605 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|