SeaWolf-AI commited on
Commit
3f147f0
·
verified ·
1 Parent(s): e192b3e

Add season 2 (Tuberculosis, closes 31 October) as a season switch; per-season copy

Browse files
Files changed (3) hide show
  1. app.py +57 -26
  2. index.html +149 -7
  3. 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
- ANCHORS = os.path.join(HERE, "data", "anchor_scores.json")
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 scores, so a
88
- page load is two requests rather than one per entry."""
89
- return CACHE.get("ids", lambda: store.listdir("submissions"))
 
 
 
90
 
91
 
92
- def _board():
93
- return CACHE.get("board", lambda: store.read("leaderboard.json", default={}) or {})
 
 
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 SEASON
 
 
 
 
 
 
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
- if store.read("submissions/%s.json" % public_id(v["inchikey"])) is not None:
 
 
 
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("submissions/%s.json" % rec["id"], rec,
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
- rows = list((_board().get("entries") or {}).values())
 
291
  anchors = []
292
- if os.path.exists(ANCHORS):
293
- for a in json.load(open(ANCHORS, encoding="utf-8")):
 
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": SEASON, "entries": merged,
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
- for r in (_board().get("entries") or {}).values():
 
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 = {e.get("smiles") for e in (_board().get("entries") or {}).values()
408
- if e.get("visibility") == "public" and e.get("smiles")}
 
 
 
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
- ids = _submissions()
440
- scored = set((_board().get("entries") or {}).keys())
 
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">&nbsp;</span></div>
334
  <p class="eyebrow">Open Discovery Challenge</p>
335
- <h1>#1 &nbsp;<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
- const t = () => T[LANG];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">&nbsp;</span></div>
345
  <p class="eyebrow">Open Discovery Challenge</p>
346
+ <h1 id="seasonhead">#1 &nbsp;<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} &nbsp;<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)]