File size: 10,927 Bytes
ea3d52f
8b49a15
ea3d52f
3ea90bc
 
8b49a15
3ea90bc
 
6f41ef4
ea3d52f
 
3ea90bc
ea3d52f
 
 
e2ee637
ea3d52f
 
 
 
e2ee637
 
ea3d52f
 
8b49a15
ea3d52f
6f41ef4
8b49a15
 
ea3d52f
 
36863e2
76a7fca
ea3d52f
 
76a7fca
ea3d52f
8b49a15
 
76a7fca
 
 
 
 
 
 
 
 
 
 
 
 
 
3ea90bc
8b49a15
ea3d52f
8b49a15
ea3d52f
 
e2ee637
 
 
 
 
 
 
 
 
 
 
 
 
 
8b49a15
e2ee637
 
 
 
 
8b49a15
e2ee637
 
 
 
 
 
8b49a15
 
 
 
 
 
 
 
 
 
 
 
 
 
ea3d52f
8b49a15
 
 
ea3d52f
 
e2ee637
 
8b49a15
 
ea3d52f
8b49a15
 
ea3d52f
 
 
 
8b49a15
ea3d52f
 
8b49a15
 
 
 
 
 
 
 
 
ea3d52f
 
af939b0
f6b86c0
 
76a7fca
 
ea3d52f
76a7fca
 
ea3d52f
f6b86c0
ea3d52f
 
6b09e6c
 
3ea90bc
ea3d52f
6b09e6c
 
 
 
 
 
 
 
f6b86c0
6b09e6c
 
 
af939b0
6b09e6c
ea3d52f
f6b86c0
 
6b09e6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4095db2
6b09e6c
 
 
af939b0
6b09e6c
 
 
 
 
af939b0
 
6b09e6c
 
 
 
 
 
 
 
 
 
 
 
76a7fca
 
f6b86c0
76a7fca
 
f6b86c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea3d52f
 
 
 
 
 
76a7fca
 
 
 
3ea90bc
 
76a7fca
3ea90bc
 
 
76a7fca
ea3d52f
 
 
 
 
 
76a7fca
3ea90bc
ea3d52f
 
 
 
76a7fca
 
ea3d52f
3ea90bc
ea3d52f
 
76a7fca
3ea90bc
ea3d52f
 
 
 
 
76a7fca
6b09e6c
 
 
 
ea3d52f
 
8b49a15
 
 
3ea90bc
ea3d52f
 
3ea90bc
6b09e6c
 
af939b0
6b09e6c
ea3d52f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""
Evaluation utilities for the DFL Benchmark.

Storage: a private HF dataset repo holds tasks/<task>/Y_test.json and the
leaderboard results.json. Submitted JSON files are evaluated in-memory.

Env vars (HF Space secrets in production):
  HF_TOKEN         β€” read+write token for the private dataset
  HF_DATASET_REPO  β€” defaults to "GT-KOALA/DFL-Bench-Data"
