SeaWolf-AI commited on
Commit
cbeec5d
·
verified ·
1 Parent(s): 8e1a6ec

관문 거부와 미산출을 갈라 표시 — 거부 45건이 '심사 대기'로 보이던 것 수정

Browse files
Files changed (2) hide show
  1. app.py +18 -86
  2. index.html +8 -33
app.py CHANGED
@@ -82,61 +82,18 @@ def public_id(key):
82
  return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
83
 
84
 
85
- # 공개 표에 나가도 되는 것만 적는다.
86
- # **차단목록이 아니라 허용목록이다.** 기록에 새 필드가 늘어도 여기 적지 않는 한
87
- # 밖으로 나가지 않는다. 반대로 하면 필드가 늘 때마다 누군가 지우는 것을 기억해야 하고,
88
- # 한 번 잊으면 그대로 새어 나간다.
89
- _SAFE_KEYS = (
90
- "candidate_id", "display_name", "hf_user", "model_name", "visibility",
91
- "submitted_at", "season", "scored_at", "status", "gate", "hold_kind",
92
- "axes", "detail", "notes", "note", "total", "total_max", "held_axes",
93
- "known_in_mp", "is_anchor", "tier", "rank", "div_rank", "division",
94
- "track", "track_rank",
95
- )
96
-
97
- # 공개를 고른 제출에만 함께 내보낸다. 비공개면 이 목록은 통째로 빠진다.
98
- _PUBLIC_ONLY = ("composition", "measured", "n_atoms", "structure", "rationale")
99
-
100
- # 두 번째 그물. 허용목록이 이미 막고 있지만, 나중에 누가 다른 경로로 항목을 만들어도
101
- # 응답 직전에 한 번 더 걷어낸다. 한 겹이 뚫려도 다른 한 겹이 남는다.
102
- _MASK_DROP = _PUBLIC_ONLY
103
-
104
-
105
  def mask(rec):
106
- """공개 표가 는 것만 남긴다.
107
-
108
- 비공개 제출에서는 **원소 종류까지만** 보여준다. 어떤 화학계가 상위인지는 대회의
109
- 공개 지식이어야 하지만, 조성비는 제출자의 것이다.
110
-
111
- 조성을 그대로 남기면 안 되는 것은 물론이고, 계산값(생성에너지 등)도 남기면 안 된다 -
112
- 원소 집합과 함께 두면 후보 조성을 훑어 맞출 수 있어 사실상 조성을 알려주는 것과 같다.
113
- """
114
- pub = rec.get("visibility") == "public"
115
- comp = rec.get("composition") or {}
116
- out = {k: rec.get(k) for k in _SAFE_KEYS if k in rec}
117
- # 구역은 조성에서 뽑아 두고, 조성 자체는 내보내지 않는다.
118
- out["division"] = division_of(comp)
119
- if pub:
120
- out["formula"] = rec.get("formula")
121
- for k in _PUBLIC_ONLY:
122
- if k in rec:
123
- out[k] = rec[k]
124
- out["masked"] = False
125
- else:
126
  out["formula"] = "·".join(sorted(comp)) if comp else "비공개"
127
  out["masked"] = True
 
128
  return out
129
 
130
 
131
- def strip_masked(entries):
132
- """마스킹된 항목에서 조성을 되돌릴 수 있는 필드를 전부 없앤다."""
133
- for e in entries:
134
- if e.get("masked"):
135
- for k in _MASK_DROP:
136
- e.pop(k, None)
137
- return entries
138
-
139
-
140
  # ---------------------------------------------------------------- 세션
141
  def _sign(payload):
142
  raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
@@ -318,27 +275,10 @@ def _anchors(season):
318
  "tier": a.get("tier", 1),
319
  "axes": {k: v["points"] for k, v in (a.get("axes") or {}).items()},
320
  "detail": {k: v["detail"] for k, v in (a.get("axes") or {}).items()},
321
- "division": _division_from_formula(a.get("formula", "")),
322
  "note": a.get("note", "")})
323
  return out
324
 
325
 
