File size: 23,524 Bytes
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f147f0
4b9a7e1
 
 
3f147f0
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8ac0ed
 
 
 
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f147f0
 
 
4b9a7e1
 
3f147f0
 
 
 
 
 
 
4b9a7e1
 
3f147f0
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
951b106
 
 
 
 
 
 
 
 
 
4b9a7e1
3f147f0
 
 
 
 
 
 
 
4b9a7e1
 
 
 
3f147f0
 
 
 
 
4b9a7e1
 
 
 
 
 
5f76b5d
 
 
 
4b9a7e1
 
 
 
 
3f147f0
 
 
 
4b9a7e1
 
 
 
 
f8ac0ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
3f147f0
4b9a7e1
 
3f147f0
 
f8ac0ed
 
 
4b9a7e1
3f147f0
 
4b9a7e1
 
 
 
 
 
3f147f0
 
 
4b9a7e1
3f147f0
 
 
4b9a7e1
 
 
 
 
 
 
 
 
e192b3e
 
4b9a7e1
 
 
 
 
 
 
 
 
3f147f0
4b9a7e1
 
 
eb7eefb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b9a7e1
3f147f0
4b9a7e1
 
 
 
 
 
 
 
 
 
 
3f147f0
 
4b9a7e1
 
 
eb7eefb
 
 
 
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
eb7eefb
 
 
 
3880783
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f147f0
 
 
 
 
4b9a7e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f147f0
4b9a7e1
 
 
 
 
 