"""

import importlib.util
import io
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd
import requests
from huggingface_hub import HfApi, hf_hub_download, hf_hub_url

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "GT-KOALA/DFL-Bench-Data")
HF_TOKEN = os.environ.get("HF_TOKEN")
RESULTS_FILENAME = "results.json"

TASKS = [
    {"key": "power_scheduling", "label": "Power Scheduling", "icon": "⚑"},
    {"key": "newsvendor",       "label": "Newsvendor",       "icon": "πŸ“¦"},
]
TASK_KEYS = [t["key"] for t in TASKS]
TASK_BY_KEY = {t["key"]: t for t in TASKS}

_api = HfApi(token=HF_TOKEN)


def _y_test_remote_path(task_key: str) -> str:
    return f"tasks/{task_key}/Y_test.json"


def _load_eval_module(task_key: str):
    path = Path(__file__).parent / "tasks" / task_key / "eval.py"
    spec = importlib.util.spec_from_file_location(f"_eval_{task_key}", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


_EVAL_MODULES = {t["key"]: _load_eval_module(t["key"]) for t in TASKS}


# ---------------------------------------------------------------------------
# HF dataset I/O
# ---------------------------------------------------------------------------

def _fetch_text_fresh(filename: str) -> str | None:
    """
    Fetch a file's current contents from the dataset repo, bypassing both the
    local HF cache and the CDN edge cache.

    hf_hub_download(force_download=True) only skips the *local* cache; the
    `main` resolve URL is still served stale by the CDN for a short window
    after an upload. We hit the resolve URL directly with a unique query
    param + no-cache headers so a refresh always reflects the latest commit.
    """
    url = hf_hub_url(repo_id=HF_DATASET_REPO, filename=filename, repo_type="dataset")
    headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
    if HF_TOKEN:
        headers["Authorization"] = f"Bearer {HF_TOKEN}"
    try:
        resp = requests.get(
            url,
            headers=headers,
            params={"cb": int(time.time() * 1000)},  # cache-buster
            timeout=20,
        )
        if resp.status_code == 404:
            return None
        resp.raise_for_status()
        return resp.text
    except requests.RequestException as e:
        print(f"[hf-dataset] failed to fetch {filename} from {HF_DATASET_REPO}: {e}")
        return None


def _upload_bytes(filename: str, data: bytes, commit_message: str):
    """Upload bytes to a path in the private dataset repo."""
    _api.upload_file(
        path_or_fileobj=io.BytesIO(data),
        path_in_repo=filename,
        repo_id=HF_DATASET_REPO,
        repo_type="dataset",
        token=HF_TOKEN,
        commit_message=commit_message,
    )


# ---------------------------------------------------------------------------
# Results persistence
# ---------------------------------------------------------------------------

def load_results() -> list[dict]:
    """Load all evaluation results from the HF dataset repo (always fresh)."""
    text = _fetch_text_fresh(RESULTS_FILENAME)
    if text is None:
        return []
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return []


def save_result(entry: dict):
    """Append a result entry and upload the updated results.json."""
    results = load_results()
    results.append(entry)
    payload = json.dumps(results, indent=2).encode("utf-8")
    _upload_bytes(
        RESULTS_FILENAME,
        payload,
        commit_message=(
            f"Add submission: {entry.get('team', '?')} / "
            f"{entry.get('model', '?')} / {entry.get('task', '?')}"
        ),
    )


_LB_COLUMNS = ["Rank", "Team", "Model", "Regret ↓", "Constraint Viol. ↓"]


def load_results_as_dataframe(task_filter: str | None = None) -> pd.DataFrame:
    """Return results as a sorted DataFrame for the leaderboard, optionally filtered by task."""
    results = load_results()
    if task_filter:
        results = [r for r in results if r.get("task") == task_filter]
    if not results:
        return pd.DataFrame(columns=_LB_COLUMNS)

    df = pd.DataFrame(results)
    # Task loss is the decision-focused metric β€” sort ascending by its mean.
    df = df.sort_values("task_loss_mean", ascending=True).reset_index(drop=True)
    df.index += 1

    def _mean_std(mean_col: str, std_col: str) -> list[str]:
        if std_col in df.columns:
            return [
                f"{m:.4f} Β± {s:.4f}" if pd.notna(s) else f"{m:.4f}"
                for m, s in zip(df[mean_col], df[std_col])
            ]
        return [f"{m:.4f}" for m in df[mean_col]]

    return pd.DataFrame({
        "Rank":               list(df.index),
        "Team":               df["team"].tolist(),
        "Model":              df["model"].tolist(),
        "Regret ↓":           _mean_std("task_loss_mean", "task_loss_std"),
        "Constraint Viol. ↓": _mean_std("constraint_violation_mean", "constraint_violation_std"),
    })


def load_task_figure(task_filter: str | None = None):
    """Scatter of Task Loss mean (y) vs std (x); constraint violators in red."""
    import plotly.graph_objects as go

    results = load_results()
    if task_filter:
        results = [r for r in results if r.get("task") == task_filter]

    fig = go.Figure()
    feasible = [r for r in results if (r.get("constraint_violation_mean") or 0) <= 0]
    violators = [r for r in results if (r.get("constraint_violation_mean") or 0) > 0]

    for group, color, name in (
        (feasible, "#2563eb", "Feasible"),
        (violators, "#dc2626", "Constraint violation"),
    ):
        if not group:
            continue
        fig.add_trace(go.Scatter(
            x=[r.get("task_loss_std", 0.0) for r in group],
            y=[r.get("task_loss_mean", 0.0) for r in group],
            mode="markers",
            name=name,
            marker=dict(color=color, size=11, line=dict(width=0.5, color="#ffffff")),
            customdata=[[r.get("model", "?"), r.get("team", "?")] for r in group],
            hovertemplate=(
                "Method: %{customdata[0]}<br>"
                "Group: %{customdata[1]}<br>"
                "Regret: %{y:.4f}<br>"
                "Std: %{x:.4f}<extra></extra>"
            ),
        ))

    fig.update_layout(
        xaxis_title="Regret Std",
        yaxis_title="Regret Mean",
        margin=dict(l=50, r=20, t=20, b=45),
        height=340,
        legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0),
        plot_bgcolor="#ffffff",
        paper_bgcolor="#ffffff",
        font=dict(size=12),
    )
    fig.update_xaxes(showgrid=True, gridcolor="#f3f4f6", zeroline=False)
    fig.update_yaxes(showgrid=True, gridcolor="#f3f4f6", zeroline=False)
    return fig


def latest_submission_time(task_filter: str | None = None) -> str | None:
    """ISO timestamp of the most recent submission (optionally for one task), or None."""
    results = load_results()
    if task_filter:
        results = [r for r in results if r.get("task") == task_filter]
    times = [r.get("submitted_at") for r in results if r.get("submitted_at")]
    return max(times) if times else None


def format_time_ago(iso_str: str | None) -> str:
    """Humanize an ISO timestamp as 'N hours ago' / 'N days ago' etc."""
    if not iso_str:
        return "no submissions yet"
    try:
        dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return "unknown"
    delta = datetime.now(timezone.utc) - dt
    seconds = int(delta.total_seconds())
    if seconds < 60:
        return "just now"
    if seconds < 3600:
        m = seconds // 60
        return f"{m} minute{'s' if m != 1 else ''} ago"
    if seconds < 86400:
        h = seconds // 3600
        return f"{h} hour{'s' if h != 1 else ''} ago"
    d = seconds // 86400
    return f"{d} day{'s' if d != 1 else ''} ago"


# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------

def evaluate_predictions(submission_path: str, task_key: str) -> dict:
    """Run the task's evaluator against Y_test.json from the data repo."""
    if task_key not in _EVAL_MODULES:
        raise ValueError(f"Unknown task: {task_key}")
    y_test_path = hf_hub_download(
        repo_id=HF_DATASET_REPO,
        filename=_y_test_remote_path(task_key),
        repo_type="dataset",
        token=HF_TOKEN,
    )
    return _EVAL_MODULES[task_key].evaluate(submission_path, y_test_path)