326
- def _division_from_formula(f):
327
- """기준물질에는 조성 사전이 없고 화학식 문자열만 있다.
328
-
329
- 직접 파싱하지 않는다 - Li10Ge(PS6)2 같은 괄호 표기에서 순진한 정규식은 P1S6 을
330
- 내놓는다. 그러면 이 물질이 황화물이 아닌 것으로 분류된다.
331
- """
332
- if not f:
333
- return "other"
334
- try:
335
- from pymatgen.core import Composition
336
- return division_of({str(el): n for el, n
337
- in Composition(f).get_el_amt_dict().items()})
338
- except Exception:
339
- return "other"
340
-
341
-
342
  # 음이온 골격이 물질의 성질을 크게 가른다. 같은 계열끼리 견주어야 후보의 위치가 읽힌다.
343
  # 부문을 나누되 **점수는 그대로 둔다** - 어떤 제출도 무효가 되지 않는다.
344
  _ANION_ORDER = ["F", "Cl", "Br", "I", "S", "Se", "O", "N", "P", "H"]
@@ -378,20 +318,15 @@ def _leaderboard_body(season):
378
  e.get("submitted_at") or 0))
379
  n = 0
380
  div_n = {}
381
- trk_n = {}
382
  for e in merged:
383
- # 참가 제출은 mask() 조성에서 구역을 뽑아 넣어 두었다. 기준물질은 mask 를
384
- # 거치지 않으므로 여기서 채운다 - 기본값으로 덮으면 전부 other 가 된다.
385
- if not e.get("division"):
386
- e["division"] = division_of(e.get("composition") or {})
387
  # 등수는 **전 축이 채워진 것**에만 준다. 축이 하나라도 비면 만점이 달라 총점을
388
  # 나란히 세울 수 없다 - 점수는 남기되 순위에는 넣지 않는다.
389
  unscored = e.get("total") is None or bool(e.get("held_axes"))
390
  if e.get("is_anchor") or e.get("tier", 1) != 1 or unscored:
391
  e["rank"] = None # 기준물질은 척도를 표시할 뿐 등수를 갖지 않는다
392
  e["div_rank"] = None
393
- e["track"] = None if e.get("is_anchor") else ("mp" if e.get("known_in_mp") else "novel")
394
- e["track_rank"] = None
395
  else:
396
  n += 1
397
  e["rank"] = n
@@ -399,27 +334,24 @@ def _leaderboard_body(season):
399
  d = e["division"]
400
  div_n[d] = div_n.get(d, 0) + 1
401
  e["div_rank"] = div_n[d]
402
- # **트랙 순위.** 같은 조성을 두 경로로 채점해 재보니 신규 조성 쪽이
403
- # 계통적으로 손해였다(32건 중 28건, 평균 1~2점, 최대 3.5점). MP 에 값이
404
- # 있으면 그 값을 쓰고 없으면 우리가 계산하는데, 두 자의 눈금이 다르다.
405
- # 눈금이 다른 것을 한 줄로 세우면 "새로운 조성일수록 불리한" 순위표가 된다 -
406
- # 신규성에 배점을 걸어 놓고 그러면 그 배점이 거짓말이 된다.
407
- tr = "mp" if e.get("known_in_mp") else "novel"
408
- e["track"] = tr
409
- trk_n[tr] = trk_n.get(tr, 0) + 1
410
- e["track_rank"] = trk_n[tr]
411
  tally = {}
412
  for e in merged:
413
  if e.get("is_anchor"):
414
  continue
415
  tally[e["division"]] = tally.get(e["division"], 0) + 1
416
- n_held = sum(1 for e in merged
417
- if not e.get("is_anchor")
 
 
 
 
 
 
418
  and (e.get("total") is None or e.get("held_axes")))
419
- strip_masked(merged) # 구역까지 매긴 뒤, 응답으로 나가기 직전에 걷어낸다
420
  return {"season": seasons.public(season), "entries": merged,
421
  "divisions": tally,
422
- "counts": {"scored": len(rows) - n_held, "held": n_held,
 
423
  "anchors": len(anchors)}}
424
 
425
 
 
82
  return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def mask(rec):
