Add season 2 (Tuberculosis, closes 31 October) as a season switch; per-season copy
Browse files- app.py +57 -26
- index.html +149 -7
- seasons.py +92 -0
app.py
CHANGED
|
@@ -37,10 +37,11 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
| 39 |
import gates
|
|
|
|
| 40 |
import store
|
| 41 |
|
| 42 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 43 |
-
|
| 44 |
CACHE = store.Cached(ttl=int(os.environ.get("ODC_CACHE_TTL", "20")))
|
| 45 |
# Masked identifiers must not be reversible from the public table.
|
| 46 |
SALT = os.environ.get("ODC_SALT", "odc-season1")
|
|
@@ -81,16 +82,24 @@ class Submission(BaseModel):
|
|
| 81 |
# the default because the reverse cannot be undone: a structure, once shown, is
|
| 82 |
# disclosed, and disclosure is what costs patentability.
|
| 83 |
visibility: str = "private"
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
-
def _submissions():
|
| 87 |
-
"""Every accepted entry. Ids come from the tree; the rollup carries the
|
| 88 |
-
page load is two requests rather than one per entry."""
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
-
def _board():
|
| 93 |
-
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def public_id(inchikey):
|
|
@@ -235,12 +244,23 @@ def logout():
|
|
| 235 |
|
| 236 |
|
| 237 |
@app.get("/api/season")
|
| 238 |
-
def season():
|
| 239 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
|
| 242 |
@app.post("/api/submit")
|
| 243 |
def submit(s: Submission, request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
user = current_user(request)
|
| 245 |
if (OAUTH_ID and OAUTH_SECRET) and not user:
|
| 246 |
raise HTTPException(401, "Hugging Face 로그인이 필요합니다")
|
|
@@ -253,7 +273,10 @@ def submit(s: Submission, request: Request):
|
|
| 253 |
|
| 254 |
# the public id is a salted hash of the InChIKey, so an id collision is a structure
|
| 255 |
# collision - no need to pull every record back to find out
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
| 257 |
return JSONResponse(
|
| 258 |
{"accepted": False,
|
| 259 |
"reasons": ["이미 제출된 구조입니다 (%s)" % public_id(v["inchikey"])]},
|
|
@@ -273,24 +296,27 @@ def submit(s: Submission, request: Request):
|
|
| 273 |
"display_name": s.display_name.strip()[:60],
|
| 274 |
"model_name": s.model_name.strip()[:80],
|
| 275 |
"rationale": s.rationale.strip()[:2000],
|
|
|
|
| 276 |
"status": "queued", "submitted_at": int(time.time()),
|
| 277 |
}
|
| 278 |
-
store.write(
|
| 279 |
-
summary="entry %s" % rec["candidate_id"])
|
| 280 |
CACHE.drop()
|
| 281 |
-
scored = set((_board().get("entries") or {}).keys())
|
| 282 |
-
ahead = len([i for i in _submissions() if i not in scored])
|
| 283 |
return {"accepted": True, "candidate_id": rec["candidate_id"],
|
| 284 |
"queue_position": ahead,
|
| 285 |
"note": "채점은 GPU 작업으로 처리되며 완료까지 몇 분 걸립니다."}
|
| 286 |
|
| 287 |
|
| 288 |
@app.get("/api/leaderboard")
|
| 289 |
-
def leaderboard():
|
| 290 |
-
|
|
|
|
| 291 |
anchors = []
|
| 292 |
-
|
| 293 |
-
|
|
|
|
| 294 |
if not a.get("admitted"):
|
| 295 |
continue
|
| 296 |
anchors.append({"candidate_id": a["label"], "is_anchor": True,
|
|
@@ -311,7 +337,7 @@ def leaderboard():
|
|
| 311 |
else:
|
| 312 |
n += 1
|
| 313 |
e["rank"] = n
|
| 314 |
-
return {"season":
|
| 315 |
"counts": {"scored": len(rows), "anchors": len(anchors)}}
|
| 316 |
|
| 317 |
|
|
@@ -335,7 +361,7 @@ def canon_model(name):
|
|
| 335 |
|
| 336 |
|
| 337 |
@app.get("/api/models")
|
| 338 |
-
def models(): # noqa: C901
|
| 339 |
"""Per-model standings: which model produced the best candidate, and which gets used.
|
| 340 |
|
| 341 |
Deliberately two separate numbers. Popularity is not quality - a model everyone
|
|
@@ -347,7 +373,8 @@ def models(): # noqa: C901
|
|
| 347 |
Reference compounds are excluded - they were not produced by an entrant's model.
|
| 348 |
"""
|
| 349 |
agg = {}
|
| 350 |
-
|
|
|
|
| 351 |
if r.get("total") is None:
|
| 352 |
continue
|
| 353 |
name = (r.get("model_name") or "").strip() or "미기재 / unspecified"
|
|
@@ -404,8 +431,11 @@ def mol3d(smiles: str = ""):
|
|
| 404 |
s = (smiles or "").strip()
|
| 405 |
if not s:
|
| 406 |
raise HTTPException(400, "no structure given")
|
| 407 |
-
published =
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
| 409 |
if s not in published:
|
| 410 |
raise HTTPException(404, "not a published structure")
|
| 411 |
|
|
@@ -429,14 +459,15 @@ def mol3d(smiles: str = ""):
|
|
| 429 |
|
| 430 |
|
| 431 |
@app.get("/api/queue")
|
| 432 |
-
def queue():
|
| 433 |
"""How much work is outstanding.
|
| 434 |
|
| 435 |
This was declared directly after the worker endpoints, so removing that block took it
|
| 436 |
along too. The page never calls it, so nothing looked broken - it surfaced only by
|
| 437 |
exercising every route after a factory rebuild.
|
| 438 |
"""
|
| 439 |
-
|
| 440 |
-
|
|
|
|
| 441 |
return {"queued": len([i for i in ids if i not in scored]),
|
| 442 |
"scored": len(scored), "total": len(ids)}
|
|
|
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
| 39 |
import gates
|
| 40 |
+
import seasons
|
| 41 |
import store
|
| 42 |
|
| 43 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 44 |
+
ANCHORS_DIR = os.path.join(HERE, "data")
|
| 45 |
CACHE = store.Cached(ttl=int(os.environ.get("ODC_CACHE_TTL", "20")))
|
| 46 |
# Masked identifiers must not be reversible from the public table.
|
| 47 |
SALT = os.environ.get("ODC_SALT", "odc-season1")
|
|
|
|
| 82 |
# the default because the reverse cannot be undone: a structure, once shown, is
|
| 83 |
# disclosed, and disclosure is what costs patentability.
|
| 84 |
visibility: str = "private"
|
| 85 |
+
# which season this entry is for; seasons overlap, so it cannot be inferred from the
|
| 86 |
+
# date and must be stated
|
| 87 |
+
season: int = 1
|
| 88 |
|
| 89 |
|
| 90 |
+
def _submissions(s):
|
| 91 |
+
"""Every accepted entry in one season. Ids come from the tree; the rollup carries the
|
| 92 |
+
scores, so a page load is two requests rather than one per entry."""
|
| 93 |
+
p = seasons.path(s, "submissions")
|
| 94 |
+
# the cache key carries the season: a shared key would serve season 2 whatever season
|
| 95 |
+
# 1 fetched most recently, for as long as the entry lives
|
| 96 |
+
return CACHE.get("ids:%d" % s["number"], lambda: store.listdir(p))
|
| 97 |
|
| 98 |
|
| 99 |
+
def _board(s):
|
| 100 |
+
p = seasons.path(s, "leaderboard.json")
|
| 101 |
+
return CACHE.get("board:%d" % s["number"],
|
| 102 |
+
lambda: store.read(p, default={}) or {})
|
| 103 |
|
| 104 |
|
| 105 |
def public_id(inchikey):
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
@app.get("/api/season")
|
| 247 |
+
def season(request: Request):
|
| 248 |
+
return seasons.public(seasons.get(request.query_params.get("season")))
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@app.get("/api/seasons")
|
| 252 |
+
def season_list():
|
| 253 |
+
"""Every season, so the page can draw its tabs without knowing them in advance."""
|
| 254 |
+
return {"seasons": seasons.listing(), "default": seasons.DEFAULT}
|
| 255 |
|
| 256 |
|
| 257 |
@app.post("/api/submit")
|
| 258 |
def submit(s: Submission, request: Request):
|
| 259 |
+
season = seasons.get(getattr(s, "season", None) or request.query_params.get("season"))
|
| 260 |
+
if not season.get("open"):
|
| 261 |
+
# a form in front of an unverified scorer collects entries it cannot grade
|
| 262 |
+
raise HTTPException(
|
| 263 |
+
403, "시즌 #%d 접수는 아직 열리지 않았습니다" % season["number"])
|
| 264 |
user = current_user(request)
|
| 265 |
if (OAUTH_ID and OAUTH_SECRET) and not user:
|
| 266 |
raise HTTPException(401, "Hugging Face 로그인이 필요합니다")
|
|
|
|
| 273 |
|
| 274 |
# the public id is a salted hash of the InChIKey, so an id collision is a structure
|
| 275 |
# collision - no need to pull every record back to find out
|
| 276 |
+
sub_path = seasons.path(season, "submissions/%s.json" % public_id(v["inchikey"]))
|
| 277 |
+
# duplicates are per season: the same molecule is a fresh question against a new
|
| 278 |
+
# organism and a new target, so season 1 must not block a season 2 entry
|
| 279 |
+
if store.read(sub_path) is not None:
|
| 280 |
return JSONResponse(
|
| 281 |
{"accepted": False,
|
| 282 |
"reasons": ["이미 제출된 구조입니다 (%s)" % public_id(v["inchikey"])]},
|
|
|
|
| 296 |
"display_name": s.display_name.strip()[:60],
|
| 297 |
"model_name": s.model_name.strip()[:80],
|
| 298 |
"rationale": s.rationale.strip()[:2000],
|
| 299 |
+
"season": season["number"],
|
| 300 |
"status": "queued", "submitted_at": int(time.time()),
|
| 301 |
}
|
| 302 |
+
store.write(sub_path, rec,
|
| 303 |
+
summary="s%d entry %s" % (season["number"], rec["candidate_id"]))
|
| 304 |
CACHE.drop()
|
| 305 |
+
scored = set((_board(season).get("entries") or {}).keys())
|
| 306 |
+
ahead = len([i for i in _submissions(season) if i not in scored])
|
| 307 |
return {"accepted": True, "candidate_id": rec["candidate_id"],
|
| 308 |
"queue_position": ahead,
|
| 309 |
"note": "채점은 GPU 작업으로 처리되며 완료까지 몇 분 걸립니다."}
|
| 310 |
|
| 311 |
|
| 312 |
@app.get("/api/leaderboard")
|
| 313 |
+
def leaderboard(request: Request):
|
| 314 |
+
season = seasons.get(request.query_params.get("season"))
|
| 315 |
+
rows = list((_board(season).get("entries") or {}).values())
|
| 316 |
anchors = []
|
| 317 |
+
apath = os.path.join(ANCHORS_DIR, season["anchors"])
|
| 318 |
+
if os.path.exists(apath):
|
| 319 |
+
for a in json.load(open(apath, encoding="utf-8")):
|
| 320 |
if not a.get("admitted"):
|
| 321 |
continue
|
| 322 |
anchors.append({"candidate_id": a["label"], "is_anchor": True,
|
|
|
|
| 337 |
else:
|
| 338 |
n += 1
|
| 339 |
e["rank"] = n
|
| 340 |
+
return {"season": seasons.public(season), "entries": merged,
|
| 341 |
"counts": {"scored": len(rows), "anchors": len(anchors)}}
|
| 342 |
|
| 343 |
|
|
|
|
| 361 |
|
| 362 |
|
| 363 |
@app.get("/api/models")
|
| 364 |
+
def models(request: Request): # noqa: C901
|
| 365 |
"""Per-model standings: which model produced the best candidate, and which gets used.
|
| 366 |
|
| 367 |
Deliberately two separate numbers. Popularity is not quality - a model everyone
|
|
|
|
| 373 |
Reference compounds are excluded - they were not produced by an entrant's model.
|
| 374 |
"""
|
| 375 |
agg = {}
|
| 376 |
+
season = seasons.get(request.query_params.get("season"))
|
| 377 |
+
for r in (_board(season).get("entries") or {}).values():
|
| 378 |
if r.get("total") is None:
|
| 379 |
continue
|
| 380 |
name = (r.get("model_name") or "").strip() or "미기재 / unspecified"
|
|
|
|
| 431 |
s = (smiles or "").strip()
|
| 432 |
if not s:
|
| 433 |
raise HTTPException(400, "no structure given")
|
| 434 |
+
published = set()
|
| 435 |
+
for n in seasons.SEASONS:
|
| 436 |
+
for e in (_board(seasons.get(n)).get("entries") or {}).values():
|
| 437 |
+
if e.get("visibility") == "public" and e.get("smiles"):
|
| 438 |
+
published.add(e["smiles"])
|
| 439 |
if s not in published:
|
| 440 |
raise HTTPException(404, "not a published structure")
|
| 441 |
|
|
|
|
| 459 |
|
| 460 |
|
| 461 |
@app.get("/api/queue")
|
| 462 |
+
def queue(request: Request):
|
| 463 |
"""How much work is outstanding.
|
| 464 |
|
| 465 |
This was declared directly after the worker endpoints, so removing that block took it
|
| 466 |
along too. The page never calls it, so nothing looked broken - it surfaced only by
|
| 467 |
exercising every route after a factory rebuild.
|
| 468 |
"""
|
| 469 |
+
season = seasons.get(request.query_params.get("season"))
|
| 470 |
+
ids = _submissions(season)
|
| 471 |
+
scored = set((_board(season).get("entries") or {}).keys())
|
| 472 |
return {"queued": len([i for i in ids if i not in scored]),
|
| 473 |
"scored": len(scored), "total": len(ids)}
|
index.html
CHANGED
|
@@ -48,6 +48,16 @@
|
|
| 48 |
border:1px solid var(--line);border-radius:20px;padding:4px 12px}
|
| 49 |
.wts b{color:var(--accent)}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
#langbar{position:absolute;top:20px;right:22px;display:flex;gap:2px;background:#fff;
|
| 52 |
border:1px solid var(--line);border-radius:9px;padding:3px;box-shadow:var(--shadow)}
|
| 53 |
#langbar button{border:0;background:transparent;color:var(--muted);font:inherit;
|
|
@@ -330,9 +340,10 @@
|
|
| 330 |
</div>
|
| 331 |
<div id="authbar"></div>
|
| 332 |
<div id="langnote"></div>
|
|
|
|
| 333 |
<div id="cd"><span class="cdlab" id="cdlab"></span><span class="cdval" id="cdval"> </span></div>
|
| 334 |
<p class="eyebrow">Open Discovery Challenge</p>
|
| 335 |
-
<h1>#1 <em data-i18n="topic">Malaria</em></h1>
|
| 336 |
<p class="concept" id="concept"></p>
|
| 337 |
<p class="sub" data-i18n="tagline"></p>
|
| 338 |
<div class="wts" id="wts"></div>
|
|
@@ -365,7 +376,7 @@
|
|
| 365 |
<section class="terms">
|
| 366 |
<div class="card prize">
|
| 367 |
<h2 data-i18n="prize_h">Prize</h2>
|
| 368 |
-
<div class="amt">$1,000 <small data-i18n="prize_unit">USD</small></div>
|
| 369 |
<p id="prize_body"></p>
|
| 370 |
<div class="fine" id="prize_fine"></div>
|
| 371 |
</div>
|
|
@@ -500,6 +511,7 @@ const T = {
|
|
| 500 |
},
|
| 501 |
ov_pred:"예측치", ov_pf:"말라리아 효소", ov_hs:"사람 효소", ov_sel:"선택도",
|
| 502 |
private_note:"제출자가 비공개를 선택했습니다", copy_smiles:"분자식 복사",
|
|
|
|
| 503 |
cd_label:"시즌 #1 마감까지", cd_day:"일", cd_over_label:"시즌 #1 마감",
|
| 504 |
cd_over:"마감되었습니다", view_3d:"3D 입체 구조로 보기",
|
| 505 |
v3_loading:"입체 구조 생성 중\u2026", v3_atoms:"원자",
|
|
@@ -598,6 +610,7 @@ const T = {
|
|
| 598 |
},
|
| 599 |
ov_pred:"Predicted", ov_pf:"parasite enzyme", ov_hs:"human enzyme", ov_sel:"selectivity",
|
| 600 |
private_note:"structure withheld by the entrant", copy_smiles:"Copy SMILES",
|
|
|
|
| 601 |
cd_label:"Season #1 closes in", cd_day:"Day", cd_over_label:"Season #1 closed",
|
| 602 |
cd_over:"closed", view_3d:"View in 3D",
|
| 603 |
v3_loading:"building conformer\u2026", v3_atoms:"atoms",
|
|
@@ -687,11 +700,91 @@ const AX = ["activity","binding","selectivity","admet","novelty","synthesis"];
|
|
| 687 |
let DATA = null, sortKey = "total", sortDir = -1;
|
| 688 |
|
| 689 |
const $ = s => document.querySelector(s);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
const f1 = v => (v==null||v==="") ? "–" : (+v).toFixed(1);
|
| 691 |
// totals carry three decimals; the per-axis columns stay at one, where the extra digits
|
| 692 |
// would be width without information
|
| 693 |
const f3 = v => (v==null||v==="") ? "–" : (+v).toFixed(3);
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
const submittedDate = e => {
|
| 696 |
if(e.is_anchor || !e.submitted_at) return "–";
|
| 697 |
const locale = LANG === "ko" ? "ko-KR" : "en-CA";
|
|
@@ -700,6 +793,33 @@ const submittedDate = e => {
|
|
| 700 |
}).format(new Date(Number(e.submitted_at) * 1000));
|
| 701 |
};
|
| 702 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
function applyLang(){
|
| 704 |
const d = t();
|
| 705 |
document.documentElement.lang = LANG;
|
|
@@ -726,6 +846,13 @@ function applyLang(){
|
|
| 726 |
$("#rationale").placeholder = LANG==="ko" ? "왜 이 구조인지 간단히" : "why this structure, briefly";
|
| 727 |
document.querySelectorAll("#langbar button").forEach(b=>b.classList.toggle("on", b.dataset.lang===LANG));
|
| 728 |
$("#langnote").textContent = LANG_AUTO ? d.auto : d.manual;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
renderAuth();
|
| 730 |
tickCountdown();
|
| 731 |
if(DATA) render();
|
|
@@ -834,8 +961,21 @@ function render(){
|
|
| 834 |
let ME = {signed_in:false, oauth_configured:false};
|
| 835 |
let MODELS = {models:[], totals:{models:0, submissions:0}};
|
| 836 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
function renderAuth(){
|
| 838 |
const d = t(), bar = $("#authbar"), gate = $("#gate"), go = $("#go");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
if(ME.signed_in){
|
| 840 |
const u = ME.user || {};
|
| 841 |
bar.innerHTML = '<span class="u">' + (u.picture ? `<img src="${u.picture}" alt="">` : "")
|
|
@@ -1193,9 +1333,9 @@ OV.onmouseleave = hideOverlay;
|
|
| 1193 |
async function load(){
|
| 1194 |
try{
|
| 1195 |
const [lb, me, md] = await Promise.all([
|
| 1196 |
-
fetch("api/leaderboard").then(r=>r.json()),
|
| 1197 |
fetch("api/me").then(r=>r.json()).catch(()=>ME),
|
| 1198 |
-
fetch("api/models").then(r=>r.json()).catch(()=>MODELS),
|
| 1199 |
]);
|
| 1200 |
DATA = lb; ME = me; MODELS = md;
|
| 1201 |
if(DATA.season && DATA.season.closes) startCountdown(DATA.season.closes);
|
|
@@ -1209,11 +1349,11 @@ $("#go").onclick = async ()=>{
|
|
| 1209 |
const vis = (document.querySelector('input[name="vis"]:checked')||{}).value || "private";
|
| 1210 |
const body = {structure:$("#structure").value.trim(), display_name:$("#display_name").value.trim(),
|
| 1211 |
model_name:$("#model_name").value.trim(), rationale:$("#rationale").value.trim(),
|
| 1212 |
-
visibility: vis};
|
| 1213 |
if(!body.structure || !body.display_name){ msg.className="msg err"; msg.textContent=d.need; return; }
|
| 1214 |
btn.disabled = true; msg.className="msg"; msg.textContent="";
|
| 1215 |
try{
|
| 1216 |
-
const r = await fetch("api/submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
| 1217 |
const j = await r.json();
|
| 1218 |
if(j.accepted){ msg.className="msg ok"; msg.textContent=d.queued(j.candidate_id,j.queue_position,j.note);
|
| 1219 |
$("#structure").value=""; load(); }
|
|
@@ -1268,6 +1408,8 @@ document.querySelectorAll("#tabs button").forEach(b => b.onclick = () => {
|
|
| 1268 |
window.addEventListener("scroll", hideOverlay, {passive:true});
|
| 1269 |
window.addEventListener("resize", hideOverlay);
|
| 1270 |
|
|
|
|
|
|
|
| 1271 |
applyLang(); load(); setInterval(load, 20000);
|
| 1272 |
</script>
|
| 1273 |
</body>
|
|
|
|
| 48 |
border:1px solid var(--line);border-radius:20px;padding:4px 12px}
|
| 49 |
.wts b{color:var(--accent)}
|
| 50 |
|
| 51 |
+
#seasonbar{display:flex;gap:6px;margin-bottom:14px}
|
| 52 |
+
#seasonbar button{border:1px solid var(--line);background:#fff;color:var(--muted);
|
| 53 |
+
font:inherit;font-size:12.5px;font-weight:700;padding:7px 14px;
|
| 54 |
+
border-radius:999px;cursor:pointer;display:flex;align-items:center;
|
| 55 |
+
gap:7px}
|
| 56 |
+
#seasonbar button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
| 57 |
+
#seasonbar .st{font-size:10px;font-weight:800;letter-spacing:.04em;padding:1px 6px;
|
| 58 |
+
border-radius:999px;background:#eef3fa;color:var(--muted)}
|
| 59 |
+
#seasonbar button.on .st{background:rgba(255,255,255,.22);color:#fff}
|
| 60 |
+
@media(max-width:720px){#seasonbar{flex-wrap:wrap}}
|
| 61 |
#langbar{position:absolute;top:20px;right:22px;display:flex;gap:2px;background:#fff;
|
| 62 |
border:1px solid var(--line);border-radius:9px;padding:3px;box-shadow:var(--shadow)}
|
| 63 |
#langbar button{border:0;background:transparent;color:var(--muted);font:inherit;
|
|
|
|
| 340 |
</div>
|
| 341 |
<div id="authbar"></div>
|
| 342 |
<div id="langnote"></div>
|
| 343 |
+
<div id="seasonbar"></div>
|
| 344 |
<div id="cd"><span class="cdlab" id="cdlab"></span><span class="cdval" id="cdval"> </span></div>
|
| 345 |
<p class="eyebrow">Open Discovery Challenge</p>
|
| 346 |
+
<h1 id="seasonhead">#1 <em data-i18n="topic">Malaria</em></h1>
|
| 347 |
<p class="concept" id="concept"></p>
|
| 348 |
<p class="sub" data-i18n="tagline"></p>
|
| 349 |
<div class="wts" id="wts"></div>
|
|
|
|
| 376 |
<section class="terms">
|
| 377 |
<div class="card prize">
|
| 378 |
<h2 data-i18n="prize_h">Prize</h2>
|
| 379 |
+
<div class="amt" id="prize_amt">$1,000 <small data-i18n="prize_unit">USD</small></div>
|
| 380 |
<p id="prize_body"></p>
|
| 381 |
<div class="fine" id="prize_fine"></div>
|
| 382 |
</div>
|
|
|
|
| 511 |
},
|
| 512 |
ov_pred:"예측치", ov_pf:"말라리아 효소", ov_hs:"사람 효소", ov_sel:"선택도",
|
| 513 |
private_note:"제출자가 비공개를 선택했습니다", copy_smiles:"분자식 복사",
|
| 514 |
+
season_soon:"준비중", season_notopen:"이 시즌은 아직 접수를 받지 않습니다. 채점기 검증이 끝나면 공지와 함께 엽니다.",
|
| 515 |
cd_label:"시즌 #1 마감까지", cd_day:"일", cd_over_label:"시즌 #1 마감",
|
| 516 |
cd_over:"마감되었습니다", view_3d:"3D 입체 구조로 보기",
|
| 517 |
v3_loading:"입체 구조 생성 중\u2026", v3_atoms:"원자",
|
|
|
|
| 610 |
},
|
| 611 |
ov_pred:"Predicted", ov_pf:"parasite enzyme", ov_hs:"human enzyme", ov_sel:"selectivity",
|
| 612 |
private_note:"structure withheld by the entrant", copy_smiles:"Copy SMILES",
|
| 613 |
+
season_soon:"soon", season_notopen:"This season is not taking entries yet. It opens once the scorer has been checked, and we will say so.",
|
| 614 |
cd_label:"Season #1 closes in", cd_day:"Day", cd_over_label:"Season #1 closed",
|
| 615 |
cd_over:"closed", view_3d:"View in 3D",
|
| 616 |
v3_loading:"building conformer\u2026", v3_atoms:"atoms",
|
|
|
|
| 700 |
let DATA = null, sortKey = "total", sortDir = -1;
|
| 701 |
|
| 702 |
const $ = s => document.querySelector(s);
|
| 703 |
+
|
| 704 |
+
// Which season the whole page is showing. Kept in the URL so a link to a season is a
|
| 705 |
+
// link to that season, and remembered so a return visit lands where the reader left off.
|
| 706 |
+
let SEASON = (function(){
|
| 707 |
+
const u = new URLSearchParams(location.search).get("season");
|
| 708 |
+
const v = u || localStorage.getItem("odc_season") || "1";
|
| 709 |
+
return parseInt(v, 10) || 1;
|
| 710 |
+
})();
|
| 711 |
+
let SEASONS = null;
|
| 712 |
+
function seasonInfo(){
|
| 713 |
+
return (SEASONS || []).find(s => s.number === SEASON) || null;
|
| 714 |
+
}
|
| 715 |
+
function qs(){ return "?season=" + SEASON; }
|
| 716 |
const f1 = v => (v==null||v==="") ? "–" : (+v).toFixed(1);
|
| 717 |
// totals carry three decimals; the per-axis columns stay at one, where the extra digits
|
| 718 |
// would be width without information
|
| 719 |
const f3 = v => (v==null||v==="") ? "–" : (+v).toFixed(3);
|
| 720 |
+
|
| 721 |
+
// ---------------------------------------------------------------- season copy
|
| 722 |
+
// Only what actually differs between seasons. Everything else lives in T and is shared,
|
| 723 |
+
// so a new season is a block of writing rather than an edit to the page.
|
| 724 |
+
const ST = {
|
| 725 |
+
ko: {
|
| 726 |
+
1: {
|
| 727 |
+
topic:"말라리아",
|
| 728 |
+
concept:"<b>AI가 발견한 말라리아 신약 후보 물질</b>의 검증 및 평가",
|
| 729 |
+
tagline:"어떤 AI로 찾아낸 후보든, 같은 기준으로 계산 검증하고 점수를 매깁니다. 표적은 말라리아 원충이 의존하는 효소 PfDHODH이며, 사람의 같은 효소(DHODH)는 건드리지 않아야 합니다.",
|
| 730 |
+
s1_p:"OpenAI · Claude · Gemini · Qwen · KIMI · DeepSeek 등 어떤 모델이든, 어떤 하네스든 자유입니다. 직접 설계해도 됩니���. 말라리아 원충 효소 PfDHODH를 막을 저분자를 찾으세요.",
|
| 731 |
+
ov_pf:"말라리아 효소", ov_hs:"사람 효소",
|
| 732 |
+
g1_lead:"말라리아 원충은 살아남기 위해 <b>PfDHODH</b>라는 효소에 의존합니다. 이 효소를 막으면 원충이 죽습니다. 문제는 사람에게도 같은 계열의 효소가 있다는 것입니다.",
|
| 733 |
+
g1_a:"PfDHODH — 말라리아 원충의 효소. 원충이 생존에 의존합니다.",
|
| 734 |
+
g1_b:"사람 DHODH — 같은 계열. 막으면 치료가 아니라 면역억제 부작용이 됩니다.",
|
| 735 |
+
cd_label:"시즌 #1 마감까지", cd_over_label:"시즌 #1 마감",
|
| 736 |
+
prize_amt:"$1,000",
|
| 737 |
+
prize_body:"각 시즌이 끝날 때 <b>순위표 1위에게 상금을 드립니다.</b> 시즌 #1(말라리아)은 <b>2026년 9월 30일</b> 마감이며 상금은 미화 1,000달러입니다.<br><br>이 돈은 여러분이 쓴 시간과 토큰에 대한 대가가 되기에는 턱없이 부족합니다. 그렇게 계산할 생각도 없습니다. <b>이것은 “당신이 한 일에 값이 있었다”고 말하는 방식입니다.</b><br><br>소외질환 연구가 힘든 이유는 아무도 고맙다고 하지 않기 때문입니다. 논문도 잘 안 실리고, 투자도 안 붙고, 아무도 보지 않습니다. 이 대회는 최소한 누가 무엇을 기여했는지 기록으로 남기고, 시즌마다 한 번 감사를 표합니다."
|
| 738 |
+
},
|
| 739 |
+
2: {
|
| 740 |
+
topic:"결핵",
|
| 741 |
+
concept:"<b>AI가 발견한 결핵 신약 후보 물질</b>의 검증 및 평가",
|
| 742 |
+
tagline:"어떤 AI로 찾아낸 후보든, 같은 기준으로 계산 검증하고 점수를 매깁니다. 표적은 결핵균이 세포벽을 만드는 데 쓰는 효소 InhA이며, 사람의 같은 화학을 담당하는 효소(FASN 에노일환원효소 영역)는 건드리지 않아야 합니다.",
|
| 743 |
+
why:"<b>결핵은 한 해 약 125만 명의 목숨을 가져갑니다. 단일 감염병으로는 세계 최다입니다</b>(WHO 세계 결핵 보고서 2024). 치료제는 있습니다. 다만 <b>6개월을 매일 먹어야 하고</b>, 그마저도 듣지 않는 내성균이 계속 늘고 있습니다. 다제내성 결핵 환자는 1년 넘게 약을 먹고도 절반 가까이 낫지 못합니다.<br><br>새 항결핵제가 드문 이유는 과학이 막혀서가 아닙니다. 환자 대부분이 가난한 나라에 있어 시장이 성립하지 않기 때문입니다. 40년 넘게 신약이 하나도 나오지 않다가 2012년에야 베다퀼린이 승인됐습니다.<br><br>시즌 #2는 그 자리를 겨냥합니다. <b>AI가 그려낸 후보를 받아, 같은 잣대로 계산 검증하고 순위를 매깁니다.</b>",
|
| 744 |
+
s1_p:"OpenAI · Claude · Gemini · Qwen · KIMI · DeepSeek 등 어떤 모델이든, 어떤 하네스든 자유입니다. 직접 설계해도 됩니다. 결핵균 효소 InhA를 막을 저분자를 찾으세요.",
|
| 745 |
+
ov_pf:"결핵균 효소", ov_hs:"사람 효소",
|
| 746 |
+
g1_lead:"결핵균은 세포벽의 지방산을 만들 때 <b>InhA</b>라는 효소를 씁니다. 결핵 표준 치료제인 이소니아지드가 실제로 막는 효소가 이것입니다. 문제는 사람에게도 같은 화학을 하는 효소가 있다는 것입니다.",
|
| 747 |
+
g1_a:"InhA — 결핵균의 에노일환원효소. 이소니아지드·에티오나미드가 작용하는 자리입니다.",
|
| 748 |
+
g1_b:"사람 FASN의 에노일환원효소 영역 — 같은 화학. 막으면 지방산 합성이 함께 멈춥니다.",
|
| 749 |
+
cd_label:"시즌 #2 마감까지", cd_over_label:"시즌 #2 마감",
|
| 750 |
+
prize_amt:"$2,000",
|
| 751 |
+
prize_body:"각 시즌이 끝날 때 <b>순위표 1위에게 상금을 드립니다.</b> 시즌 #2(결핵)는 <b>2026년 10월 31일</b> 마감이며 상금은 미화 2,000달러입니다.<br><br>이 돈은 여러분이 쓴 시간과 토큰에 대한 대가가 되기에는 턱없이 부족합니다. 그렇게 계산할 생각도 없습니다. <b>이것은 “당신이 한 일에 값이 있었다”고 말하는 방식입니다.</b><br><br>소외질환 연구가 힘든 이유는 아무도 고맙다고 하지 않기 때문입니다. 논문도 잘 안 실리고, 투자도 안 붙고, 아무도 보지 않습니다. 이 대회는 최소한 누가 무엇을 기여했는지 기록으로 남기고, 시즌마다 한 번 감사를 표합니다."
|
| 752 |
+
}
|
| 753 |
+
},
|
| 754 |
+
en: {
|
| 755 |
+
1: {
|
| 756 |
+
topic:"Malaria",
|
| 757 |
+
concept:"<b>Verifying and scoring malaria drug candidates discovered by AI</b>",
|
| 758 |
+
tagline:"However you found the candidate, it is checked computationally against the same rubric and scored. The target is PfDHODH, an enzyme the malaria parasite depends on; the human counterpart must be left alone.",
|
| 759 |
+
s1_p:"OpenAI, Claude, Gemini, Qwen, KIMI, DeepSeek - any model, any harness, or by hand. The goal is a small molecule that blocks PfDHODH, an enzyme the malaria parasite depends on.",
|
| 760 |
+
ov_pf:"parasite enzyme", ov_hs:"human enzyme",
|
| 761 |
+
g1_lead:"The malaria parasite depends on an enzyme called <b>PfDHODH</b> to survive. Block it and the parasite dies. The difficulty is that humans carry an enzyme of the same family.",
|
| 762 |
+
g1_a:"PfDHODH - the parasite's enzyme, which it cannot live without.",
|
| 763 |
+
g1_b:"Human DHODH - same family. Blocking it is immunosuppression, not treatment.",
|
| 764 |
+
cd_label:"Season #1 closes in", cd_over_label:"Season #1 closed",
|
| 765 |
+
prize_amt:"$1,000",
|
| 766 |
+
prize_body:"When a season closes, <b>the entry at the top of the table receives a prize.</b> Season #1 (Malaria) closes on <b>30 September 2026</b> and carries USD 1,000.<br><br>This does not come close to paying for the time and tokens you spent, and it is not meant to. <b>It is a way of saying that what you did had worth.</b><br><br>Neglected-disease work is thankless by construction: the papers are hard to place, the funding is not there, and mostly nobody looks. This challenge at least records who contributed what, and says thank you once a season."
|
| 767 |
+
},
|
| 768 |
+
2: {
|
| 769 |
+
topic:"Tuberculosis",
|
| 770 |
+
concept:"<b>Verifying and scoring tuberculosis drug candidates discovered by AI</b>",
|
| 771 |
+
tagline:"However you found the candidate, it is checked computationally against the same rubric and scored. The target is InhA, the enzyme M. tuberculosis uses to build its cell wall; the human enzyme that does the same chemistry must be left alone.",
|
| 772 |
+
why:"<b>Tuberculosis kills about 1.25 million people a year - more than any other single infection</b> (WHO Global TB Report 2024). Treatment exists. It also takes <b>six months of daily doses</b>, and resistant strains that shrug it off keep spreading. Patients with multidrug-resistant TB take drugs for over a year and close to half of them still are not cured.<br><br>New tuberculosis drugs are rare not because the science is stuck but because most patients are poor and there is no market. Forty years passed without a single new one before bedaquiline was approved in 2012.<br><br>Season #2 aims at that gap. <b>Bring what your AI found; it is verified computationally and ranked on the same scale as everyone else's.</b>",
|
| 773 |
+
s1_p:"OpenAI, Claude, Gemini, Qwen, KIMI, DeepSeek - any model, any harness, or by hand. The goal is a small molecule that blocks InhA, an enzyme M. tuberculosis depends on.",
|
| 774 |
+
ov_pf:"bacterial enzyme", ov_hs:"human enzyme",
|
| 775 |
+
g1_lead:"M. tuberculosis uses an enzyme called <b>InhA</b> to build the fatty acids in its cell wall. It is what isoniazid, the front-line tuberculosis drug, actually blocks. The difficulty is that humans run the same chemistry.",
|
| 776 |
+
g1_a:"InhA - the bacterium's enoyl reductase, where isoniazid and ethionamide act.",
|
| 777 |
+
g1_b:"The enoyl-reductase domain of human FASN - same chemistry. Blocking it stops our own fatty-acid synthesis.",
|
| 778 |
+
cd_label:"Season #2 closes in", cd_over_label:"Season #2 closed",
|
| 779 |
+
prize_amt:"$2,000",
|
| 780 |
+
prize_body:"When a season closes, <b>the entry at the top of the table receives a prize.</b> Season #2 (Tuberculosis) closes on <b>31 October 2026</b> and carries USD 2,000.<br><br>This does not come close to paying for the time and tokens you spent, and it is not meant to. <b>It is a way of saying that what you did had worth.</b><br><br>Neglected-disease work is thankless by construction: the papers are hard to place, the funding is not there, and mostly nobody looks. This challenge at least records who contributed what, and says thank you once a season."
|
| 781 |
+
}
|
| 782 |
+
}
|
| 783 |
+
};
|
| 784 |
+
|
| 785 |
+
// season copy wins over the shared strings; anything a season does not override is shared
|
| 786 |
+
function t(){ return Object.assign({}, T[LANG], (ST[LANG] || {})[SEASON] || {}); }
|
| 787 |
+
|
| 788 |
const submittedDate = e => {
|
| 789 |
if(e.is_anchor || !e.submitted_at) return "–";
|
| 790 |
const locale = LANG === "ko" ? "ko-KR" : "en-CA";
|
|
|
|
| 793 |
}).format(new Date(Number(e.submitted_at) * 1000));
|
| 794 |
};
|
| 795 |
|
| 796 |
+
function renderSeasonBar(){
|
| 797 |
+
if(!SEASONS) return;
|
| 798 |
+
const d = t();
|
| 799 |
+
$("#seasonbar").innerHTML = SEASONS.map(s => {
|
| 800 |
+
const nm = (ST[LANG] && ST[LANG][s.number] && ST[LANG][s.number].topic) || s.topic;
|
| 801 |
+
// a season that is not taking entries says so on the button, so nobody clicks
|
| 802 |
+
// through and writes out a molecule before finding out
|
| 803 |
+
const tag = s.open ? "" : `<span class="st">${d.season_soon}</span>`;
|
| 804 |
+
return `<button data-season="${s.number}" class="${s.number===SEASON?"on":""}">`
|
| 805 |
+
+ `#${s.number} ${nm}${tag}</button>`;
|
| 806 |
+
}).join("");
|
| 807 |
+
document.querySelectorAll("#seasonbar button").forEach(b =>
|
| 808 |
+
b.onclick = () => setSeason(parseInt(b.dataset.season,10)));
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
function setSeason(n){
|
| 812 |
+
if(n === SEASON) return;
|
| 813 |
+
SEASON = n;
|
| 814 |
+
localStorage.setItem("odc_season", String(n));
|
| 815 |
+
// the season belongs in the URL: a link to a season should open on that season
|
| 816 |
+
const u = new URL(location.href); u.searchParams.set("season", String(n));
|
| 817 |
+
history.replaceState(null, "", u);
|
| 818 |
+
DATA = null; MODELS = {models:[],totals:{models:0,submissions:0}};
|
| 819 |
+
$("#rows").innerHTML = "";
|
| 820 |
+
applyLang(); load();
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
function applyLang(){
|
| 824 |
const d = t();
|
| 825 |
document.documentElement.lang = LANG;
|
|
|
|
| 846 |
$("#rationale").placeholder = LANG==="ko" ? "왜 이 구조인지 간단히" : "why this structure, briefly";
|
| 847 |
document.querySelectorAll("#langbar button").forEach(b=>b.classList.toggle("on", b.dataset.lang===LANG));
|
| 848 |
$("#langnote").textContent = LANG_AUTO ? d.auto : d.manual;
|
| 849 |
+
const si = seasonInfo();
|
| 850 |
+
if(si){
|
| 851 |
+
$("#seasonhead").innerHTML = `#${si.number} <em>${d.topic}</em>`;
|
| 852 |
+
document.title = `Open Discovery Challenge #${si.number} ${d.topic}`;
|
| 853 |
+
}
|
| 854 |
+
if(d.prize_amt) $("#prize_amt").firstChild.nodeValue = d.prize_amt + " ";
|
| 855 |
+
renderSeasonBar();
|
| 856 |
renderAuth();
|
| 857 |
tickCountdown();
|
| 858 |
if(DATA) render();
|
|
|
|
| 961 |
let ME = {signed_in:false, oauth_configured:false};
|
| 962 |
let MODELS = {models:[], totals:{models:0, submissions:0}};
|
| 963 |
|
| 964 |
+
function seasonClosed(){
|
| 965 |
+
const si = seasonInfo();
|
| 966 |
+
return si ? !si.open : false;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
function renderAuth(){
|
| 970 |
const d = t(), bar = $("#authbar"), gate = $("#gate"), go = $("#go");
|
| 971 |
+
if(seasonClosed()){
|
| 972 |
+
// the server refuses these posts anyway; saying so here saves someone writing out an
|
| 973 |
+
// entry and being turned away after
|
| 974 |
+
bar.innerHTML = "";
|
| 975 |
+
gate.innerHTML = `<div class="lock">\u{1F6A7} ${d.season_notopen}</div>`;
|
| 976 |
+
go.disabled = true;
|
| 977 |
+
return;
|
| 978 |
+
}
|
| 979 |
if(ME.signed_in){
|
| 980 |
const u = ME.user || {};
|
| 981 |
bar.innerHTML = '<span class="u">' + (u.picture ? `<img src="${u.picture}" alt="">` : "")
|
|
|
|
| 1333 |
async function load(){
|
| 1334 |
try{
|
| 1335 |
const [lb, me, md] = await Promise.all([
|
| 1336 |
+
fetch("api/leaderboard" + qs()).then(r=>r.json()),
|
| 1337 |
fetch("api/me").then(r=>r.json()).catch(()=>ME),
|
| 1338 |
+
fetch("api/models" + qs()).then(r=>r.json()).catch(()=>MODELS),
|
| 1339 |
]);
|
| 1340 |
DATA = lb; ME = me; MODELS = md;
|
| 1341 |
if(DATA.season && DATA.season.closes) startCountdown(DATA.season.closes);
|
|
|
|
| 1349 |
const vis = (document.querySelector('input[name="vis"]:checked')||{}).value || "private";
|
| 1350 |
const body = {structure:$("#structure").value.trim(), display_name:$("#display_name").value.trim(),
|
| 1351 |
model_name:$("#model_name").value.trim(), rationale:$("#rationale").value.trim(),
|
| 1352 |
+
visibility: vis, season: SEASON};
|
| 1353 |
if(!body.structure || !body.display_name){ msg.className="msg err"; msg.textContent=d.need; return; }
|
| 1354 |
btn.disabled = true; msg.className="msg"; msg.textContent="";
|
| 1355 |
try{
|
| 1356 |
+
const r = await fetch("api/submit" + qs(),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
| 1357 |
const j = await r.json();
|
| 1358 |
if(j.accepted){ msg.className="msg ok"; msg.textContent=d.queued(j.candidate_id,j.queue_position,j.note);
|
| 1359 |
$("#structure").value=""; load(); }
|
|
|
|
| 1408 |
window.addEventListener("scroll", hideOverlay, {passive:true});
|
| 1409 |
window.addEventListener("resize", hideOverlay);
|
| 1410 |
|
| 1411 |
+
fetch("api/seasons").then(r=>r.json()).then(j=>{ SEASONS = j.seasons; applyLang(); })
|
| 1412 |
+
.catch(()=>{});
|
| 1413 |
applyLang(); load(); setInterval(load, 20000);
|
| 1414 |
</script>
|
| 1415 |
</body>
|
seasons.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Season registry. Everything that differs between seasons lives here and nowhere else.
|
| 3 |
+
|
| 4 |
+
Seasons run concurrently: #1 Malaria closes 30 September 2026, #2 Tuberculosis closes 31
|
| 5 |
+
October 2026, and for six weeks both take entries. So "the season" stopped being a
|
| 6 |
+
constant and became a lookup, and every read of the ledger is scoped by it.
|
| 7 |
+
|
| 8 |
+
Season 1 keeps the ledger paths it already had. Its 65 scored records stay exactly where
|
| 9 |
+
they are - re-homing live data to make the layout tidy is how records get lost, and there
|
| 10 |
+
is nothing wrong with the layout that a prefix cannot solve. Season 2 is namespaced under
|
| 11 |
+
s2/, and every season after it gets its own prefix the same way.
|
| 12 |
+
|
| 13 |
+
The scoring rubric is deliberately repeated per season rather than shared. Season 2 has to
|
| 14 |
+
correct three defects measured in Season 1 - a binding term that tracks halogen atomic
|
| 15 |
+
number, an Ames threshold that relegates three approved antimalarials, and a PAINS filter
|
| 16 |
+
that rejects atovaquone - and none of those corrections may reach Season 1, where entrants
|
| 17 |
+
have already been scored under the published rules.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
SEASONS = {
|
| 21 |
+
1: {
|
| 22 |
+
"number": 1,
|
| 23 |
+
"name": "Open Discovery Challenge",
|
| 24 |
+
"topic": "Malaria",
|
| 25 |
+
"topic_ko": "말라리아",
|
| 26 |
+
"organism": "Plasmodium falciparum",
|
| 27 |
+
"target": "PfDHODH",
|
| 28 |
+
"counter_target": "human DHODH",
|
| 29 |
+
"closes": "2026-09-30",
|
| 30 |
+
"prize_usd": 1000,
|
| 31 |
+
"weights": {"activity": 30, "binding": 20, "selectivity": 20,
|
| 32 |
+
"admet": 15, "novelty": 10, "synthesis": 5},
|
| 33 |
+
# empty prefix: season 1 predates the split and its paths are not moving
|
| 34 |
+
"prefix": "",
|
| 35 |
+
"anchors": "anchor_scores.json",
|
| 36 |
+
"open": True,
|
| 37 |
+
},
|
| 38 |
+
2: {
|
| 39 |
+
"number": 2,
|
| 40 |
+
"name": "Open Discovery Challenge",
|
| 41 |
+
"topic": "Tuberculosis",
|
| 42 |
+
"topic_ko": "결핵",
|
| 43 |
+
"organism": "Mycobacterium tuberculosis",
|
| 44 |
+
# InhA is the target isoniazid and ethionamide act through, it has direct
|
| 45 |
+
# (non-prodrug) inhibitors to calibrate against, 1,460 ChEMBL activities, and a
|
| 46 |
+
# 269-residue crystallised construct that docks sanely.
|
| 47 |
+
"target": "InhA",
|
| 48 |
+
# human fatty acid synthase carries the homologous enoyl-reductase chemistry, so
|
| 49 |
+
# selectivity keeps the same meaning it had in season 1: hit the pathogen's
|
| 50 |
+
# enzyme, not ours
|
| 51 |
+
"counter_target": "human FASN (ER domain)",
|
| 52 |
+
"closes": "2026-10-31",
|
| 53 |
+
"prize_usd": 2000,
|
| 54 |
+
# Not the season 1 split. The tuberculosis whole-cell model reaches AUROC 0.860 for
|
| 55 |
+
# strong activity on held-out scaffolds, but its lower bound has to sit ~2.0 log
|
| 56 |
+
# below the prediction to hold 90% coverage - MIC measurements disagree by ~0.6 log
|
| 57 |
+
# even within one assay. So activity is paid what it can support and the five points
|
| 58 |
+
# move to binding and selectivity, which are measured rather than predicted.
|
| 59 |
+
"weights": {"activity": 20, "binding": 25, "selectivity": 25,
|
| 60 |
+
"admet": 15, "novelty": 10, "synthesis": 5},
|
| 61 |
+
"prefix": "s2/",
|
| 62 |
+
"anchors": "anchor_scores_s2.json",
|
| 63 |
+
# submissions stay shut until the season 2 scorer is measured against its own
|
| 64 |
+
# anchor panel. An open form in front of a scorer nobody has checked collects
|
| 65 |
+
# entries it cannot honestly grade.
|
| 66 |
+
"open": False,
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
DEFAULT = 1
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get(n):
|
| 74 |
+
try:
|
| 75 |
+
n = int(n)
|
| 76 |
+
except (TypeError, ValueError):
|
| 77 |
+
n = DEFAULT
|
| 78 |
+
return SEASONS.get(n) or SEASONS[DEFAULT]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def public(s):
|
| 82 |
+
"""What the page is allowed to see. `prefix` and `anchors` are storage details."""
|
| 83 |
+
return {k: v for k, v in s.items() if k not in ("prefix", "anchors")}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def path(s, name):
|
| 87 |
+
"""Ledger path for this season, e.g. path(s, "leaderboard.json")."""
|
| 88 |
+
return s["prefix"] + name
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def listing():
|
| 92 |
+
return [public(SEASONS[n]) for n in sorted(SEASONS)]
|