3f147f0
 
 
4b9a7e1
 
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
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# -*- coding: utf-8 -*-
"""Open Discovery Challenge - submission intake and leaderboard.



Deliberately thin. Nothing here computes a score.



Scoring needs docking, docking needs a GPU, and a docking call takes minutes: the tunnel

in front of this service cuts a request at ~125 s, and boltz spawns grandchildren that

hold the stdout pipe open so a subprocess call never returns and wedges the whole worker.

That combination already took the PharmaOS API down once. So submissions land in a ledger

here, a worker on the GPU box picks them up, and results come back the same way. One

entrant can never block the service.



Endpoints

    GET  /                  leaderboard page

    POST /api/submit        accept a structure, run the gates, queue it

    GET  /api/leaderboard   ranked table, structures masked

    GET  /api/queue         how much work is outstanding



The ledger is a private dataset, not a file in this container: a Space's filesystem does

not survive a restart, and losing every submission to a rebuild is not acceptable when a

prize depends on them. The worker reads that same dataset directly, so nothing here hands

out work or accepts scores.

"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.parse
import urllib.request
import uuid

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel

import gates
import seasons
import store

HERE = os.path.dirname(os.path.abspath(__file__))
ANCHORS_DIR = os.path.join(HERE, "data")
CACHE = store.Cached(ttl=int(os.environ.get("ODC_CACHE_TTL", "20")))
# Masked identifiers must not be reversible from the public table.
SALT = os.environ.get("ODC_SALT", "odc-season1")

# Hugging Face signs entrants in. Identity has to come from somewhere the entrant cannot
# forge: a free-text name would let anyone post under someone else's handle, and the
# per-model chart is only meaningful if entries cannot be stuffed under a rival's label.
OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
SPACE_HOST = os.environ.get("SPACE_HOST", "")
COOKIE = "odc_session"
# Served in an iframe from another origin, so our cookies are third-party. Lax ones are
# discarded there; None requires Secure, which requires HTTPS - true on the Space, not on
# a local http run.
IN_FRAME = bool(SPACE_HOST)
COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
             else {"samesite": "lax", "secure": False})
SESSION_KEY = os.environ.get("ODC_SESSION_KEY") or secrets.token_hex(16)

# Entries accepted from one account in one day, per season. Chosen from the measured
# distribution: median 7/day, 90th percentile 34, with a few accounts reaching ~100 in a
# day and pushing arrivals past what the GPUs could score.
DAILY_CAP = int(os.environ.get("ODC_DAILY_CAP", "30"))
# the day boundary entrants will assume, rather than UTC
CAP_TZ_OFFSET = 9 * 3600

SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria",
          "target": "PfDHODH", "counter_target": "human DHODH",
          # keyed by axis, not by a word - the page owns the wording in each language
          "weights": {"activity": 30, "binding": 20, "selectivity": 20,
                      "admet": 15, "novelty": 10, "synthesis": 5},
          # Each season is a different disease; the prize is awarded when a season closes.
          "closes": "2026-09-30", "prize_usd": 1000}

app = FastAPI(title="Open Discovery Challenge")


class Submission(BaseModel):
    structure: str            # SMILES or InChI
    display_name: str         # name, affiliation or handle - entrant's choice
    model_name: str = ""      # which model proposed it; free text, blank allowed
    rationale: str = ""       # optional design note
    # The entrant owns the molecule and decides whether anyone else sees it. Private is
    # the default because the reverse cannot be undone: a structure, once shown, is
    # disclosed, and disclosure is what costs patentability.
    visibility: str = "private"
    # which season this entry is for; seasons overlap, so it cannot be inferred from the
    # date and must be stated
    season: int = 1


def _submissions(s):
    """Every accepted entry in one season. Ids come from the tree; the rollup carries the

    scores, so a page load is two requests rather than one per entry."""
    p = seasons.path(s, "submissions")
    # the cache key carries the season: a shared key would serve season 2 whatever season
    # 1 fetched most recently, for as long as the entry lives
    return CACHE.get("ids:%d" % s["number"], lambda: store.listdir(p))


def _board(s):
    p = seasons.path(s, "leaderboard.json")
    return CACHE.get("board:%d" % s["number"],
                     lambda: store.read(p, default={}) or {})


def public_id(inchikey):
    """A stable public handle that cannot be walked back to the structure.



    The skeleton block of an InChIKey is a hash of connectivity, so it is safe to show

    and still lets anyone check two entries are the same compound. The full key and the

    SMILES stay in the ledger."""
    h = hashlib.sha256((SALT + inchikey).encode()).hexdigest()[:6].upper()
    return "ODC-%s" % h


def mask(rec):
    """What the public table is allowed to see: enough to verify and compare, never

    enough to reconstruct. Entrants keep their chemistry until they choose otherwise."""
    pub = rec.get("visibility") == "public"
    return {
        "candidate_id": rec.get("candidate_id"),
        "skeleton": (rec.get("inchikey") or "")[:14],
        # released only where the entrant asked for it to be released
        "visibility": rec.get("visibility", "private"),
        "smiles": rec.get("smiles") if pub else None,
        "display_name": rec.get("display_name"),
        "hf_user": rec.get("hf_user"),
        "model_name": rec.get("model_name"),
        "mw_band": rec.get("mw_band"),
        "status": rec.get("status"),
        "total": rec.get("total"),
        "axes": rec.get("axes_points"),
        "tier": rec.get("tier", 1),
        "relegate_reason": rec.get("relegate_reason") or [],
        "submitted_at": rec.get("submitted_at"),
    }


def band(x, step=50):
    if x is None:
        return None
    lo = int(x // step) * step
    return "%d-%d" % (lo, lo + step)


def _sign(payload):
    raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
    sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
    return raw + "." + sig


def _unsign(token):
    try:
        raw, sig = (token or "").rsplit(".", 1)
    except ValueError:
        return None
    good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
    if not hmac.compare_digest(sig, good):
        return None
    pad = "=" * (-len(raw) % 4)
    try:
        return json.loads(base64.urlsafe_b64decode(raw + pad))
    except Exception:
        return None


def current_user(request: Request):
    return _unsign(request.cookies.get(COOKIE))


def _redirect_uri(request: Request):
    if SPACE_HOST:
        return "https://%s/auth/callback" % SPACE_HOST
    return str(request.base_url).rstrip("/") + "/auth/callback"


@app.get("/")
def index():
    return FileResponse(os.path.join(HERE, "index.html"))


@app.get("/api/me")
def me(request: Request):
    u = current_user(request)
    return {"signed_in": bool(u), "user": u,
            "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}


@app.get("/login")
def login(request: Request):
    if not (OAUTH_ID and OAUTH_SECRET):
        raise HTTPException(503, "OAuth๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค (README์— hf_oauth ํ•„์š”)")
    state = secrets.token_urlsafe(16)
    q = urllib.parse.urlencode({
        "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
        "response_type": "code", "scope": "openid profile", "state": state})
    r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS.rstrip("/"), q))
    # state is echoed back by the provider; comparing it to this cookie is what stops a
    # third party from completing someone else's sign-in
    r.set_cookie("odc_state", state, max_age=600, httponly=True, **COOKIE_KW)
    return r


@app.get("/auth/callback")
def callback(request: Request, code: str = "", state: str = ""):
    saved = request.cookies.get("odc_state")
    if not code:
        raise HTTPException(400, "์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” ๋กœ๊ทธ์ธ์„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
    if not saved:
        # the cookie was never stored, which is a browser policy problem, not tampering
        raise HTTPException(400,
                            "๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฟ ํ‚ค๋ฅผ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ํŽ˜์ด์ง€๋ฅผ "
                            "์ƒˆ ํƒญ์—์„œ ์ง์ ‘ ์—ด๊ณ (์ฃผ์†Œ์ฐฝ์˜ .hf.space ์ฃผ์†Œ) ๋‹ค์‹œ "
                            "๋กœ๊ทธ์ธํ•ด ์ฃผ์„ธ์š”. 3์ž ์ฟ ํ‚ค ์ฐจ๋‹จ์ด ์›์ธ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
    if state != saved:
        raise HTTPException(400, "๋กœ๊ทธ์ธ ์ƒํƒœ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
    body = urllib.parse.urlencode({
        "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
        "grant_type": "authorization_code", "code": code,
        "redirect_uri": _redirect_uri(request)}).encode()
    tok_req = urllib.request.Request(
        OAUTH_ISS.rstrip("/") + "/oauth/token", data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded"})
    with urllib.request.urlopen(tok_req, timeout=20) as r:
        tok = json.loads(r.read().decode())
    ui_req = urllib.request.Request(
        OAUTH_ISS.rstrip("/") + "/oauth/userinfo",
        headers={"Authorization": "Bearer " + tok["access_token"]})
    with urllib.request.urlopen(ui_req, timeout=20) as r:
        ui = json.loads(r.read().decode())
    user = {"name": ui.get("preferred_username") or ui.get("name"),
            "picture": ui.get("picture"), "at": int(time.time())}
    resp = RedirectResponse("/")
    resp.set_cookie(COOKIE, _sign(user), max_age=60 * 60 * 24 * 14,
                    httponly=True, **COOKIE_KW)
    resp.delete_cookie("odc_state")
    return resp


@app.get("/logout")
def logout():
    r = RedirectResponse("/")
    r.delete_cookie(COOKIE)
    return r


@app.get("/assets/{name}")
def asset(name: str):
    """Static files for the page. Names are matched against what is actually on disk, so

    a crafted name cannot walk out of the directory."""
    d = os.path.join(HERE, "assets")
    if name not in set(os.listdir(d) if os.path.isdir(d) else []):
        raise HTTPException(404, "not found")
    return FileResponse(os.path.join(d, name))


@app.get("/api/season")
def season(request: Request):
    return seasons.public(seasons.get(request.query_params.get("season")))


@app.get("/api/seasons")
def season_list():
    """Every season, so the page can draw its tabs without knowing them in advance."""
    return {"seasons": seasons.listing(), "default": seasons.DEFAULT}


@app.post("/api/submit")
def submit(s: Submission, request: Request):
    season = seasons.get(getattr(s, "season", None) or request.query_params.get("season"))
    if not season.get("open"):
        # a form in front of an unverified scorer collects entries it cannot grade
        raise HTTPException(
            403, "์‹œ์ฆŒ #%d ์ ‘์ˆ˜๋Š” ์•„์ง ์—ด๋ฆฌ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค" % season["number"])
    user = current_user(request)
    if (OAUTH_ID and OAUTH_SECRET) and not user:
        raise HTTPException(401, "Hugging Face ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
    if not s.display_name.strip():
        raise HTTPException(400, "ํ‘œ์‹œ ID๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”")
    # Season 1's target is not a covalent-mechanism enzyme, so that rule is off here.
    # Size caps and the PAINS allowance come from the season, not from module state: one
    # process serves every season, so a module-level constant would apply season 3's limits
    # to a season 1 entry.
    v = gates.check(s.structure, covalent_rule=False, **seasons.gate(season))
    if not v["admitted"]:
        return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422)  # dicts: {code, text}

    # the public id is a salted hash of the InChIKey, so an id collision is a structure
    # collision - no need to pull every record back to find out
    sub_path = seasons.path(season, "submissions/%s.json" % public_id(v["inchikey"]))
    # duplicates are per season: the same molecule is a fresh question against a new
    # organism and a new target, so season 1 must not block a season 2 entry
    if store.read(sub_path) is not None:
        return JSONResponse(
            {"accepted": False,
             "reasons": ["์ด๋ฏธ ์ œ์ถœ๋œ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค (%s)" % public_id(v["inchikey"])]},
            status_code=409)

    # Daily cap. Counted from a small per-day tally file rather than by walking the
    # submission tree - that would be one Hub request per existing entry, on every single
    # submission, and there are already more than a thousand.
    who = (user or {}).get("name") or s.display_name.strip()[:60]
    day = time.strftime("%Y-%m-%d", time.gmtime(time.time() + CAP_TZ_OFFSET))
    quota_path = seasons.path(season, "quota/%s.json" % day)
    quota = {}
    if who and DAILY_CAP > 0:
        quota = store.read(quota_path, default={}) or {}
        used = int(quota.get(who, 0))
        if used >= DAILY_CAP:
            return JSONResponse(
                {"accepted": False, "daily_cap": DAILY_CAP, "used": used,
                 "reasons": ["ํ•˜๋ฃจ ์ œ์ถœ ํ•œ๋„์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค (%d/%d). ํ•œ๊ตญ์‹œ๊ฐ„ ์ž์ •์— ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค."
                             % (used, DAILY_CAP)]},
                status_code=429)

    rec = {
        # the candidate id is derived from the structure, so it doubles as the record key
        # and makes a resubmission collide by construction
        "id": public_id(v["inchikey"]),
        "candidate_id": public_id(v["inchikey"]),
        "smiles": v["smiles"], "inchikey": v["inchikey"],
        "mw": v["mw"], "mw_band": band(v["mw"]),
        # the HF handle is recorded separately from the free-text display name, so the
        # table can show who actually submitted rather than what they typed
        "hf_user": (user or {}).get("name"),
        "visibility": "public" if s.visibility == "public" else "private",
        "display_name": s.display_name.strip()[:60],
        "model_name": s.model_name.strip()[:80],
        "rationale": s.rationale.strip()[:2000],
        "season": season["number"],
        "status": "queued", "submitted_at": int(time.time()),
    }
    store.write(sub_path, rec,
                summary="s%d entry %s" % (season["number"], rec["candidate_id"]))
    if who and DAILY_CAP > 0:
        quota[who] = int(quota.get(who, 0)) + 1
        store.write(quota_path, quota, summary="quota %s %s" % (day, who))
    CACHE.drop()
    scored = set((_board(season).get("entries") or {}).keys())
    ahead = len([i for i in _submissions(season) if i not in scored])
    return {"accepted": True, "candidate_id": rec["candidate_id"],
            "queue_position": ahead,
            "note": "์ฑ„์ ์€ GPU ์ž‘์—…์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ์™„๋ฃŒ๊นŒ์ง€ ๋ช‡ ๋ถ„ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค."}


@app.get("/api/leaderboard")
def leaderboard(request: Request):
    season = seasons.get(request.query_params.get("season"))
    rows = list((_board(season).get("entries") or {}).values())
    anchors = []
    apath = os.path.join(ANCHORS_DIR, season["anchors"])
    if os.path.exists(apath):
        for a in json.load(open(apath, encoding="utf-8")):
            if not a.get("admitted"):
                continue
            anchors.append({"candidate_id": a["label"], "is_anchor": True,
                            "display_name": "๊ธฐ์ค€๋ฌผ์งˆ", "model_name": "",
                            "total": a["total"], "tier": a.get("tier", 1),
                            "axes": {k: v["points"] for k, v in a["axes"].items()},
                            # reference rows carry their reasoning too, so the overlay
                            # explains the scale as well as the entries
                            "detail": {k: v["detail"] for k, v in a["axes"].items()},
                            "note": a.get("note", ""),
                            "note_en": a.get("note_en", "")})
    merged = rows + anchors
    merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") or -1)))
    n = 0
    for e in merged:
        if e.get("is_anchor") or e.get("tier", 1) != 1:
            e["rank"] = None            # anchors mark the ladder; they do not climb it
        else:
            n += 1
            e["rank"] = n
    return {"season": seasons.public(season), "entries": merged,
            "counts": {"scored": len(rows), "anchors": len(anchors)}}


# Tokens that carry no distinguishing information when a vendor is already named.
# "OpenAI GPT-5.6 Sol" and "OpenAI 5.6 SOL" are the same model; "GPT-5.6 Pro" is not the
# same as "GPT-5.6 Sol", so only the filler comes out - never a variant word.
_MODEL_FILLER = {"gpt"}


def canon_model(name):
    """Spelling-insensitive key for a model name.



    Splits on anything that is not a letter, digit or dot, so separators stop mattering

    while version numbers survive intact: claude-opus-5 and "Claude opus 5" collapse,

    5.6 stays 5.6 rather than becoming 5 and 6.

    """
    import re
    toks = [t for t in re.split(r"[^0-9A-Za-z.]+", (name or "").lower()) if t]
    toks = [t for t in toks if t not in _MODEL_FILLER]
    return " ".join(toks)


@app.get("/api/models")
def models(request: Request):  # noqa: C901
    """Per-model standings: which model produced the best candidate, and which gets used.



    Deliberately two separate numbers. Popularity is not quality - a model everyone

    reaches for will rack up entries regardless of whether any of them score, and a model

    used three times could hold the top result. Reporting only a mean would hide both:

    one lucky hit drowns in a hundred weak entries, and a model with two good tries looks

    better than one with fifty. So the chart carries best, mean and count side by side.



    Reference compounds are excluded - they were not produced by an entrant's model.

    """
    agg = {}
    season = seasons.get(request.query_params.get("season"))
    for r in (_board(season).get("entries") or {}).values():
        if r.get("total") is None:
            continue
        name = (r.get("model_name") or "").strip() or "๋ฏธ๊ธฐ์žฌ / unspecified"
        key = canon_model(name) or name
        a = agg.setdefault(key, {"model": name, "n": 0, "best": None, "sum": 0.0,
                                 "entrants": set(), "top_candidate": None,
                                 # every spelling seen under this key, so the label can be
                                 # the one entrants actually used most
                                 "spellings": {}})
        a["spellings"][name] = a["spellings"].get(name, 0) + 1
        a["n"] += 1
        a["sum"] += r["total"]
        if a["best"] is None or r["total"] > a["best"]:
            a["best"] = r["total"]
            a["top_candidate"] = r.get("candidate_id")
        who = r.get("hf_user") or r.get("display_name")
        if who:
            a["entrants"].add(who)
    out = []
    for a in agg.values():
        label = max(a["spellings"].items(), key=lambda kv: (kv[1], len(kv[0])))[0]
        out.append({"model": label, "submissions": a["n"],
                    # so the page can say a row is several spellings pooled
                    "spellings": sorted(a["spellings"], key=lambda s: -a["spellings"][s]),
                    # rounded here, so a one-decimal cut on this side is a cut the page
                    # cannot undo - the standings read 60.4 while the table read 60.430
                    "best": round(a["best"], 3) if a["best"] is not None else None,
                    "mean": round(a["sum"] / a["n"], 3) if a["n"] else None,
                    "entrants": len(a["entrants"]),
                    "top_candidate": a["top_candidate"]})
    out.sort(key=lambda x: (-(x["best"] or 0), -x["submissions"]))
    return {"models": out,
            "totals": {"models": len(out), "submissions": sum(x["submissions"] for x in out)}}



# The worker no longer talks to this service at all. It reads the dataset for entries
# that have no result file yet, scores them, and writes the result and the rollup back.
# That removed the claim/post endpoints and the shared key they were guarded by - both
# sides already authenticate to the Hub, so there is one fewer secret to leak.


@app.get("/api/mol3d")
def mol3d(smiles: str = ""):
    """A 3D conformer for the viewer, as an MDL mol block.



    Published structures only. The lookup is against the rollup rather than trusting the

    caller: the page only ever has public strings, but an endpoint that embeds whatever

    it is given would answer "is this the private entry?" for anyone willing to guess.

    """
    from rdkit import Chem
    from rdkit.Chem import AllChem, rdMolDescriptors

    s = (smiles or "").strip()
    if not s:
        raise HTTPException(400, "no structure given")
    published = set()
    for n in seasons.SEASONS:
        for e in (_board(seasons.get(n)).get("entries") or {}).values():
            if e.get("visibility") == "public" and e.get("smiles"):
                published.add(e["smiles"])
    if s not in published:
        raise HTTPException(404, "not a published structure")

    m = Chem.MolFromSmiles(s)
    if m is None:
        raise HTTPException(400, "unparseable structure")
    mh = Chem.AddHs(m)
    # a fixed seed so the same entry always renders as the same conformer; if embedding
    # fails the flat coordinates still draw, they are simply not a conformation
    if AllChem.EmbedMolecule(mh, randomSeed=1) != 0:
        AllChem.Compute2DCoords(mh)
    else:
        try:
            AllChem.MMFFOptimizeMolecule(mh, maxIters=400)
        except Exception:
            pass
    return {"mol": Chem.MolToMolBlock(mh),
            "formula": rdMolDescriptors.CalcMolFormula(m),
            "mw": round(rdMolDescriptors.CalcExactMolWt(m), 2),
            "atoms": m.GetNumAtoms(), "atoms_h": mh.GetNumAtoms()}


@app.get("/api/queue")
def queue(request: Request):
    """How much work is outstanding.



    This was declared directly after the worker endpoints, so removing that block took it

    along too. The page never calls it, so nothing looked broken - it surfaced only by

    exercising every route after a factory rebuild.

    """
    season = seasons.get(request.query_params.get("season"))
    ids = _submissions(season)
    scored = set((_board(season).get("entries") or {}).keys())
    return {"queued": len([i for i in ids if i not in scored]),
            "scored": len(scored), "total": len(ids)}