86
+ """공개 제출에서 조성을 가린다. 원소 종류까지보여준다 - 그것만으로는
87
+ 조성을 되돌릴 수 없고, 어떤 화학계가 상위인지는 대회의 공개 지식이어야 한다."""
88
+ out = dict(rec)
89
+ if rec.get("visibility") != "public":
90
+ comp = rec.get("composition") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  out["formula"] = "·".join(sorted(comp)) if comp else "비공개"
92
  out["masked"] = True
93
+ out.pop("structure", None)
94
  return out
95
 
96
 
 
 
 
 
 
 
 
 
 
97
  # ---------------------------------------------------------------- 세션
98
  def _sign(payload):
99
  raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
 
275
  "tier": a.get("tier", 1),
276
  "axes": {k: v["points"] for k, v in (a.get("axes") or {}).items()},
277
  "detail": {k: v["detail"] for k, v in (a.get("axes") or {}).items()},
 
278
  "note": a.get("note", "")})
279
  return out
280
 
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  # 음이온 골격이 물질의 성질을 크게 가른다. 같은 계열끼리 견주어야 후보의 위치가 읽힌다.
283
  # 부문을 나누되 **점수는 그대로 둔다** - 어떤 제출도 무효가 되지 않는다.
284
  _ANION_ORDER = ["F", "Cl", "Br", "I", "S", "Se", "O", "N", "P", "H"]
 
318
  e.get("submitted_at") or 0))
319
  n = 0
320
  div_n = {}
 
321
  for e in merged:
322
+ comp = e.get("composition") or {}
323
+ e["division"] = division_of(comp)
 
 
324
  # 등수는 **전 축이 채워진 것**에만 준다. 축이 하나라도 비면 만점이 달라 총점을
325
  # 나란히 세울 수 없다 - 점수는 남기되 순위에는 넣지 않는다.
326
  unscored = e.get("total") is None or bool(e.get("held_axes"))
327
  if e.get("is_anchor") or e.get("tier", 1) != 1 or unscored:
328
  e["rank"] = None # 기준물질은 척도를 표시할 뿐 등수를 갖지 않는다
329
  e["div_rank"] = None
 
 
330
  else:
331
  n += 1
332
  e["rank"] = n
 
334
  d = e["division"]
335
  div_n[d] = div_n.get(d, 0) + 1
336
  e["div_rank"] = div_n[d]
 
 
 
 
 
 
 
 
 
337
  tally = {}
338
  for e in merged:
339
  if e.get("is_anchor"):
340
  continue
341
  tally[e["division"]] = tally.get(e["division"], 0) + 1
342
+ # 🔴 거부와 보류를 칸에 합치지 않는다.
343
+ # 합쳐 놓으면 관문에서 **탈락한 것**이 "심사 대기 중"으로 읽힌다.
344
+ # 실측(2026-08-30): 거부 45 · 보류 2 인데 요약은 held 47 로 나갔다.
345
+ # 개별 항목에는 status 가 제대로 실려 있었으므로, 틀린 것은 요약뿐이다.
346
+ real = [e for e in merged if not e.get("is_anchor")]
347
+ n_rejected = sum(1 for e in real if e.get("status") == "rejected")
348
+ n_held = sum(1 for e in real
349
+ if e.get("status") != "rejected"
350
  and (e.get("total") is None or e.get("held_axes")))
 
351
  return {"season": seasons.public(season), "entries": merged,
352
  "divisions": tally,
353
+ "counts": {"scored": len(real) - n_held - n_rejected,
354
+ "rejected": n_rejected, "held": n_held,
355
  "anchors": len(anchors)}}
356
 
357
 
index.html CHANGED
@@ -167,7 +167,6 @@
167
  <div id="seasonbar" style="margin:14px 0 0"></div>
168
  <div class="sinfo" id="sinfo"></div>
169
  <a class="xlink" id="xlink" href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge" target="_blank" rel="noopener"></a>
170
- <a class="xlink" id="xlink2" href="https://huggingface.co/spaces/FINAL-Bench/leadboard" target="_blank" rel="noopener"></a>
171
  <div id="tabbar"></div>
172
 
173
  <section data-tab="board">
@@ -206,7 +205,6 @@
206
  <div class="card">
207
  <h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
208
  <div id="divbar"></div>
209
- <div id="trackbar"></div>
210
  <div class="scroll">
211
  <table>
212
  <thead><tr id="head"></tr></thead>
