| |
| """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 gzip |
| 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.middleware.gzip import GZipMiddleware |
| from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse, |
| Response) |
| 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", "60"))) |
| |
| SALT = os.environ.get("ODC_SALT", "odc-season1") |
|
|
| |
| |
| |
| 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" |
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| DAILY_CAP = int(os.environ.get("ODC_DAILY_CAP", "30")) |
| |
| CAP_TZ_OFFSET = 9 * 3600 |
|
|
| SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria", |
| "target": "PfDHODH", "counter_target": "human DHODH", |
| |
| "weights": {"activity": 30, "binding": 20, "selectivity": 20, |
| "admet": 15, "novelty": 10, "synthesis": 5}, |
| |
| "closes": "2026-09-30", "prize_usd": 1000} |
|
|
| app = FastAPI(title="Open Discovery Challenge") |
|
|
|
|
| |
| app.add_middleware(GZipMiddleware, minimum_size=1024) |
|
|
| class Submission(BaseModel): |
| structure: str |
| display_name: str |
| model_name: str = "" |
| rationale: str = "" |
| |
| |
| |
| visibility: str = "private" |
| |
| |
| 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") |
| |
| |
| 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], |
| |
| "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), |
| |
| |
| |
| "alerts": rec.get("alerts") or [], |
| |
| |
| "total_sd": rec.get("total_sd"), |
| "total_ci95": rec.get("total_ci95"), |
| "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("/") |
| async 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)) |
| |
| |
| 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: |
| |
| 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/totals") |
| def api_totals(): |
| """전 시즌 누적 제출·참가자·경과일. 축하 문구가 읽는 값이다. |
| |
| 🔴 숫자를 문구에 박지 않는다. 제출이 시간당 수십 건씩 들어와서, 박아두면 |
| 반나절 만에 낡은 값이 공개면에 남는다. 여기서 세어 화면이 그때그때 읽는다. |
| |
| 집계 파일은 _board() 가 이미 캐시하고 있으므로 그것을 쓴다 - 원장을 다시 훑지 않는다. |
| """ |
| subs = 0 |
| users = set() |
| first = None |
| for num in sorted(seasons.SEASONS): |
| ent = (_board(seasons.SEASONS[num]) or {}).get("entries") or {} |
| rows = list(ent.values() if isinstance(ent, dict) else ent) |
| subs += len(rows) |
| for r in rows: |
| if not r: |
| continue |
| u = r.get("hf_user") |
| if u: |
| users.add(u) |
| ts = r.get("submitted_at") |
| if isinstance(ts, (int, float)) and (first is None or ts < first): |
| first = ts |
| days = ((time.time() - first) / 86400.0) if first else None |
| return {"submissions": subs, "participants": len(users), |
| "opened_at": first, "days": round(days, 1) if days else None} |
|
|
|
|
| @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"): |
| |
| 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를 입력하세요") |
| |
| |
| |
| |
| |
| |
| v = gates.check(s.structure, covalent_rule=seasons.covalent(season), |
| **seasons.gate(season)) |
| if not v["admitted"]: |
| return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422) |
|
|
| |
| |
| sub_path = seasons.path(season, "submissions/%s.json" % public_id(v["inchikey"])) |
| |
| |
| if store.read(sub_path) is not None: |
| return JSONResponse( |
| {"accepted": False, |
| "reasons": ["이미 제출된 구조입니다 (%s)" % public_id(v["inchikey"])]}, |
| status_code=409) |
|
|
| |
| |
| |
| |
| skel_path = None |
| if seasons.dedup_mode(season) == "structure" and v.get("dedup_key"): |
| skel_path = seasons.path(season, "dedup/%s.json" % v["dedup_key"]) |
| prior = store.read(skel_path) |
| if prior is not None: |
| return JSONResponse( |
| {"accepted": False, |
| "reasons": ["이미 제출된 것과 화학적으로 같은 구조입니다. 동위원소 표기" |
| "([19F] 등), 염·수화물, 표기법만 다른 것은 같은 분자로 봅니다. " |
| "거울상·라세미체는 별개로 접수됩니다."]}, |
| status_code=409) |
|
|
| |
| |
| |
| 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 = { |
| |
| |
| "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"]), |
| |
| |
| "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"], |
| |
| |
| "skeleton": v.get("skeleton"), |
| "status": "queued", "submitted_at": int(time.time()), |
| } |
| |
| |
| |
| files = [(sub_path, rec)] |
| |
| |
| if skel_path: |
| files.append((skel_path, |
| {"candidate_id": rec["candidate_id"], "at": rec["submitted_at"]})) |
| if who and DAILY_CAP > 0: |
| quota[who] = int(quota.get(who, 0)) + 1 |
| files.append((quota_path, quota)) |
| try: |
| store.write_many(files, summary="s%d entry %s" |
| % (season["number"], rec["candidate_id"])) |
| except store.Busy: |
| |
| |
| return JSONResponse( |
| {"accepted": False, "retryable": True, |
| "reasons": ["저장소가 혼잡해 접수를 확정하지 못했습니다. 잠시 뒤 그대로 다시 " |
| "제출하세요 - 혹시 먼저 기록됐다면 중복으로 안내됩니다."]}, |
| status_code=503) |
| 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): |
| """The response depends on nothing but the season, so the body is built once per TTL |
| and handed out as bytes rather than re-sorted and re-serialised per request.""" |
| season = seasons.get(request.query_params.get("season")) |
| |
| |
| |
| |
| |
| raw, gz = CACHE.get("lbresp:%d" % season["number"], |
| lambda: _leaderboard_bytes(season)) |
| if "gzip" in (request.headers.get("accept-encoding") or ""): |
| return Response(content=gz, media_type="application/json", |
| headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"}) |
| return Response(content=raw, media_type="application/json", |
| headers={"Vary": "Accept-Encoding"}) |
|
|
|
|
| def _leaderboard_bytes(season): |
| raw = json.dumps(_leaderboard_body(season), ensure_ascii=False).encode("utf-8") |
| return raw, gzip.compress(raw, 6) |
|
|
|
|
| def _leaderboard_body(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()}, |
| |
| |
| "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 |
| else: |
| n += 1 |
| e["rank"] = n |
| return {"season": seasons.public(season), "entries": merged, |
| "counts": {"scored": len(rows), "anchors": len(anchors)}} |
|
|
|
|
|
|
| |
| |
| |
| _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): |
| """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, |
| |
| |
| "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"], |
| |
| "spellings": sorted(a["spellings"], key=lambda s: -a["spellings"][s]), |
| |
| |
| "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)}} |
|
|
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| @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) |
| |
| |
| 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)} |
|
|