Season 3 (Chagas disease): per-season admission limits, panel verdict, opens 30 Nov 2026
5f76b5d verified | # -*- 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" | |
| def index(): | |
| return FileResponse(os.path.join(HERE, "index.html")) | |
| def me(request: Request): | |
| u = current_user(request) | |
| return {"signed_in": bool(u), "user": u, | |
| "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)} | |
| 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 | |
| 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 | |
| def logout(): | |
| r = RedirectResponse("/") | |
| r.delete_cookie(COOKIE) | |
| return r | |
| 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)) | |
| def season(request: Request): | |
| return seasons.public(seasons.get(request.query_params.get("season"))) | |
| def season_list(): | |
| """Every season, so the page can draw its tabs without knowing them in advance.""" | |
| return {"seasons": seasons.listing(), "default": seasons.DEFAULT} | |
| 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 작업으로 처리되며 완료까지 몇 분 걸립니다."} | |
| 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) | |
| 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. | |
| 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()} | |
| 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)} | |