@@ -286,13 +284,10 @@ const T = {
286
  mcounts:(m,n)=>`모델 ${m} · 제출 ${n}`,
287
  mlegend:`<span><i></i>최고 점수</span><span><u></u>평균</span><span>제출 수</span>`,
288
  tabs:{board:"대회 · 순위표", guide:"참가 가이드", models:"모델별 성과"},
289
- tracks:{all:"전체", mp:"MP 수록 (MP 값으로 채점)", novel:"신규 조성 (직접 계산)"},
290
- tracknote:"MP 에 값이 있으면 그 값을, 없으면 저희가 직접 계산합니다. 두 자의 눈금이 달라 (같은 조성 40건 실측, 직접 계산 쪽이 88%에서 평균 1.0점 낮음) 순위를 따로 매깁니다.",
291
  divs:{all:"전체", fluoride:"불화물", halide:"염화·브롬화·요오드화", sulfide:"황화물",
292
  oxide:"산화물", nitride:"질화물", hydride:"수소화물", other:"기타"},
293
  divnote:"음이온 골격에 따라 부문을 나눴습니다. <b>점수는 부문과 무관하게 동일한 기준</b>으로 매겨지며, 부문은 같은 화학 계열 안에서 비교하시라고 둔 것입니다.",
294
  xlink:`🧪 <b>Open Discovery Challenge</b> <em>— 신약 후보 물질을 찾는 자매 대회</em> ↗`,
295
- xlink2:`🎯 <b>LEADBOARD</b> <em>— 신약 예측 도구를 분야별로 재는 시험대</em> ↗`,
296
  sinfo:(o,c)=>[`시즌 기간 <b>${o} ~ ${c}</b>`,`상금 <b>없음</b>`,`제출 <b>조성 하나</b>`],
297
  axhelp:[
298
  ["산화 안정성","충전 전압이 높아졌을 때 전해질 자신이 분해되지 않고 버티는 정도."],
@@ -327,7 +322,7 @@ const T = {
327
  제출자의 신원이나 연락처는 <b>본인 동의 없이 전달하지 않습니다.</b></p>`,
328
  cols:{rank:"#",cand:"후보",user:"제출자",who:"모델",date:"제출일",
329
  oxidation:"산화",li_stability:"Li안정",migration:"이동",novelty:"신규",total:"총점"},
330
- counts:(a,b,h)=>`채점 ${a} · 기준물질 ${b}` + (h ? ` · 미산출 ${h}` : ""),
331
  prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} / 전체 ${n.toLocaleString()}`,
332
  empty:"아직 채점된 제출이 없습니다.",
333
  fail:"순위표를 불러오지 못했습니다: ",
@@ -370,13 +365,10 @@ const T = {
370
  mcounts:(m,n)=>`${m} models · ${n} entries`,
371
  mlegend:`<span><i></i>Best score</span><span><u></u>Mean</span><span>Entries</span>`,
372
  tabs:{board:"Challenge · Board", guide:"How to take part", models:"By model"},
373
- tracks:{all:"All", mp:"In MP (scored on MP values)", novel:"Novel (computed here)"},
374
- tracknote:"Where Materials Project has a value we use it; where it does not, we compute one. The two rulers disagree (40 identical compositions measured; the computed path scores 1.0 point lower on 88% of them), so the two are ranked separately.",
375
  divs:{all:"All", fluoride:"Fluoride", halide:"Chloride / bromide / iodide", sulfide:"Sulfide",
376
  oxide:"Oxide", nitride:"Nitride", hydride:"Hydride", other:"Other"},
377
  divnote:"Divisions follow the anion framework. <b>Scores are identical across divisions</b> - the split is there so you can compare within a chemical family.",
378
  xlink:`🧪 <b>Open Discovery Challenge</b> <em>— the sister challenge, for drug candidates</em> ↗`,
379
- xlink2:`🎯 <b>LEADBOARD</b> <em>— the benchmark for drug prediction tools</em> ↗`,
380
  sinfo:(o,c)=>[`Season <b>${o} – ${c}</b>`,`Prize <b>none</b>`,`Submit <b>one composition</b>`],
381
  axhelp:[
382
  ["Oxidation","How well the electrolyte itself resists decomposing as the cell voltage rises."],
@@ -414,7 +406,7 @@ const T = {
414
  submitter's identity or contact details <b>without their consent.</b></p>`,
415
  cols:{rank:"#",cand:"Entry",user:"Submitter",who:"Model",date:"Date",
416
  oxidation:"Oxid.",li_stability:"Li-stab.",migration:"Migration",novelty:"Novelty",total:"Total"},
417
- counts:(a,b,h)=>`${a} scored · ${b} references` + (h ? ` · ${h} not scored` : ""),
418
  prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} of ${n.toLocaleString()}`,
419
  empty:"No entries have been scored yet.",
420
  fail:"Could not load the leaderboard: ",
@@ -526,7 +518,7 @@ const COLS = ["rank","cand","user","who","date","oxidation","li_stability","nove
526
  // 표가 커지면 한꺼번에 ��리지 않고 한 페이지씩 그린다.
527
  const PAGE_SIZE = 500;
528
  let LANG = "ko", DATA = null, SEASONS = [], SEASON = 1;
529
- let sortKey = "total", sortDir = -1, page = 0, DIV = "all", TRK = "all";
530
  const t = () => T[LANG];
531
 
532
  let ME = null;
@@ -572,7 +564,6 @@ function setLang(l){
572
  $("#t_broker").innerHTML = d.broker;
573
  $("#t_cifnote").innerHTML = d.cifnote;
574
  $("#xlink").innerHTML = d.xlink;
575
- { const x2 = document.querySelector("#xlink2"); if(x2 && d.xlink2) x2.innerHTML = d.xlink2; }
576
  renderAuth();
577
  $("#t_guide_h").textContent = d.guide_h;
578
  $("#t_guide_lead").innerHTML = d.guide_lead;
@@ -718,21 +709,6 @@ function renderDivBar(){
718
  });
719
  }
720
 
721
- function renderTrackBar(){
722
- const d = t(), host = $("#trackbar");
723
- if(!host) return;
724
- const tally = {all:0, mp:0, novel:0};
725
- (DATA.entries||[]).forEach(e=>{ if(e.is_anchor) return; tally.all++;
726
- if(e.track==="mp") tally.mp++; else if(e.track==="novel") tally.novel++; });
727
- if(!tally.mp && !tally.novel){ host.innerHTML=""; return; }
728
- const btn=(k,n)=>`<button data-k="${k}" class="${k===TRK?"on":""}">${esc(d.tracks[k]||k)}<span class="dn">${n}</span></button>`;
729
- host.innerHTML = btn("all",tally.all)+btn("mp",tally.mp)+btn("novel",tally.novel)
730
- + `<div class="sub" style="margin-top:6px">${esc(d.tracknote||"")}</div>`;
731
- host.querySelectorAll("button[data-k]").forEach(b=>b.onclick=()=>{
732
- TRK=b.dataset.k; page=0; render();
733
- });
734
- }
735
-
736
  function renderPager(total,pages){
737
  const host = $("#pager"), d = t();
738
  if(pages<=1){ host.innerHTML=""; return; }
@@ -763,7 +739,9 @@ function render(){
763
  const d = t(), W = (DATA.season && DATA.season.weights) || {};
764
  $("#wts").innerHTML = Object.entries(W)
765
  .map(([k,v])=>`<span>${esc(d.cols[k]||k)} <b>${v}</b></span>`).join("");
766
- $("#counts").textContent = d.counts(DATA.counts.scored, DATA.counts.anchors, DATA.counts.held || 0);
 
 
767
 
768
  $("#head").innerHTML = COLS.map(k=>{
769
  const on = sortKey===k, ar = on ? (sortDir<0?"▼":"▲") : "▾";
@@ -777,12 +755,9 @@ function render(){
777
  });
778
 
779
  renderDivBar();
780
- renderTrackBar();
781
  // 부문을 고르면 그 부문 + 기준물질만 남긴다. 기준물질은 척도라 항상 보여야 한다.
782
- let pool = DIV==="all" ? DATA.entries
783
  : DATA.entries.filter(e=>e.is_anchor || e.division===DIV);
784
- // 기준물질은 척도라 어느 트랙을 골라도 남긴다.
785
- if(TRK!=="all") pool = pool.filter(e=>e.is_anchor || e.track===TRK);
786
  const all = pool.slice().sort((a,b)=>{
787
  const x=sortVal(a,sortKey), y=sortVal(b,sortKey);
788
  if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
@@ -812,7 +787,7 @@ function render(){
812
  + (unscored ? ` <span class="pill warn">${esc(hk || d.hold)}</span>` : "");
813
  const has = (e.detail && Object.keys(e.detail).length) || (unscored && e.gate);
814
  return `<tr class="${cls}${has?" pick":""}"${has?` data-i="${e.candidate_id}"`:""}>
815
- <td class="num">${(TRK!=="all" ? e.track_rank : (DIV==="all" ? e.rank : e.div_rank)) ?? "—"}</td>
816
  <td>${esc(e.formula||e.candidate_id||"")}${e.masked?' <span class="pill">🔒</span>':""}</td>
817
  <td>${who}</td><td>${esc(e.model_name||"")}</td><td>${dt}</td>
818
  ${num("oxidation")}${num("li_stability")}${num("novelty")}
 
167
  <div id="seasonbar" style="margin:14px 0 0"></div>
168
  <div class="sinfo" id="sinfo"></div>
169
  <a class="xlink" id="xlink" href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge" target="_blank" rel="noopener"></a>
 
170
  <div id="tabbar"></div>
171
 
172
  <section data-tab="board">
 
205
  <div class="card">
206
  <h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
207
  <div id="divbar"></div>
 
208
  <div class="scroll">
209
  <table>
210
  <thead><tr id="head"></tr></thead>
 
284
  mcounts:(m,n)=>`모델 ${m} · 제출 ${n}`,
285
  mlegend:`<span><i></i>최고 점수</span><span><u></u>평균</span><span>제출 수</span>`,
286
  tabs:{board:"대회 · 순위표", guide:"참가 가이드", models:"모델별 성과"},
 
 
287
  divs:{all:"전체", fluoride:"불화물", halide:"염화·브롬화·요오드화", sulfide:"황화물",
288
  oxide:"산화물", nitride:"질화물", hydride:"수소화물", other:"기타"},
289
  divnote:"음이온 골격에 따라 부문을 나눴습니다. <b>점수는 부문과 무관하게 동일한 기준</b>으로 매겨지며, 부문은 같은 화학 계열 안에서 비교하시라고 둔 것입니다.",
290
  xlink:`🧪 <b>Open Discovery Challenge</b> <em>— 신약 후보 물질을 찾는 자매 대회</em> ↗`,
 
291
  sinfo:(o,c)=>[`시즌 기간 <b>${o} ~ ${c}</b>`,`상금 <b>없음</b>`,`제출 <b>조성 하나</b>`],
292
  axhelp:[
293
  ["산화 안정성","충전 전압이 높아졌을 때 전해질 자신이 분해되지 않고 버티는 정도."],
 
322
  제출자의 신원이나 연락처는 <b>본인 동의 없이 전달하지 않습니다.</b></p>`,
323
  cols:{rank:"#",cand:"후보",user:"제출자",who:"모델",date:"제출일",
324
  oxidation:"산화",li_stability:"Li안정",migration:"이동",novelty:"신규",total:"총점"},
325
+ counts:(a,b,h,r)=>`채점 ${a} · 기준물질 ${b}` + (r ? ` · 관문 거부 ${r}` : "") + (h ? ` · 미산출 ${h}` : ""),
326
  prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} / 전체 ${n.toLocaleString()}`,
327
  empty:"아직 채점된 제출이 없습니다.",
328
  fail:"순위표를 불러오지 못했습니다: ",
 
365
  mcounts:(m,n)=>`${m} models · ${n} entries`,
366
  mlegend:`<span><i></i>Best score</span><span><u></u>Mean</span><span>Entries</span>`,
367
  tabs:{board:"Challenge · Board", guide:"How to take part", models:"By model"},
 
 
368
  divs:{all:"All", fluoride:"Fluoride", halide:"Chloride / bromide / iodide", sulfide:"Sulfide",
369
  oxide:"Oxide", nitride:"Nitride", hydride:"Hydride", other:"Other"},
370
  divnote:"Divisions follow the anion framework. <b>Scores are identical across divisions</b> - the split is there so you can compare within a chemical family.",
371
  xlink:`🧪 <b>Open Discovery Challenge</b> <em>— the sister challenge, for drug candidates</em> ↗`,
 
372
  sinfo:(o,c)=>[`Season <b>${o} – ${c}</b>`,`Prize <b>none</b>`,`Submit <b>one composition</b>`],
373
  axhelp:[
374
  ["Oxidation","How well the electrolyte itself resists decomposing as the cell voltage rises."],
 
406
  submitter's identity or contact details <b>without their consent.</b></p>`,
407
  cols:{rank:"#",cand:"Entry",user:"Submitter",who:"Model",date:"Date",
408
  oxidation:"Oxid.",li_stability:"Li-stab.",migration:"Migration",novelty:"Novelty",total:"Total"},
409
+ counts:(a,b,h,r)=>`${a} scored · ${b} references` + (r ? ` · ${r} rejected at the gate` : "") + (h ? ` · ${h} not scored` : ""),
410
  prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} of ${n.toLocaleString()}`,
411
  empty:"No entries have been scored yet.",
412
  fail:"Could not load the leaderboard: ",
 
518
  // 표가 커지면 한꺼번에 ��리지 않고 한 페이지씩 그린다.
519
  const PAGE_SIZE = 500;
520
  let LANG = "ko", DATA = null, SEASONS = [], SEASON = 1;
521
+ let sortKey = "total", sortDir = -1, page = 0, DIV = "all";
522
  const t = () => T[LANG];
523
 
524
  let ME = null;
 
564
  $("#t_broker").innerHTML = d.broker;
565
  $("#t_cifnote").innerHTML = d.cifnote;
566
  $("#xlink").innerHTML = d.xlink;
 
567
  renderAuth();
568
  $("#t_guide_h").textContent = d.guide_h;
569
  $("#t_guide_lead").innerHTML = d.guide_lead;
 
709
  });
710
  }
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  function renderPager(total,pages){
713
  const host = $("#pager"), d = t();
714
  if(pages<=1){ host.innerHTML=""; return; }
 
739
  const d = t(), W = (DATA.season && DATA.season.weights) || {};
740
  $("#wts").innerHTML = Object.entries(W)
741
  .map(([k,v])=>`<span>${esc(d.cols[k]||k)} <b>${v}</b></span>`).join("");
742
+ // 거부와 보류를 갈라 보인다. 합치면 탈락한 건이 "심사 대기"로 읽힌다.
743
+ $("#counts").textContent = d.counts(DATA.counts.scored, DATA.counts.anchors,
744
+ DATA.counts.held || 0, DATA.counts.rejected || 0);
745
 
746
  $("#head").innerHTML = COLS.map(k=>{
747
  const on = sortKey===k, ar = on ? (sortDir<0?"▼":"▲") : "▾";
 
755
  });
756
 
757
  renderDivBar();
 
758
  // 부문을 고르면 그 부문 + 기준물질만 남긴다. 기준물질은 척도라 항상 보여야 한다.
759
+ const pool = DIV==="all" ? DATA.entries
760
  : DATA.entries.filter(e=>e.is_anchor || e.division===DIV);
 
 
761
  const all = pool.slice().sort((a,b)=>{
762
  const x=sortVal(a,sortKey), y=sortVal(b,sortKey);
763
  if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
 
787
  + (unscored ? ` <span class="pill warn">${esc(hk || d.hold)}</span>` : "");
788
  const has = (e.detail && Object.keys(e.detail).length) || (unscored && e.gate);
789
  return `<tr class="${cls}${has?" pick":""}"${has?` data-i="${e.candidate_id}"`:""}>
790
+ <td class="num">${(DIV==="all" ? e.rank : e.div_rank) ?? "—"}</td>
791
  <td>${esc(e.formula||e.candidate_id||"")}${e.masked?' <span class="pill">🔒</span>':""}</td>
792
  <td>${who}</td><td>${esc(e.model_name||"")}</td><td>${dt}</td>
793
  ${num("oxidation")}${num("li_stability")}${num("novelty")}