# ---------------------------------------------------------------------------
# Full submission handler
# ---------------------------------------------------------------------------

def handle_submission(team_name: str, model_name: str, task_key: str, file_obj) -> str:
    """Validate β†’ evaluate JSON β†’ persist metrics β†’ return status."""
    if not team_name or not team_name.strip():
        return "❌ Please enter a team name."
    if not model_name or not model_name.strip():
        return "❌ Please enter a model name."
    if task_key not in TASK_KEYS:
        return f"❌ Unknown task: {task_key}"
    if file_obj is None:
        return "❌ Please upload a submission JSON file."

    try:
        scores = evaluate_predictions(file_obj, task_key)
    except Exception as e:
        return f"❌ Evaluation error: {e}"

    entry = {
        "team": team_name.strip(),
        "model": model_name.strip(),
        "task": task_key,
        "task_loss_mean": scores["task_loss_mean"],
        "task_loss_std": scores["task_loss_std"],
        "constraint_violation_mean": scores["constraint_violation_mean"],
        "constraint_violation_std": scores["constraint_violation_std"],
        "submitted_at": datetime.now(timezone.utc).isoformat(),
    }
    try:
        save_result(entry)
    except Exception as e:
        return f"❌ Could not persist result: {e}"

    return (
        "βœ… **Submission evaluated successfully!**\n\n"
        "| Metric | Mean Β± Std |\n"
        "|--------|------------|\n"
        f"| **Regret** | **{scores['task_loss_mean']:.4f} Β± {scores['task_loss_std']:.4f}** |\n"
        f"| Constraint Violation | {scores['constraint_violation_mean']:.4f} Β± {scores['constraint_violation_std']:.4f} |\n"
    )