SeaWolf-AI commited on
Commit
6a38202
·
verified ·
1 Parent(s): 2bb302c

Bilingual UI (ko/en) with browser detection and a language switch

Browse files

All copy moves into a string dictionary served at /i18n.js, so the English edition cannot silently fall behind. Language resolves from a stored choice, then the browser locale. Page order now leads with disciplines, then the measurement-limit evidence, then the spec.

Files changed (4) hide show
  1. app.py +7 -0
  2. categories.json +22 -11
  3. i18n.js +218 -0
  4. index.html +190 -211
app.py CHANGED
@@ -183,6 +183,13 @@ def index():
183
  return FileResponse(os.path.join(HERE, "index.html"))
184
 
185
 
 
 
 
 
 
 
 
186
  @app.get("/api/categories")
187
  def api_categories():
188
  c = categories()
 
183
  return FileResponse(os.path.join(HERE, "index.html"))
184
 
185
 
186
+ @app.get("/i18n.js")
187
+ def i18n():
188
+ """문자열 사전. 화면 코드와 분리해 두어야 영어판이 조용히 뒤처지지 않는다."""
189
+ return FileResponse(os.path.join(HERE, "i18n.js"),
190
+ media_type="application/javascript")
191
+
192
+
193
  @app.get("/api/categories")
194
  def api_categories():
195
  c = categories()
categories.json CHANGED
@@ -10,7 +10,8 @@
10
  "planned": 8,
11
  "boards": [
12
  "Solubility"
13
- ]
 
14
  },
15
  {
16
  "id": "D",
@@ -19,7 +20,8 @@
19
  "icon": "🩸",
20
  "desc": "혈액 단백질에 붙는가 · 뇌까지 가는가",
21
  "planned": 5,
22
- "boards": []
 
23
  },
24
  {
25
  "id": "M",
@@ -32,7 +34,8 @@
32
  "CYP3A4",
33
  "CYP2D6",
34
  "CYP2C9"
35
- ]
 
36
  },
37
  {
38
  "id": "E",
@@ -41,7 +44,8 @@
41
  "icon": "💧",
42
  "desc": "몸 밖으로 언제 나가는가",
43
  "planned": 2,
44
- "boards": []
 
45
  },
46
  {
47
  "id": "T",
@@ -52,7 +56,8 @@
52
  "planned": 7,
53
  "boards": [
54
  "hERG"
55
- ]
 
56
  },
57
  {
58
  "id": "P",
@@ -65,7 +70,8 @@
65
  "AChE",
66
  "MAOB",
67
  "COX2"
68
- ]
 
69
  },
70
  {
71
  "id": "K",
@@ -86,7 +92,8 @@
86
  "BRAF",
87
  "KIT",
88
  "ALK"
89
- ]
 
90
  },
91
  {
92
  "id": "S",
@@ -95,7 +102,8 @@
95
  "icon": "🧬",
96
  "desc": "어떤 자세로 붙는가 · 그 자세가 물리적으로 말이 되는가",
97
  "planned": 4,
98
- "boards": []
 
99
  },
100
  {
101
  "id": "X",
@@ -104,7 +112,8 @@
104
  "icon": "🔬",
105
  "desc": "약이 실제 세포에 무슨 짓을 하는가",
106
  "planned": 5,
107
- "boards": []
 
108
  },
109
  {
110
  "id": "C",
@@ -113,7 +122,8 @@
113
  "icon": "🏥",
114
  "desc": "사람 몸에서의 농도 · 약물 충돌 · 용량. 겨룰 표가 아직 없는 자리",
115
  "planned": 6,
116
- "boards": []
 
117
  },
118
  {
119
  "id": "G",
@@ -122,7 +132,8 @@
122
  "icon": "⚗️",
123
  "desc": "새로 만들어 내는 쪽 · 소분자를 넘어서는 쪽",
124
  "planned": 5,
125
- "boards": []
 
126
  }
127
  ]
128
  }
 
10
  "planned": 8,
11
  "boards": [
12
  "Solubility"
13
+ ],
14
+ "desc_en": "Whether an orally dosed compound enters the body at all"
15
  },
16
  {
17
  "id": "D",
 
20
  "icon": "🩸",
21
  "desc": "혈액 단백질에 붙는가 · 뇌까지 가는가",
22
  "planned": 5,
23
+ "boards": [],
24
+ "desc_en": "Binding to plasma protein; passage across the blood-brain barrier"
25
  },
26
  {
27
  "id": "M",
 
34
  "CYP3A4",
35
  "CYP2D6",
36
  "CYP2C9"
37
+ ],
38
+ "desc_en": "How quickly the liver clears it, and which enzymes act on it"
39
  },
40
  {
41
  "id": "E",
 
44
  "icon": "💧",
45
  "desc": "몸 밖으로 언제 나가는가",
46
  "planned": 2,
47
+ "boards": [],
48
+ "desc_en": "When the compound leaves the body"
49
  },
50
  {
51
  "id": "T",
 
56
  "planned": 7,
57
  "boards": [
58
  "hERG"
59
+ ],
60
+ "desc_en": "Cardiac, hepatic and mutagenic liability — where candidates most often fail"
61
  },
62
  {
63
  "id": "P",
 
70
  "AChE",
71
  "MAOB",
72
  "COX2"
73
+ ],
74
+ "desc_en": "How tightly a compound binds its intended target"
75
  },
76
  {
77
  "id": "K",
 
92
  "BRAF",
93
  "KIT",
94
  "ALK"
95
+ ],
96
+ "desc_en": "Which of several hundred kinases are engaged, and which are spared"
97
  },
98
  {
99
  "id": "S",
 
102
  "icon": "🧬",
103
  "desc": "어떤 자세로 붙는가 · 그 자세가 물리적으로 말이 되는가",
104
  "planned": 4,
105
+ "boards": [],
106
+ "desc_en": "The binding pose, and whether that pose is physically admissible"
107
  },
108
  {
109
  "id": "X",
 
112
  "icon": "🔬",
113
  "desc": "약이 실제 세포에 무슨 짓을 하는가",
114
  "planned": 5,
115
+ "boards": [],
116
+ "desc_en": "What a compound actually does to a living cell"
117
  },
118
  {
119
  "id": "C",
 
122
  "icon": "🏥",
123
  "desc": "사람 몸에서의 농도 · 약물 충돌 · 용량. 겨룰 표가 아직 없는 자리",
124
  "planned": 6,
125
+ "boards": [],
126
+ "desc_en": "Human exposure, drug-drug interaction and dose — endpoints with no leaderboard yet"
127
  },
128
  {
129
  "id": "G",
 
132
  "icon": "⚗️",
133
  "desc": "새로 만들어 내는 쪽 · 소분자를 넘어서는 쪽",
134
  "planned": 5,
135
+ "boards": [],
136
+ "desc_en": "Generating new matter, and modalities beyond small molecules"
137
  }
138
  ]
139
  }
i18n.js ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* LEADBOARD 문자열 사전.
2
+ *
3
+ * 문자열은 전부 여기에만 둔다. 화면 코드에 한국어가 섞이면 영어판이 조용히 뒤처진다.
4
+ * 작은따옴표를 쓰지 않는다 - 영문 축약형(don't) 하나가 문자열을 끊어 사전 전체를
5
+ * 무너뜨린 전례가 있다. 축약형은 풀어 쓴다.
6
+ */
7
+ window.T = {
8
+ ko: {
9
+ htmlLang: "ko",
10
+ title: "LEADBOARD — 신약 예측 도구 시험대",
11
+ navSpec: "규격",
12
+ login: "HF 로그인", logout: "로그아웃",
13
+ langBtn: "EN",
14
+
15
+ heroKicker: "AI 신약 개발 벤치마크",
16
+ heroTag: "신약 예측 도구를<br>분야별로, 동일한 기준으로 평가합니다",
17
+ heroLede: "본 시험대는 정답을 배포하지 않습니다. 대신 <b>학습하지 않은 기준선</b>과 " +
18
+ "<b>해당 종목의 실험실 간 재현 오차</b>를 사전에 공시하여, 보고된 성능이 " +
19
+ "측정 한계 대비 어디에 위치하는지 판정할 수 있게 합니다.",
20
+
21
+ statBoards: "개설 부문", statCompounds: "테스트 화합물", statFields: "분야",
22
+ statClauses: "규격 조항", statPlanned: "계획 부문",
23
+
24
+ secBrowse: "분야", secBrowseLead: "분야를 선택하면 소속 부문의 순위표와 제출 창구로 이동합니다.",
25
+ secEvidence: "측정 한계 지형",
26
+ secEvidenceLead: "가로축은 <b>기준선 오차 ÷ 실험 재현 오차</b>입니다. 1.0 에 근접할수록 " +
27
+ "모델을 개선해도 <b>측정의 산포를 예측하는 일</b>에 가까워집니다.",
28
+ secMethod: "채점 규격 8개 조항",
29
+ secMethodLead: "참가자에게 부과하는 요건이 아니라, 채점자가 스스로에게 부과하는 제약입니다.",
30
+ secData: "데이터와 라이선스",
31
+
32
+ terrLeft: "실험 오차 안쪽", terrRight: "모델 오차가 더 큼",
33
+ insideCount: n => `${n}개 부문이 1.3배 안쪽에 위치합니다.`,
34
+ lostNote: n => `<b>${n}개 부문에서 학습 기준선이 상수 예측에 미달했습니다.</b> ` +
35
+ "학습하지 않은 예측이 더 정확했다는 의미입니다. 기준선을 순위표에 상주시키지 " +
36
+ "않으면(3조) 이 사실은 어떤 지표에도 나타나지 않습니다.",
37
+
38
+ openN: n => `${n} 개설`, prep: "준비 중",
39
+ cardBoards: (o, p) => `부문 ${o}/${p}`,
40
+ cardCompounds: n => `${n} 화합물`,
41
+
42
+ clauses: [
43
+ ["정답은 배포하지 않는다", "부문마다 분할 등급과 정답 등급을 공시한다"],
44
+ ["점수마다 두 개의 지문", "데이터 판본과 채점 코드의 SHA-256 을 각인한다"],
45
+ ["기준선은 순위표에서 내려가지 않는다", "상수 예측 · 최근접 이웃 · Morgan+LightGBM 영구 등재"],
46
+ ["실험 오차를 사전 공시한다", "동일 화합물의 논문 간 측정 차이로 산출한다"],
47
+ ["누출은 매 제출마다 검사한다", "정답과 소수 자리까지 일치하는 항목의 과밀도를 공시한다"],
48
+ ["결과 제출과 모델 제출을 구분한다", "직접 재실행하여 확인한 제출에만 [검증됨]을 부여한다"],
49
+ ["미산출은 0점이 아니다", "측정하지 못한 것과 측정하여 나쁜 것은 다르다"],
50
+ ["반복 제출로 순위가 오르지 않는다", "Ladder · Blum &amp; Hardt, ICML 2015"]
51
+ ],
52
+
53
+ all: "전체",
54
+ tabRank: "순위", tabSubmit: "제출", tabInfo: "부문 정보",
55
+ thRank: "#", thMethod: "방법", thUser: "제출자", thLeak: "누출", thGrade: "등급",
56
+ baseline: "기준선", submitted: "제출됨", verified: "검증됨",
57
+ leakHigh: "조회 흔적", leakElev: "근접 과밀",
58
+ noiseNote: v => `<b>실험 재현 오차 ${v}.</b> 이 폭 안의 차이는 순위 차이로 주장하지 ` +
59
+ "않습니다 (4조). 기준선 3종은 순위표에서 내려가지 않습니다 (3조).",
60
+ noEntries: "참가 제출이 아직 없습니다. 표에 표시된 것은 기준선입니다.",
61
+ updated: t => `순위 갱신 ${t}`,
62
+
63
+ gradeH: "등급", holdoutH: "홀드아웃", noiseH: "실험 재현 오차", baseH: "기준선",
64
+ splitGrade: "분할 등급", answerGrade: "정답 등급",
65
+ gradeNote: {
66
+ T: "시간 분할 — 최초 보고 연도 기준. 기준일 이후 처음 등장한 화합물만 평가한다",
67
+ S: "스캐폴드 분할 — 골격이 훈련에 없는 화합물만 평가한다",
68
+ P1: "정답이 공개 데이터베이스에 존재한다. 조회 방어는 8조가 담당한다",
69
+ P2: "원자료는 공개지만 선별과 환산이 본 판본을 구성한다. 공개 DB 조회로는 동일 값이 나오지 않는다",
70
+ P3: "파트너로부터 제공받은 미공표 실측",
71
+ P4: "부문 개설 이후 생성된 측정. 개설 시점에 존재하지 않았다"
72
+ },
73
+ kSource: "출처", kCut: "분할 기준일", kTrainTest: "훈련 / 테스트",
74
+ kOverlap: "구조 겹침", kThr: "분류 임계", kSha: "데이터 지문", kLicense: "라이선스",
75
+ cutNote: "최초 보고 연도 기준", overlapNote: "0 (시간 분할 정의상)",
76
+ posRate: p => `양성 ${p}%`,
77
+ noisePairs: (p, c) => `비교쌍 ${p} · 화합물 ${c}`,
78
+ noiseDesc: "동일 화합물을 <b>서로 다른 논문</b>에서 측정한 값의 차이",
79
+ noiseSd: "단일 측정 표준편차 추정",
80
+ nearNote: (p, hi) => `분류 임계선에서 실험 오차 1 표준편차 안에 있는 테스트 화합물이 ` +
81
+ `<b>${p}%</b> 입니다. ` + (hi ? "재측정 시 라벨이 반전될 수 있어 <b>회귀를 주지표</b>로 둡니다."
82
+ : "분류 지표를 함께 사용합니다."),
83
+ beatsYes: "학습 기준선이 상수 예측을 상회했습니다.",
84
+ beatsNo: "<b>학습 기준선이 상수 예측에 미달했습니다.</b> 학습하지 않은 예측이 더 정확했습니다.",
85
+ noiseFloorRow: "실험 오차 바닥",
86
+
87
+ step1: "테스트셋 수령", step2: "예측 제출", step3: "컨테이너 제출 (선택)",
88
+ step1d: n => `화합물 <b>${n}개</b>의 구조(SMILES)만 포함합니다. 라벨은 본 서버에 존재하지 않습니다.`,
89
+ licLine: "ChEMBL 파생 · CC BY-SA 3.0 — 재배포 시 출처와 동일 라이선스를 유지하십시오.",
90
+ dl: "테스트셋 내려받기",
91
+ fMethod: "방법 이름", fMethodH: "순위표에 표시될 명칭",
92
+ fTrain: "학습에 사용한 데이터", fTrainH: "5조 누출 검사에 사용합니다",
93
+ fPred: "예측값", fPredH: "JSON 또는 CSV (compound_id, prediction)",
94
+ drop: "파일을 끌어다 놓거나 클릭하여 선택",
95
+ send: "제출", needLogin: "제출하려면 HF 계정 로그인이 필요합니다.",
96
+ step3d: "모델과 추론 환경을 함께 제출하면 직접 재실행하여 동일 결과를 확인하고 " +
97
+ "[검증됨]을 부여합니다 (6조).",
98
+ ladderNote: (v, c) => `<b>8조.</b> 반복 제출로는 순위가 오르지 않습니다. 직전 최고 점수를 ` +
99
+ `실험 오차 <b>${v}</b> 이상 개선한 경우에만 새 점수가 공개됩니다. 일일 제출 한도 ${c}회.`,
100
+ accepted: id => `접수 완료 · 제출 번호 ${id}`,
101
+ parseFail: m => `예측값을 해석하지 못했습니다: ${m}`,
102
+ sending: n => `${n}개 항목 전송 중`,
103
+ emptyCat: "본 분야는 아직 개설된 부문이 없습니다.",
104
+ loadFail: "화면을 불러오지 못했습니다.",
105
+ dataNote: "모든 부문은 ChEMBL_37 에서 구축했으며 최초 보고 연도를 기준으로 시간 분할했습니다. " +
106
+ "배포되는 테스트셋은 구조만 포함하며 라벨은 포함하지 않습니다.",
107
+ srcLabel: "데이터 출처"
108
+ },
109
+
110
+ en: {
111
+ htmlLang: "en",
112
+ title: "LEADBOARD — Benchmark for Drug Prediction Tools",
113
+ navSpec: "spec",
114
+ login: "Sign in with HF", logout: "Sign out",
115
+ langBtn: "한국어",
116
+
117
+ heroKicker: "AI Drug Discovery Benchmark",
118
+ heroTag: "One yardstick, per discipline,<br>for drug prediction tools",
119
+ heroLede: "We do not release the answer key. Instead we publish, in advance, " +
120
+ "<b>baselines that learned nothing</b> and <b>the inter-laboratory reproducibility " +
121
+ "of each endpoint</b> — so that any reported score can be judged against the " +
122
+ "measurement limit itself.",
123
+
124
+ statBoards: "open boards", statCompounds: "test compounds", statFields: "disciplines",
125
+ statClauses: "spec clauses", statPlanned: "planned boards",
126
+
127
+ secBrowse: "Disciplines",
128
+ secBrowseLead: "Select a discipline to reach its boards, leaderboards and submission desk.",
129
+ secEvidence: "Measurement-limit landscape",
130
+ secEvidenceLead: "The axis is <b>baseline error divided by experimental error</b>. " +
131
+ "The closer to 1.0, the more that further tuning amounts to " +
132
+ "<b>predicting measurement scatter</b>.",
133
+ secMethod: "Eight clauses the scorer imposes on itself",
134
+ secMethodLead: "These are not requirements placed on entrants. They are constraints we accept.",
135
+ secData: "Data and licence",
136
+
137
+ terrLeft: "inside experimental error", terrRight: "model error larger",
138
+ insideCount: n => `${n} boards sit within 1.3x.`,
139
+ lostNote: n => `<b>On ${n} boards the trained baseline failed to beat constant prediction.</b> ` +
140
+ "A prediction that learned nothing was more accurate. Without baselines resident on the " +
141
+ "leaderboard (clause 3), this fact appears in no metric.",
142
+
143
+ openN: n => `${n} open`, prep: "in preparation",
144
+ cardBoards: (o, p) => `boards ${o}/${p}`,
145
+ cardCompounds: n => `${n} compounds`,
146
+
147
+ clauses: [
148
+ ["The answer key is not distributed", "Each board declares its split grade and answer grade"],
149
+ ["Every score carries two fingerprints", "SHA-256 of the data revision and of the scoring code"],
150
+ ["Baselines never leave the table", "Constant, nearest-neighbour and Morgan+LightGBM stay resident"],
151
+ ["Experimental error is published first", "Derived from cross-publication measurements of the same compound"],
152
+ ["Leakage is screened on every submission", "We report the density of predictions matching labels to the decimal"],
153
+ ["Result submission and model submission are distinct", "Only re-executed submissions receive [verified]"],
154
+ ["An unmeasured axis is not a zero", "Failing to measure differs from measuring a poor result"],
155
+ ["Repeated submission does not raise rank", "Ladder · Blum &amp; Hardt, ICML 2015"]
156
+ ],
157
+
158
+ all: "All",
159
+ tabRank: "Leaderboard", tabSubmit: "Submit", tabInfo: "Board detail",
160
+ thRank: "#", thMethod: "Method", thUser: "Entrant", thLeak: "Leakage", thGrade: "Grade",
161
+ baseline: "baseline", submitted: "submitted", verified: "verified",
162
+ leakHigh: "lookup trace", leakElev: "near-exact density",
163
+ noiseNote: v => `<b>Experimental error ${v}.</b> Differences within this band are not ` +
164
+ "claimed as rank differences (clause 4). The three baselines stay resident (clause 3).",
165
+ noEntries: "No entrant submissions yet. The rows shown are baselines.",
166
+ updated: t => `Leaderboard updated ${t}`,
167
+
168
+ gradeH: "Grades", holdoutH: "Holdout", noiseH: "Experimental error", baseH: "Baselines",
169
+ splitGrade: "Split grade", answerGrade: "Answer grade",
170
+ gradeNote: {
171
+ T: "Time split by year of first report. Only compounds first reported after the cut are scored",
172
+ S: "Scaffold split. Only compounds whose scaffold is absent from training are scored",
173
+ P1: "The answer exists in a public database. Clause 8 carries the defence against lookup",
174
+ P2: "The source is public, but selection and unit conversion define this revision. " +
175
+ "Querying the public database does not reproduce these values",
176
+ P3: "Unpublished measurements provided by a partner",
177
+ P4: "Measured after the board opened. It did not exist at opening"
178
+ },
179
+ kSource: "Source", kCut: "Split year", kTrainTest: "Train / test",
180
+ kOverlap: "Structure overlap", kThr: "Classification threshold", kSha: "Data fingerprint",
181
+ kLicense: "Licence",
182
+ cutNote: "by year of first report", overlapNote: "0 (by construction of the time split)",
183
+ posRate: p => `positives ${p}%`,
184
+ noisePairs: (p, c) => `${p} pairs · ${c} compounds`,
185
+ noiseDesc: "Difference between measurements of the same compound in <b>different publications</b>",
186
+ noiseSd: "Estimated single-measurement SD",
187
+ nearNote: (p, hi) => `<b>${p}%</b> of test compounds lie within one experimental SD of the ` +
188
+ "classification threshold. " + (hi ? "Labels there can invert on re-measurement, so " +
189
+ "<b>regression is the primary metric</b>." : "Classification metrics are reported alongside."),
190
+ beatsYes: "The trained baseline beat constant prediction.",
191
+ beatsNo: "<b>The trained baseline failed to beat constant prediction.</b> " +
192
+ "A prediction that learned nothing was more accurate.",
193
+ noiseFloorRow: "experimental error floor",
194
+
195
+ step1: "Obtain the test set", step2: "Submit predictions", step3: "Submit a container (optional)",
196
+ step1d: n => `Structures (SMILES) for <b>${n} compounds</b>. No labels — they do not exist on this server.`,
197
+ licLine: "Derived from ChEMBL · CC BY-SA 3.0 — retain attribution and the same licence on redistribution.",
198
+ dl: "Download test set",
199
+ fMethod: "Method name", fMethodH: "as it will appear on the leaderboard",
200
+ fTrain: "Training data used", fTrainH: "used for the clause 5 leakage screen",
201
+ fPred: "Predictions", fPredH: "JSON or CSV (compound_id, prediction)",
202
+ drop: "Drop a file here, or click to choose",
203
+ send: "Submit", needLogin: "Signing in with a Hugging Face account is required to submit.",
204
+ step3d: "Submit the model with its inference environment and we re-execute it, confirm the " +
205
+ "same numbers, and mark it [verified] (clause 6).",
206
+ ladderNote: (v, c) => `<b>Clause 8.</b> Repeated submission does not raise rank. A new score is ` +
207
+ `released only when it improves on your previous best by more than the experimental error ` +
208
+ `<b>${v}</b>. Daily limit ${c}.`,
209
+ accepted: id => `Accepted · submission ${id}`,
210
+ parseFail: m => `Could not parse the predictions: ${m}`,
211
+ sending: n => `sending ${n} entries`,
212
+ emptyCat: "No boards are open in this discipline yet.",
213
+ loadFail: "The page could not be loaded.",
214
+ dataNote: "Every board is built from ChEMBL_37 and split by year of first report. " +
215
+ "Distributed test sets contain structures only; labels are not included.",
216
+ srcLabel: "Data source"
217
+ }
218
+ };
index.html CHANGED
@@ -4,27 +4,26 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>LEADBOARD — 신약 예측 도구 시험대</title>
7
- <meta name="description" content="신약 예측 도구를 분야별로 같은 잣대로 잰다. 정답은 나눠주지 않고, 기준선과 실험 오차를 먼저 공개한다.">
8
  <!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면
9
  그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. -->
10
  <style>
11
  :root{
12
- --bg:#fbfcfd; --bg2:#f0f3f7; --card:#fff; --ink:#0b0d12; --ink2:#3a4150;
13
- --muted:#6d7585; --line:#e6e9ef; --line2:#d0d6e0;
14
- --acc:#1f4fa3; --acc2:#3d7ae0; --soft:#eaf0fb;
15
- --warn:#8a5c00; --warnbg:#fff6e2; --warnln:#f0d9a0;
16
- --good:#0f6b43; --goodbg:#e6f6ee; --bad:#9c2f24; --badbg:#fdeceb;
17
  --m:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
18
  --sh:0 1px 2px rgba(13,18,30,.05),0 2px 6px rgba(13,18,30,.05);
19
  --sh2:0 8px 16px -6px rgba(13,18,30,.14),0 20px 36px -12px rgba(13,18,30,.14);
20
  }
21
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
22
- --bg:#090b0f; --bg2:#101319; --card:#141821; --ink:#f1f4f8; --ink2:#c8cfdb;
23
- --muted:#8b94a5; --line:#212734; --line2:#2e3646;
24
- --acc:#8cb0f0; --acc2:#6a95e4; --soft:#111a2b;
25
- --warn:#e8bd5e; --warnbg:#241d0e; --warnln:#4a3c1a;
26
- --good:#4fd396; --goodbg:#0c2418; --bad:#f59285; --badbg:#2b1411;
27
- --sh:0 1px 2px rgba(0,0,0,.4); --sh2:0 12px 32px rgba(0,0,0,.55);}}
28
  :root[data-theme="dark"]{
29
  --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb;
30
  --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4;
@@ -33,9 +32,9 @@
33
  --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);}
34
  *{box-sizing:border-box}
35
  /* 히어로 뒤 그라디언트가 좌우로 음수 inset 을 쓴다. z-index 를 내려도 스크롤 범위에는
36
- 남아 가로 스크롤이 생긴다. clip 은 hidden 과 달리 sticky 헤더를 깨뜨리지 않는다. */
37
  html{overflow-x:clip}
38
- body{margin:0;background:var(--bg);color:var(--ink);line-height:1.62;font-size:15px;overflow-x:clip;
39
  font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo",
40
  "Malgun Gothic",Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased}
41
  .wrap{max-width:1060px;margin:0 auto;padding:0 24px}
@@ -45,7 +44,7 @@
45
 
46
  nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 78%,transparent);
47
  backdrop-filter:saturate(180%) blur(16px);border-bottom:1px solid var(--line)}
48
- nav .in{max-width:1060px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:12px}
49
  .lg{font-weight:850;letter-spacing:-.04em;font-size:18px;color:var(--ink)}
50
  .lg:hover{text-decoration:none}.lg i{color:var(--acc);font-style:normal}
51
  .sp{flex:1}
@@ -55,42 +54,40 @@
55
  .chip.ok{background:var(--goodbg);color:var(--good)}
56
  .chip.wr{background:var(--warnbg);color:var(--warn)}
57
  .chip.bd{background:var(--badbg);color:var(--bad)}
 
 
 
58
  .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 19px;border-radius:11px;
59
  background:linear-gradient(180deg,var(--acc2),var(--acc));color:#fff;font-size:14px;
60
  font-weight:650;border:0;cursor:pointer;box-shadow:var(--sh);transition:transform .1s,filter .15s}
61
  .btn:hover{filter:brightness(1.08);text-decoration:none}
62
  .btn:active{transform:translateY(1px)}.btn:disabled{opacity:.45;cursor:default}
63
 
64
- /* hero */
65
  .hero{padding:78px 0 46px;position:relative}
66
  .hero::before{content:"";position:absolute;inset:-70% -30% auto -30%;height:520px;z-index:-1;
67
  background:radial-gradient(ellipse 55% 50% at 32% 55%,color-mix(in srgb,var(--acc) 16%,transparent),transparent 70%)}
68
  .brand{font-size:clamp(46px,9vw,80px);font-weight:900;letter-spacing:-.05em;margin:0;line-height:.96}
69
  .brand i{color:var(--acc);font-style:normal}
70
- .subttl{font-size:13px;color:var(--muted);margin:8px 0 0;letter-spacing:.06em;text-transform:uppercase}
71
- .tag{font-size:clamp(23px,4vw,34px);font-weight:750;margin:30px 0 12px;
72
- letter-spacing:-.03em;line-height:1.3;max-width:16em}
73
- .lede{font-size:16.5px;color:var(--ink2);margin:0;max-width:36em;line-height:1.7}
74
-
75
- .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:1px;
76
- margin:42px 0 0;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden}
77
  .stat{background:var(--card);padding:17px 19px}
78
- .stat b{display:block;font-size:30px;font-weight:850;font-family:var(--m);
79
- letter-spacing:-.045em;line-height:1.1}
80
  .stat span{font-size:11.5px;color:var(--muted)}
81
 
82
- .sec{margin:72px 0 0}
83
  .sec h2{font-size:11.5px;font-weight:750;letter-spacing:.17em;text-transform:uppercase;
84
- color:var(--acc);margin:0 0 7px}
85
- .sec .lead2{font-size:15.5px;color:var(--ink2);margin:0 0 22px;max-width:44em}
86
  h3{font-size:18.5px;font-weight:720;margin:30px 0 9px;letter-spacing:-.018em}
87
  p{margin:0 0 15px}
88
 
89
- /* ── 서명 차트: 측정 한계 지형 ── */
90
  .terr{background:var(--card);border:1px solid var(--line);border-radius:16px;
91
  padding:22px 24px 18px;box-shadow:var(--sh)}
92
- .trow{display:grid;grid-template-columns:96px 1fr 62px;align-items:center;gap:12px;
93
- padding:4px 0;font-size:13px}
94
  .trow .nm{font-weight:650;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
95
  .trow .nm a{color:var(--ink)}
96
  .trk{position:relative;height:19px;background:var(--bg2);border-radius:5px;overflow:hidden}
@@ -100,11 +97,10 @@
100
  .trk .one{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--ink);opacity:.42}
101
  .trow .v{font-family:var(--m);font-size:12px;color:var(--muted);text-align:right}
102
  .trow .v.in{color:var(--warn);font-weight:700}
103
- .taxis{display:grid;grid-template-columns:96px 1fr 62px;gap:12px;margin-top:10px;
104
  font-size:11px;color:var(--muted);font-family:var(--m)}
105
- .taxis .lbl{display:flex;justify-content:space-between}
106
 
107
- /* cards */
108
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:15px}
109
  .cc{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px;
110
  display:block;box-shadow:var(--sh);transition:box-shadow .2s,transform .13s,border-color .2s}
@@ -131,7 +127,7 @@
131
  box-shadow:var(--sh);margin:18px 0}
132
  table{width:100%;border-collapse:collapse;font-size:13.5px}
133
  th{text-align:right;font-weight:650;color:var(--muted);font-size:10.5px;letter-spacing:.08em;
134
- text-transform:uppercase;padding:13px 13px;border-bottom:1px solid var(--line);white-space:nowrap}
135
  th:first-child,td:first-child{text-align:center;width:48px}
136
  th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){text-align:left}
137
  td{padding:13px;border-bottom:1px solid var(--line);text-align:right;
@@ -153,7 +149,8 @@
153
  .note{background:var(--warnbg);border:1px solid var(--warnln);border-radius:13px;
154
  padding:15px 18px;font-size:14.5px;margin:18px 0;color:var(--ink2);line-height:1.65}
155
  .note.ok{background:var(--goodbg);border-color:color-mix(in srgb,var(--good) 30%,transparent)}
156
- .kv{display:grid;grid-template-columns:170px 1fr;gap:10px 18px;font-size:14px;margin:15px 0}
 
157
  .kv dt{color:var(--muted)}.kv dd{margin:0;font-family:var(--m);font-size:13px;word-break:break-word}
158
  label{display:block;font-size:13.5px;font-weight:680;margin:17px 0 6px}
159
  .hint{font-size:12px;color:var(--muted);font-weight:400}
@@ -172,7 +169,7 @@
172
  display:flex;align-items:center;justify-content:center;font-family:var(--m);border:3px solid var(--bg)}
173
  .steps b{display:block;font-size:16px;margin-bottom:4px}
174
  ol.cl{list-style:none;counter-reset:c;padding:0;margin:18px 0;
175
- display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0}
176
  ol.cl li{counter-increment:c;position:relative;padding:16px 16px 16px 52px;border-top:1px solid var(--line)}
177
  ol.cl li::before{content:counter(c);position:absolute;left:8px;top:16px;width:30px;height:30px;
178
  border-radius:9px;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px;
@@ -186,7 +183,7 @@
186
  font-size:13px;color:var(--muted);display:flex;gap:20px;flex-wrap:wrap;align-items:center}
187
  @media(max-width:640px){
188
  .kv{grid-template-columns:1fr}.kv dt{margin-top:8px}
189
- .trow,.taxis{grid-template-columns:74px 1fr 50px;gap:8px;font-size:12px}}
190
  </style>
191
  </head>
192
  <body>
@@ -195,65 +192,76 @@
195
  <a class="lg" href="#/">LEAD<i>BOARD</i></a>
196
  <span class="chip g" id="spec">v1.1</span>
197
  <span class="sp"></span>
 
198
  <span id="who" style="font-size:13px;color:var(--muted)"></span>
199
  </div></nav>
200
 
201
  <div id="app"><div class="wrap"><div class="skel" style="margin-top:70px"></div></div></div>
 
202
 
203
- <footer class="wrap">
204
- <span>LEADBOARD · FINAL-Bench / VIDRAFT</span>
205
- <span class="dim">데이터 출처 <a href="https://www.ebi.ac.uk/chembl/">ChEMBL</a> ·
206
- CC BY-SA 3.0</span>
207
- <span class="sp"></span>
208
- <a href="/api/health">API</a>
209
- <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge">ODC</a>
210
- <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge">OMC</a>
211
- </footer>
212
-
213
  <script>
214
  const $=s=>document.querySelector(s), app=$("#app");
215
  const f=(x,d=3)=>(x==null||isNaN(x))?"—":Number(x).toFixed(d);
216
  const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
217
  const W=h=>`<div class="wrap">${h}</div>`;
218
- let CATS=null, ME={user:null}, BOARDS={};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  async function j(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(await r.text());return r.json();}
220
 
221
- /* ═══ 서명 차트 · 측정 한계 지형 ═══
222
- 가로축 = 모델 오차 ÷ 실험 오차. 1.0 왼쪽이면 모델 오차가 실험 오차보다 작다는 뜻이고,
223
- 그건 모델이 뛰어나서가 아니라 그 부문의 값이 좁게 몰려 있다는 뜻이다.
224
- 어느 쪽이든 "여기서 더 재봐야 측정을 재는 것"이라는 신호다. */
225
- function terrain(items, link){
226
- const MAX=3.0;
227
- const pc=v=>Math.min(100,100*v/MAX);
228
- return `<div class="terr">
229
- ${items.map(b=>{
230
- const inside=b.ratio<1.0;
231
- const nm=link?`<a href="#/b/${encodeURIComponent(b.board)}">${esc(b.board)}</a>`:esc(b.board);
232
- return `<div class="trow">
233
- <div class="nm">${nm}</div>
234
- <div class="trk">
235
- <div class="fill ${inside?"in":""}" style="width:${pc(b.ratio)}%"></div>
236
- <div class="one" style="left:${pc(1)}%"></div>
237
- </div>
238
- <div class="v ${inside?"in":""}">${f(b.ratio,2)}×</div></div>`;}).join("")}
239
- <div class="taxis"><span></span>
240
- <span class="lbl"><i>0</i><i>← 실험 오차 안쪽 · 1.0 · 모델이 더 나쁨 →</i><i>${MAX.toFixed(1)}</i></span>
241
- <span></span></div></div>`;
242
  }
243
- const ratioOf=b=>{const bl=b.baselines,nf=b.noise_floor.sd_single;
244
- return Math.min(...Object.values(bl).map(v=>v.mae))/nf;};
245
 
246
  async function boot(){
247
  try{ME=await j("/api/me");}catch(e){}
248
- $("#who").innerHTML=ME.user?`${esc(ME.user.user)} · <a href="/logout">로그아웃</a>`
249
- :(ME.login_enabled?`<a href="/login?next=${encodeURIComponent(location.hash||"#/")}">HF 로그인</a>`:"");
250
  CATS=await j("/api/categories");
251
- $("#spec").textContent=CATS.spec;
252
  CATS.categories.forEach(c=>(c.board_cards||[]).forEach(b=>BOARDS[b.board]=b));
253
- route();
 
 
254
  }
255
  addEventListener("hashchange",route);
256
  function route(){
 
257
  const p=(location.hash||"#/").slice(2).split("/").filter(Boolean);scrollTo(0,0);
258
  if(!p.length)return hub();
259
  if(p[0]==="c")return category(p[1]);
@@ -263,70 +271,51 @@ function route(){
263
 
264
  /* ═══ 허브 ═══ */
265
  function hub(){
266
- const t=CATS.totals, bs=Object.values(BOARDS);
267
  const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})).sort((a,b)=>a.ratio-b.ratio);
268
  const inside=items.filter(x=>x.ratio<1.3).length;
269
- const cmp=bs.reduce((s,b)=>s+b.n_test,0);
270
  const lost=bs.filter(b=>b.beats_constant===false).length;
271
  app.innerHTML=W(`
272
  <div class="hero">
273
  <h1 class="brand">LEAD<i>BOARD</i></h1>
274
- <p class="subttl">AI Drug Discovery Benchmark</p>
275
- <p class="tag">신약 예측 도구를,<br>분야별로, 같은 잣대로 잰다.</p>
276
- <p class="lede">정답은 나눠주지 않습니다. 대신 <b>아무것도 학습하지 않은 기준선</b>
277
- <b>그 분야를 실험으로 다시 재도 남는 오차</b>를 먼저 공개합니다.
278
- 믿어달라고 하지 않고, 확인하게 합니다.</p>
279
  <div class="stats">
280
- <div class="stat"><b>${t.open}</b><span>개설 부문</span></div>
281
- <div class="stat"><b>${cmp.toLocaleString()}</b><span>테스트 화합물</span></div>
282
- <div class="stat"><b>${CATS.categories.filter(c=>c.open).length}</b><span>분야</span></div>
283
- <div class="stat"><b>8</b><span>규격 조항</span></div>
284
- <div class="stat"><b>${t.planned}</b><span>계획 부문</span></div>
285
  </div>
286
  </div>
287
 
288
- <div class="sec">
289
- <h2>측정 한계 지형</h2>
290
- <p class="lead2">가로축은 <b>기준선 오차 ÷ 실험 오차</b>입니다.
291
- 1.0 에 가까울수록 모델을 더 다듬어도 <b>측정의 흔들림을 맞히는 일</b>이 됩니다.
292
- ${inside} 부문이 1.3배 안쪽에 있습니다.</p>
293
  ${terrain(items,true)}
294
- ${lost?`<div class="note"><b>${lost}개 부문에서 학습 기준선이 상수 예측에 졌습니다.</b>
295
- 아무것도 배우지 않은 쪽이 더 정확했다는 뜻입니다.
296
- 기준선을 표에 상주시키지 않으면(3조) 이 사실은 어디에도 나타나지 않습니다.</div>`:""}
297
- </div>
298
 
299
- <div class="sec">
300
- <h2>분야</h2>
301
- <p class="lead2">분야를 고르면 그 안의 부문과 순위·제출로 들어갑니다.</p>
302
- <div class="grid">${CATS.categories.map(cardHTML).join("")}</div>
303
- </div>
304
 
305
- <div class="sec">
306
- <h2>채점자가 스스로에게 부과하는 8개 조항</h2>
307
- <p class="lead2">참가자에게 요구하는 것이 아니라, 우리가 지키는 것입니다.</p>
308
- <ol class="cl">
309
- ${[["정답은 배포하지 않는다","부문마다 분할 등급과 정답 등급을 공시한다"],
310
- ["점수마다 두 개의 지문","데이터 판본과 채점 코드의 SHA-256"],
311
- ["기준선은 표에서 내려가지 않는다","상수 예측 · 최근접 이웃 · Morgan+LightGBM 영구 등재"],
312
- ["잡음 바닥을 먼저 공시한다","같은 화합물을 다른 논문에서 잰 차이로 산출한다"],
313
- ["누출은 매 제출마다 검사한다","정답과 소수점까지 같은 항목이 무더기면 표에 적는다"],
314
- ["결과 제출과 모델 제출을 구분한다","우리가 직접 돌려본 것만 [검증됨]"],
315
- ["미산출은 0점이 아니다","측정하지 못한 것과 측정해서 나쁜 것은 다르다"],
316
- ["되풀이 제출로는 순위가 오르지 않는다","Ladder · Blum &amp; Hardt, ICML 2015"]]
317
- .map(([a,b])=>`<li><b>${a}</b><span>${b}</span></li>`).join("")}
318
- </ol>
319
- </div>`);
320
  }
321
 
322
  function cardHTML(c){
323
  const on=c.open>0, bs=c.board_cards||[];
324
  const tot=bs.reduce((s,b)=>s+b.n_test,0);
325
  const inner=`<div class="ico">${c.icon}</div>
326
- <div class="nm">${esc(c.name)}<span class="chip ${on?"ok":"g"}">${on?c.open+" 개설":"준비 중"}</span></div>
327
- <div class="ds">${esc(c.desc)}</div>
328
- <div class="mt"><span>부문 ${c.open}/${c.planned}</span>
329
- <span>${tot?tot.toLocaleString()+" 화합물":"—"}</span></div>`;
 
330
  return on?`<a class="cc" href="#/c/${c.id}">${inner}</a>`:`<div class="cc off">${inner}</div>`;
331
  }
332
 
@@ -335,28 +324,27 @@ function category(id){
335
  const c=CATS.categories.find(x=>x.id===id); if(!c)return hub();
336
  const bs=(c.board_cards||[]).slice().sort((a,b)=>b.n_test-a.n_test);
337
  const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})).sort((a,b)=>a.ratio-b.ratio);
 
338
  app.innerHTML=W(`
339
- <div class="crumb"><a href="#/">전체</a> › ${esc(c.name)}</div>
340
- <h1 class="ttl">${c.icon} ${esc(c.name)}</h1>
341
- <p class="lede">${esc(c.desc)}</p>
342
  <div class="stats" style="margin-top:26px">
343
- <div class="stat"><b>${c.open}</b><span>개설 부문</span></div>
344
- <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>테스트 화합물</span></div>
345
- <div class="stat"><b>${c.planned}</b><span>계획 부문</span></div>
346
- </div>
347
  ${bs.length?`
348
- <div class="sec"><h2>측정 한계 지형</h2>
349
- <p class="lead2">기준선 오차 ÷ 실험 오차. 부문 이름을 누르면 순위로 갑니다.</p>
350
- ${terrain(items,true)}</div>
351
- <div class="sec"><h2>부문</h2><div class="grid">${bs.map(boardMini).join("")}</div></div>`
352
- :`<div class="note">이 분야는 아직 개설된 부문이 없습니다.</div>`}`);
353
  }
354
  function boardMini(b){
355
  const bl=b.baselines[b.best_baseline]||{};
356
  return `<a class="cc" href="#/b/${encodeURIComponent(b.board)}">
357
  <div class="nm">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></div>
358
- <div class="ds">테스트 <b>${b.n_test.toLocaleString()}</b>개 · 실험 오차 ${f(b.noise_floor.sd_single)}<br>
359
- 최선 기준선 ${esc(b.best_baseline)} ${f(bl.mae)}</div>
360
  <div class="mt"><span>${esc(b.source)}</span><span>${f(ratioOf(b),2)}×</span></div></a>`;
361
  }
362
 
@@ -364,13 +352,14 @@ function boardMini(b){
364
  async function board(name,tab){
365
  const b=BOARDS[name]||await j("/api/board/"+encodeURIComponent(name));
366
  const cat=CATS.categories.find(c=>(c.boards||[]).includes(name));
367
- const T=[["rank","순위"],["submit","제출"],["info","부문 정보"]];
 
368
  app.innerHTML=W(`
369
- <div class="crumb"><a href="#/">전체</a> › <a href="#/c/${cat?cat.id:""}">${esc(cat?cat.name:"")}</a> › ${esc(b.board)}</div>
370
  <h1 class="ttl">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></h1>
371
  <p class="lede">${esc(b.source)} · ${esc(b.type)} ·
372
- 테스트 <b>${b.n_test.toLocaleString()}</b>개 · 실험 오차 <b>${f(b.noise_floor.sd_single)}</b></p>
373
- <div class="pills">${T.map(([k,l])=>
374
  `<a class="pilltab ${k===tab?"on":""}" href="#/b/${encodeURIComponent(name)}/${k}">${l}</a>`).join("")}</div>
375
  <div id="pane"><div class="skel" style="margin-top:20px"></div></div>`);
376
  if(tab==="rank")return paneRank(b);
@@ -382,8 +371,8 @@ async function paneRank(b){
382
  const d=await j(`/api/board/${encodeURIComponent(b.board)}/leaderboard`);
383
  const nf=d.noise_floor, ent=d.rows.filter(r=>r.kind!=="baseline");
384
  const worst=Math.max(...d.rows.map(r=>r.mae||0))||1;
385
- const leak=l=>({high:'<span class="chip bd">조회 흔적</span>',
386
- elevated:'<span class="chip wr">근접 과밀</span>'}[l]||'<span class="chip g">—</span>');
387
  const rows=d.rows.map(r=>{
388
  const g=r.rank&&r.rank<=3?` g${r.rank}`:"";
389
  const w=Math.max(4,58*(r.mae||0)/worst);
@@ -392,102 +381,92 @@ async function paneRank(b){
392
  <td>${esc(r.method)}</td><td>${esc(r.user)}</td>
393
  <td><span class="mini" style="width:${w}px"></span>${f(r.mae)}</td>
394
  <td>${f(r.auroc)}</td><td>${f(r.prauc)}</td>
395
- <td>${r.kind==="baseline"?'<span class="chip g">기준선</span>':leak(r.leak)}</td>
396
- <td>${r.kind==="baseline"?"—":(r.verified?'<span class="chip ok">검증됨</span>'
397
- :'<span class="chip g">제출됨</span>')}</td></tr>`;}).join("");
398
  $("#pane").innerHTML=`
399
- <div class="tw"><table>
400
- <thead><tr><th>#</th><th>방법</th><th>제출자</th><th>MAE</th><th>AUROC</th>
401
- <th>PR-AUC</th><th>누출</th><th>등급</th></tr></thead><tbody>${rows}</tbody></table></div>
402
- <div class="note"><b>실험 오차 ${f(nf)}.</b> 이 폭 안의 차이는 <b>순위 차이로 주장하지 않습니다</b> (4조).
403
- 기준선 3종은 표에서 내려가지 않습니다 (3조).</div>
404
- ${ent.length?`<p class="dim" style="font-size:13px">순위 갱신 ${new Date(d.updated*1000).toLocaleString("ko-KR")}</p>`
405
- :`<p class="dim" style="font-size:13px">아직 참가 제출이 없습니다. 표에 있는 것은 기준선뿐입니다.</p>`}`;
406
  }
407
 
408
  function paneInfo(b){
409
- const nf=b.noise_floor,bl=b.baselines;
410
  $("#pane").innerHTML=`
411
- <h3>등급</h3>
412
  <dl class="kv">
413
- <dt>분할 등급 <b>${b.split_grade}</b></dt><dd>${esc(b.split_grade_note||"")}</dd>
414
- <dt>정답 등급 <b>${b.answer_grade}</b></dt><dd>${esc(b.answer_grade_note||"")}</dd></dl>
415
- <h3>홀드아웃</h3>
 
 
416
  <dl class="kv">
417
- <dt>출처</dt><dd>${esc(b.source)} · ${esc(b.target)} · ${esc(b.type)}</dd>
418
- <dt>분할 기준일</dt><dd>${b.cut} — 최초 보고 연도</dd>
419
- <dt>훈련 / 테스트</dt><dd>${b.n_train.toLocaleString()} / ${b.n_test.toLocaleString()}</dd>
420
- <dt>구조 겹침</dt><dd>0 (시간분할 정의상)</dd>
421
- <dt>분류 임계</dt><dd>${b.threshold} · 양성 ${(b.positive_rate*100).toFixed(1)}%</dd>
422
- <dt>데이터 지문</dt><dd>${esc(b.dataset_sha)}</dd>
423
- <dt>라이선스</dt><dd>ChEMBL · CC BY-SA 3.0</dd></dl>
424
- <h3>실험 오차 <span class="dim" style="font-weight:400;font-size:14px">(4)</span></h3>
425
- <div class="fig">같은 화합물을 <b>서로 다른 논문</b>에서 잰 차이<br>
426
- 비교쌍 <span class="n">${nf.pairs.toLocaleString()}</span> · 화합물 <span class="n">${nf.compounds.toLocaleString()}</span><br>
427
- 중앙값 ${f(nf.median)} · 평균 ${f(nf.mean)} · 90분위 ${f(nf.p90)}<br>
428
- <b>단일 측정 SD 추정 <span class="n">${f(nf.sd_single)}</span></b></div>
429
- <div class="note">분류 임계선에서 실험 오차 한 칸 안에 있는 테스트 화합물이 <b>${b.near_pct}%</b> 입니다.
430
- ${b.near_pct>45?"절반 가까이가 재측정하면 라벨이 뒤집힐 수 있어 <b>회귀를 주지표</b>로 둡니다."
431
- :"분류 지표를 함께 씁니다."}</div>
432
- <h3>기준선 <span class="dim" style="font-weight:400;font-size:14px">(3조)</span></h3>
433
- <div class="tw"><table><thead><tr><th style="width:auto">·</th><th>기준선</th><th>MAE</th>
434
  <th>AUROC</th><th>PR-AUC</th></tr></thead><tbody>
435
  ${Object.entries(bl).map(([k,v])=>`<tr><td>·</td><td>${esc(k)}</td><td>${f(v.mae)}</td>
436
  <td>${f(v.auroc)}</td><td>${f(v.prauc)}</td></tr>`).join("")}
437
- <tr class="base"><td>·</td><td>실험 오차 바닥</td><td>${f(nf.sd_single)}</td><td>—</td><td>—</td></tr>
438
  </tbody></table></div>
439
- <div class="note ${b.beats_constant?"ok":""}">${b.beats_constant
440
- ?"학습 기준선이 상수 예측을 넘었습니다."
441
- :"<b>학습 기준선이 상수 예측을 넘지 못했습니다.</b> 아무것도 배우지 않은 쪽이 더 정확했습니다."}</div>`;
442
  }
443
 
444
  function paneSubmit(b){
445
  const url=`/api/board/${encodeURIComponent(b.board)}/testset`;
446
  $("#pane").innerHTML=`
447
  <ol class="steps">
448
- <li><b>테스트셋 받기</b>
449
- <p class="dim" style="font-size:13.5px">화합물 <b>${b.n_test.toLocaleString()}</b>의 구조(SMILES)만
450
- 들어 있습니다. 라벨은 이 서버에 존재하지 않습니다.<br>
451
- ChEMBL 파생 · CC BY-SA 3.0 — 재배포 시 출처와 같은 라이선스를 유지해 주십시오.</p>
452
- <a class="btn" href="${url}" download>테스트셋 내려받기</a>
453
  <span class="hint">&nbsp;${esc(b.board.toLowerCase())}_test.json</span></li>
454
- <li><b>예측 올리기</b>
455
  ${ME.user?`
456
- <label>방법 이름 <span class="hint">순위표에 이렇게 표시됩니다</span></label>
457
- <input type="text" id="m" placeholder="예: my-gnn-ensemble-v2" maxlength="60">
458
- <label>학습에 쓴 데이터 <span class="hint">5조 누출 검사에 씁니다</span></label>
459
- <input type="text" id="td" placeholder="예: ChEMBL34 + 사내 데이터">
460
- <label>예측값 <span class="hint">JSON {"LB-…": 6.42} 또는 CSV compound_id,prediction</span></label>
461
- <div class="drop" id="drop">파일을 끌어다 놓거나 눌러서 고르세요
462
- <input type="file" id="file" accept=".json,.csv,.txt" hidden></div>
463
- <textarea id="p" rows="6" style="margin-top:11px"
464
- placeholder='{"LB-A1B2C3D4E5F6": 6.42}'></textarea>
465
- <p style="margin-top:15px"><button class="btn" id="go">제출</button>
466
  <span class="hint" id="msg"></span></p>`
467
- :`<div class="note">제출하려면 HF 계정으로 로그인해야 합니다.
468
- <a href="/login?next=${encodeURIComponent(location.hash)}">로그인</a></div>`}</li>
469
- <li><b>컨테이너까지 올리면 (선택)</b>
470
- <p class="dim" style="font-size:13.5px">모델과 추론 환경을 함께 제출하면 <b>우리가 직접 돌려</b>
471
- 같은 숫자가 나오는지 확인하고 <span class="chip ok">검증됨</span> 표시를 붙입니다 (6조).</p></li>
472
  </ol>
473
- <div class="note"><b>8조.</b> 되풀이 제출로는 순위가 오르지 않습니다.
474
- 직전 최고점을 실험 오차 <b>${f(b.noise_floor.sd_single)}</b> 이상으로 넘었을 때만
475
- 새 점수가 공개됩니다. 하루 제출 한도 ${ME.daily_cap||5}회.</div>`;
476
  wire(b);
477
  }
478
- function parsePred(t){
479
- t=t.trim(); if(t.startsWith("{"))return JSON.parse(t);
480
  const o={};
481
- t.split(/\r?\n/).forEach((ln,i)=>{const p=ln.split(/[,\t]/);if(p.length<2)return;
482
  const k=p[0].trim();if(!k||(i===0&&isNaN(parseFloat(p[1]))))return;
483
  const v=parseFloat(p[1]);if(!isNaN(v))o[k]=v;});
484
- if(!Object.keys(o).length)throw new Error("읽을 있는 항목이 없습니다");
485
  return o;
486
  }
487
  function wire(b){
488
  const drop=$("#drop"),file=$("#file"),ta=$("#p"),go=$("#go");
489
  const load=f0=>{const r=new FileReader();r.onload=()=>{ta.value=r.result;
490
- drop.textContent=`${f0.name} — ${(f0.size/1024).toFixed(0)} KB 읽음`;};r.readAsText(f0);};
491
  if(drop){
492
  drop.onclick=()=>file.click();
493
  file.onchange=e=>e.target.files[0]&&load(e.target.files[0]);
@@ -498,18 +477,18 @@ function wire(b){
498
  if(!go)return;
499
  go.onclick=async()=>{
500
  const msg=$("#msg");let preds;
501
- try{preds=parsePred(ta.value);}catch(e){msg.textContent="예측값을 읽지 못했습니다: "+e.message;return;}
502
- msg.textContent=`${Object.keys(preds).length.toLocaleString()}개 항목 · 보내는 중…`;go.disabled=true;
503
  try{const r=await j("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"},
504
  body:JSON.stringify({board:b.board,method:($("#m").value||"").trim(),
505
  predictions:preds,training_data:($("#td").value||"").trim()})});
506
- msg.innerHTML=`접수 완료 · <code>${esc(r.submission_id)}</code>`;
507
- }catch(e){let t=String(e.message||e);try{t=JSON.parse(t).detail||t;}catch(_){}msg.textContent=t;}
508
  go.disabled=false;
509
  };
510
  }
511
  boot().catch(e=>{app.innerHTML=W(`<div class="note" style="margin-top:50px">
512
- 화면을 불러오지 못했습니다. ${esc(e.message||e)}</div>`);});
513
  </script>
514
  </body>
515
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>LEADBOARD — 신약 예측 도구 시험대</title>
7
+ <meta name="description" content="One yardstick, per discipline, for drug prediction tools. Baselines and experimental error published in advance.">
8
  <!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면
9
  그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. -->
10
  <style>
11
  :root{
12
+ --bg:#fbfcfd;--bg2:#f0f3f7;--card:#fff;--ink:#0b0d12;--ink2:#3a4150;
13
+ --muted:#6d7585;--line:#e6e9ef;--line2:#d0d6e0;
14
+ --acc:#1f4fa3;--acc2:#3d7ae0;--soft:#eaf0fb;
15
+ --warn:#8a5c00;--warnbg:#fff6e2;--warnln:#f0d9a0;
16
+ --good:#0f6b43;--goodbg:#e6f6ee;--bad:#9c2f24;--badbg:#fdeceb;
17
  --m:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
18
  --sh:0 1px 2px rgba(13,18,30,.05),0 2px 6px rgba(13,18,30,.05);
19
  --sh2:0 8px 16px -6px rgba(13,18,30,.14),0 20px 36px -12px rgba(13,18,30,.14);
20
  }
21
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
22
+ --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb;
23
+ --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4;
24
+ --soft:#111a2b;--warn:#e8bd5e;--warnbg:#241d0e;--warnln:#4a3c1a;
25
+ --good:#4fd396;--goodbg:#0c2418;--bad:#f59285;--badbg:#2b1411;
26
+ --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);}}
 
27
  :root[data-theme="dark"]{
28
  --bg:#090b0f;--bg2:#101319;--card:#141821;--ink:#f1f4f8;--ink2:#c8cfdb;
29
  --muted:#8b94a5;--line:#212734;--line2:#2e3646;--acc:#8cb0f0;--acc2:#6a95e4;
 
32
  --sh:0 1px 2px rgba(0,0,0,.4);--sh2:0 12px 32px rgba(0,0,0,.55);}
33
  *{box-sizing:border-box}
34
  /* 히어로 뒤 그라디언트가 좌우로 음수 inset 을 쓴다. z-index 를 내려도 스크롤 범위에는
35
+ 남아 가로 스크롤이 생긴다. clip 은 hidden 과 달리 sticky 헤더를 깨뜨리지 않는다. */
36
  html{overflow-x:clip}
37
+ body{margin:0;overflow-x:clip;background:var(--bg);color:var(--ink);line-height:1.62;font-size:15px;
38
  font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo",
39
  "Malgun Gothic",Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased}
40
  .wrap{max-width:1060px;margin:0 auto;padding:0 24px}
 
44
 
45
  nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 78%,transparent);
46
  backdrop-filter:saturate(180%) blur(16px);border-bottom:1px solid var(--line)}
47
+ nav .in{max-width:1060px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:11px}
48
  .lg{font-weight:850;letter-spacing:-.04em;font-size:18px;color:var(--ink)}
49
  .lg:hover{text-decoration:none}.lg i{color:var(--acc);font-style:normal}
50
  .sp{flex:1}
 
54
  .chip.ok{background:var(--goodbg);color:var(--good)}
55
  .chip.wr{background:var(--warnbg);color:var(--warn)}
56
  .chip.bd{background:var(--badbg);color:var(--bad)}
57
+ .lang{padding:5px 11px;border-radius:8px;border:1px solid var(--line2);background:var(--card);
58
+ color:var(--ink2);font-size:12px;font-weight:650;cursor:pointer;font-family:inherit}
59
+ .lang:hover{border-color:var(--acc);color:var(--acc)}
60
  .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 19px;border-radius:11px;
61
  background:linear-gradient(180deg,var(--acc2),var(--acc));color:#fff;font-size:14px;
62
  font-weight:650;border:0;cursor:pointer;box-shadow:var(--sh);transition:transform .1s,filter .15s}
63
  .btn:hover{filter:brightness(1.08);text-decoration:none}
64
  .btn:active{transform:translateY(1px)}.btn:disabled{opacity:.45;cursor:default}
65
 
 
66
  .hero{padding:78px 0 46px;position:relative}
67
  .hero::before{content:"";position:absolute;inset:-70% -30% auto -30%;height:520px;z-index:-1;
68
  background:radial-gradient(ellipse 55% 50% at 32% 55%,color-mix(in srgb,var(--acc) 16%,transparent),transparent 70%)}
69
  .brand{font-size:clamp(46px,9vw,80px);font-weight:900;letter-spacing:-.05em;margin:0;line-height:.96}
70
  .brand i{color:var(--acc);font-style:normal}
71
+ .subttl{font-size:12.5px;color:var(--muted);margin:9px 0 0;letter-spacing:.11em;text-transform:uppercase}
72
+ .tag{font-size:clamp(23px,3.9vw,34px);font-weight:750;margin:30px 0 14px;
73
+ letter-spacing:-.03em;line-height:1.32;max-width:17em}
74
+ .lede{font-size:16.5px;color:var(--ink2);margin:0;max-width:38em;line-height:1.72}
75
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(128px,1fr));gap:1px;
76
+ margin:44px 0 0;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden}
 
77
  .stat{background:var(--card);padding:17px 19px}
78
+ .stat b{display:block;font-size:30px;font-weight:850;font-family:var(--m);letter-spacing:-.045em;line-height:1.1}
 
79
  .stat span{font-size:11.5px;color:var(--muted)}
80
 
81
+ .sec{margin:76px 0 0}
82
  .sec h2{font-size:11.5px;font-weight:750;letter-spacing:.17em;text-transform:uppercase;
83
+ color:var(--acc);margin:0 0 8px}
84
+ .sec .lead2{font-size:15.5px;color:var(--ink2);margin:0 0 22px;max-width:46em}
85
  h3{font-size:18.5px;font-weight:720;margin:30px 0 9px;letter-spacing:-.018em}
86
  p{margin:0 0 15px}
87
 
 
88
  .terr{background:var(--card);border:1px solid var(--line);border-radius:16px;
89
  padding:22px 24px 18px;box-shadow:var(--sh)}
90
+ .trow{display:grid;grid-template-columns:104px 1fr 60px;align-items:center;gap:12px;padding:4px 0;font-size:13px}
 
91
  .trow .nm{font-weight:650;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
  .trow .nm a{color:var(--ink)}
93
  .trk{position:relative;height:19px;background:var(--bg2);border-radius:5px;overflow:hidden}
 
97
  .trk .one{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--ink);opacity:.42}
98
  .trow .v{font-family:var(--m);font-size:12px;color:var(--muted);text-align:right}
99
  .trow .v.in{color:var(--warn);font-weight:700}
100
+ .taxis{display:grid;grid-template-columns:104px 1fr 60px;gap:12px;margin-top:11px;
101
  font-size:11px;color:var(--muted);font-family:var(--m)}
102
+ .taxis .lbl{display:flex;justify-content:space-between;gap:8px}
103
 
 
104
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:15px}
105
  .cc{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px;
106
  display:block;box-shadow:var(--sh);transition:box-shadow .2s,transform .13s,border-color .2s}
 
127
  box-shadow:var(--sh);margin:18px 0}
128
  table{width:100%;border-collapse:collapse;font-size:13.5px}
129
  th{text-align:right;font-weight:650;color:var(--muted);font-size:10.5px;letter-spacing:.08em;
130
+ text-transform:uppercase;padding:13px;border-bottom:1px solid var(--line);white-space:nowrap}
131
  th:first-child,td:first-child{text-align:center;width:48px}
132
  th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){text-align:left}
133
  td{padding:13px;border-bottom:1px solid var(--line);text-align:right;
 
149
  .note{background:var(--warnbg);border:1px solid var(--warnln);border-radius:13px;
150
  padding:15px 18px;font-size:14.5px;margin:18px 0;color:var(--ink2);line-height:1.65}
151
  .note.ok{background:var(--goodbg);border-color:color-mix(in srgb,var(--good) 30%,transparent)}
152
+ .note.n{background:var(--card);border-color:var(--line)}
153
+ .kv{display:grid;grid-template-columns:190px 1fr;gap:10px 18px;font-size:14px;margin:15px 0}
154
  .kv dt{color:var(--muted)}.kv dd{margin:0;font-family:var(--m);font-size:13px;word-break:break-word}
155
  label{display:block;font-size:13.5px;font-weight:680;margin:17px 0 6px}
156
  .hint{font-size:12px;color:var(--muted);font-weight:400}
 
169
  display:flex;align-items:center;justify-content:center;font-family:var(--m);border:3px solid var(--bg)}
170
  .steps b{display:block;font-size:16px;margin-bottom:4px}
171
  ol.cl{list-style:none;counter-reset:c;padding:0;margin:18px 0;
172
+ display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:0}
173
  ol.cl li{counter-increment:c;position:relative;padding:16px 16px 16px 52px;border-top:1px solid var(--line)}
174
  ol.cl li::before{content:counter(c);position:absolute;left:8px;top:16px;width:30px;height:30px;
175
  border-radius:9px;background:var(--soft);color:var(--acc);font-weight:750;font-size:13px;
 
183
  font-size:13px;color:var(--muted);display:flex;gap:20px;flex-wrap:wrap;align-items:center}
184
  @media(max-width:640px){
185
  .kv{grid-template-columns:1fr}.kv dt{margin-top:8px}
186
+ .trow,.taxis{grid-template-columns:78px 1fr 48px;gap:8px;font-size:12px}}
187
  </style>
188
  </head>
189
  <body>
 
192
  <a class="lg" href="#/">LEAD<i>BOARD</i></a>
193
  <span class="chip g" id="spec">v1.1</span>
194
  <span class="sp"></span>
195
+ <button class="lang" id="lang"></button>
196
  <span id="who" style="font-size:13px;color:var(--muted)"></span>
197
  </div></nav>
198
 
199
  <div id="app"><div class="wrap"><div class="skel" style="margin-top:70px"></div></div></div>
200
+ <footer class="wrap" id="foot"></footer>
201
 
202
+ <script src="i18n.js"></script>
 
 
 
 
 
 
 
 
 
203
  <script>
204
  const $=s=>document.querySelector(s), app=$("#app");
205
  const f=(x,d=3)=>(x==null||isNaN(x))?"—":Number(x).toFixed(d);
206
  const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
207
  const W=h=>`<div class="wrap">${h}</div>`;
208
+ let CATS=null, ME={user:null}, BOARDS={}, L=null, t=null;
209
+
210
+ /* 언어 결정 순서: 저장된 선택 → 브라우저 설정 → 영어.
211
+ 브라우저를 먼저 보면 한 번 고른 선택이 새로고침마다 뒤집힌다. */
212
+ function pickLang(){
213
+ const saved=localStorage.getItem("lb-lang");
214
+ if(saved && window.T[saved]) return saved;
215
+ return (navigator.language||"").toLowerCase().startsWith("ko") ? "ko" : "en";
216
+ }
217
+ function setLang(l){
218
+ L=l; t=window.T[l];
219
+ localStorage.setItem("lb-lang", l);
220
+ document.documentElement.lang=t.htmlLang;
221
+ document.title=t.title;
222
+ $("#lang").textContent=t.langBtn;
223
+ paintChrome();
224
+ route();
225
+ }
226
+ function paintChrome(){
227
+ $("#who").innerHTML=ME.user?`${esc(ME.user.user)} · <a href="/logout">${t.logout}</a>`
228
+ :(ME.login_enabled?`<a href="/login?next=${encodeURIComponent(location.hash||"#/")}">${t.login}</a>`:"");
229
+ $("#foot").innerHTML=`<span>LEADBOARD · FINAL-Bench / VIDRAFT</span>
230
+ <span class="dim">${t.srcLabel} <a href="https://www.ebi.ac.uk/chembl/">ChEMBL</a> ·
231
+ <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0</a></span>
232
+ <span class="sp"></span><a href="/api/health">API</a>
233
+ <a href="https://huggingface.co/spaces/FINAL-Bench/open-discovery-challenge">ODC</a>
234
+ <a href="https://huggingface.co/spaces/FINAL-Bench/open-materials-challenge">OMC</a>`;
235
+ }
236
  async function j(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(await r.text());return r.json();}
237
 
238
+ /* ═══ 측정 한계 지형 ═══ */
239
+ function terrain(items,link){
240
+ const MAX=3.0, pc=v=>Math.min(100,100*v/MAX);
241
+ return `<div class="terr">${items.map(b=>{
242
+ const inside=b.ratio<1.0;
243
+ const nm=link?`<a href="#/b/${encodeURIComponent(b.board)}">${esc(b.board)}</a>`:esc(b.board);
244
+ return `<div class="trow"><div class="nm">${nm}</div>
245
+ <div class="trk"><div class="fill ${inside?"in":""}" style="width:${pc(b.ratio)}%"></div>
246
+ <div class="one" style="left:${pc(1)}%"></div></div>
247
+ <div class="v ${inside?"in":""}">${f(b.ratio,2)}×</div></div>`;}).join("")}
248
+ <div class="taxis"><span></span><span class="lbl">
249
+ <i>0</i><i>← ${t.terrLeft} · 1.0 · ${t.terrRight} →</i><i>${MAX.toFixed(1)}</i>
250
+ </span><span></span></div></div>`;
 
 
 
 
 
 
 
 
251
  }
252
+ const ratioOf=b=>Math.min(...Object.values(b.baselines).map(v=>v.mae))/b.noise_floor.sd_single;
 
253
 
254
  async function boot(){
255
  try{ME=await j("/api/me");}catch(e){}
 
 
256
  CATS=await j("/api/categories");
 
257
  CATS.categories.forEach(c=>(c.board_cards||[]).forEach(b=>BOARDS[b.board]=b));
258
+ $("#spec").textContent=CATS.spec;
259
+ $("#lang").onclick=()=>setLang(L==="ko"?"en":"ko");
260
+ setLang(pickLang());
261
  }
262
  addEventListener("hashchange",route);
263
  function route(){
264
+ if(!t)return;
265
  const p=(location.hash||"#/").slice(2).split("/").filter(Boolean);scrollTo(0,0);
266
  if(!p.length)return hub();
267
  if(p[0]==="c")return category(p[1]);
 
271
 
272
  /* ═══ 허브 ═══ */
273
  function hub(){
274
+ const T0=CATS.totals, bs=Object.values(BOARDS);
275
  const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})).sort((a,b)=>a.ratio-b.ratio);
276
  const inside=items.filter(x=>x.ratio<1.3).length;
 
277
  const lost=bs.filter(b=>b.beats_constant===false).length;
278
  app.innerHTML=W(`
279
  <div class="hero">
280
  <h1 class="brand">LEAD<i>BOARD</i></h1>
281
+ <p class="subttl">${t.heroKicker}</p>
282
+ <p class="tag">${t.heroTag}</p>
283
+ <p class="lede">${t.heroLede}</p>
 
 
284
  <div class="stats">
285
+ <div class="stat"><b>${T0.open}</b><span>${t.statBoards}</span></div>
286
+ <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div>
287
+ <div class="stat"><b>${CATS.categories.filter(c=>c.open).length}</b><span>${t.statFields}</span></div>
288
+ <div class="stat"><b>8</b><span>${t.statClauses}</span></div>
289
+ <div class="stat"><b>${T0.planned}</b><span>${t.statPlanned}</span></div>
290
  </div>
291
  </div>
292
 
293
+ <div class="sec"><h2>${t.secBrowse}</h2><p class="lead2">${t.secBrowseLead}</p>
294
+ <div class="grid">${CATS.categories.map(cardHTML).join("")}</div></div>
295
+
296
+ <div class="sec"><h2>${t.secEvidence}</h2>
297
+ <p class="lead2">${t.secEvidenceLead} ${t.insideCount(inside)}</p>
298
  ${terrain(items,true)}
299
+ ${lost?`<div class="note">${t.lostNote(lost)}</div>`:""}</div>
 
 
 
300
 
301
+ <div class="sec"><h2>${t.secMethod}</h2><p class="lead2">${t.secMethodLead}</p>
302
+ <ol class="cl">${t.clauses.map(([a,b])=>`<li><b>${a}</b><span>${b}</span></li>`).join("")}</ol></div>
 
 
 
303
 
304
+ <div class="sec"><h2>${t.secData}</h2>
305
+ <div class="note n">${t.dataNote}<br><br>
306
+ ${t.srcLabel} · <a href="https://www.ebi.ac.uk/chembl/">ChEMBL_37 (EMBL-EBI)</a> ·
307
+ <a href="https://creativecommons.org/licenses/by-sa/3.0/">CC BY-SA 3.0 Unported</a></div></div>`);
 
 
 
 
 
 
 
 
 
 
 
308
  }
309
 
310
  function cardHTML(c){
311
  const on=c.open>0, bs=c.board_cards||[];
312
  const tot=bs.reduce((s,b)=>s+b.n_test,0);
313
  const inner=`<div class="ico">${c.icon}</div>
314
+ <div class="nm">${esc(L==="ko"?c.name:c.en)}
315
+ <span class="chip ${on?"ok":"g"}">${on?t.openN(c.open):t.prep}</span></div>
316
+ <div class="ds">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</div>
317
+ <div class="mt"><span>${t.cardBoards(c.open,c.planned)}</span>
318
+ <span>${tot?t.cardCompounds(tot.toLocaleString()):"—"}</span></div>`;
319
  return on?`<a class="cc" href="#/c/${c.id}">${inner}</a>`:`<div class="cc off">${inner}</div>`;
320
  }
321
 
 
324
  const c=CATS.categories.find(x=>x.id===id); if(!c)return hub();
325
  const bs=(c.board_cards||[]).slice().sort((a,b)=>b.n_test-a.n_test);
326
  const items=bs.map(b=>({board:b.board,ratio:ratioOf(b)})).sort((a,b)=>a.ratio-b.ratio);
327
+ const nm=L==="ko"?c.name:c.en;
328
  app.innerHTML=W(`
329
+ <div class="crumb"><a href="#/">${t.all}</a> › ${esc(nm)}</div>
330
+ <h1 class="ttl">${c.icon} ${esc(nm)}</h1>
331
+ <p class="lede">${esc(L==="ko"?c.desc:(c.desc_en||c.desc))}</p>
332
  <div class="stats" style="margin-top:26px">
333
+ <div class="stat"><b>${c.open}</b><span>${t.statBoards}</span></div>
334
+ <div class="stat"><b>${bs.reduce((s,b)=>s+b.n_test,0).toLocaleString()}</b><span>${t.statCompounds}</span></div>
335
+ <div class="stat"><b>${c.planned}</b><span>${t.statPlanned}</span></div></div>
 
336
  ${bs.length?`
337
+ <div class="sec"><h2>${t.secBrowse}</h2><div class="grid">${bs.map(boardMini).join("")}</div></div>
338
+ <div class="sec"><h2>${t.secEvidence}</h2><p class="lead2">${t.secEvidenceLead}</p>
339
+ ${terrain(items,true)}</div>`
340
+ :`<div class="note">${t.emptyCat}</div>`}`);
 
341
  }
342
  function boardMini(b){
343
  const bl=b.baselines[b.best_baseline]||{};
344
  return `<a class="cc" href="#/b/${encodeURIComponent(b.board)}">
345
  <div class="nm">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></div>
346
+ <div class="ds">${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} ${f(b.noise_floor.sd_single)}<br>
347
+ ${esc(b.best_baseline)} ${f(bl.mae)}</div>
348
  <div class="mt"><span>${esc(b.source)}</span><span>${f(ratioOf(b),2)}×</span></div></a>`;
349
  }
350
 
 
352
  async function board(name,tab){
353
  const b=BOARDS[name]||await j("/api/board/"+encodeURIComponent(name));
354
  const cat=CATS.categories.find(c=>(c.boards||[]).includes(name));
355
+ const cn=cat?(L==="ko"?cat.name:cat.en):"";
356
+ const TT=[["rank",t.tabRank],["submit",t.tabSubmit],["info",t.tabInfo]];
357
  app.innerHTML=W(`
358
+ <div class="crumb"><a href="#/">${t.all}</a> › <a href="#/c/${cat?cat.id:""}">${esc(cn)}</a> › ${esc(b.board)}</div>
359
  <h1 class="ttl">${esc(b.board)}<span class="chip">${b.split_grade}/${b.answer_grade}</span></h1>
360
  <p class="lede">${esc(b.source)} · ${esc(b.type)} ·
361
+ ${t.cardCompounds(b.n_test.toLocaleString())} · ${t.noiseH} <b>${f(b.noise_floor.sd_single)}</b></p>
362
+ <div class="pills">${TT.map(([k,l])=>
363
  `<a class="pilltab ${k===tab?"on":""}" href="#/b/${encodeURIComponent(name)}/${k}">${l}</a>`).join("")}</div>
364
  <div id="pane"><div class="skel" style="margin-top:20px"></div></div>`);
365
  if(tab==="rank")return paneRank(b);
 
371
  const d=await j(`/api/board/${encodeURIComponent(b.board)}/leaderboard`);
372
  const nf=d.noise_floor, ent=d.rows.filter(r=>r.kind!=="baseline");
373
  const worst=Math.max(...d.rows.map(r=>r.mae||0))||1;
374
+ const leak=l=>l==="high"?`<span class="chip bd">${t.leakHigh}</span>`
375
+ :l==="elevated"?`<span class="chip wr">${t.leakElev}</span>`:`<span class="chip g">—</span>`;
376
  const rows=d.rows.map(r=>{
377
  const g=r.rank&&r.rank<=3?` g${r.rank}`:"";
378
  const w=Math.max(4,58*(r.mae||0)/worst);
 
381
  <td>${esc(r.method)}</td><td>${esc(r.user)}</td>
382
  <td><span class="mini" style="width:${w}px"></span>${f(r.mae)}</td>
383
  <td>${f(r.auroc)}</td><td>${f(r.prauc)}</td>
384
+ <td>${r.kind==="baseline"?`<span class="chip g">${t.baseline}</span>`:leak(r.leak)}</td>
385
+ <td>${r.kind==="baseline"?"—":(r.verified?`<span class="chip ok">${t.verified}</span>`
386
+ :`<span class="chip g">${t.submitted}</span>`)}</td></tr>`;}).join("");
387
  $("#pane").innerHTML=`
388
+ <div class="tw"><table><thead><tr><th>${t.thRank}</th><th>${t.thMethod}</th><th>${t.thUser}</th>
389
+ <th>MAE</th><th>AUROC</th><th>PR-AUC</th><th>${t.thLeak}</th><th>${t.thGrade}</th></tr></thead>
390
+ <tbody>${rows}</tbody></table></div>
391
+ <div class="note">${t.noiseNote(f(nf))}</div>
392
+ <p class="dim" style="font-size:13px">${ent.length
393
+ ? t.updated(new Date(d.updated*1000).toLocaleString(L==="ko"?"ko-KR":"en-GB"))
394
+ : t.noEntries}</p>`;
395
  }
396
 
397
  function paneInfo(b){
398
+ const nf=b.noise_floor, bl=b.baselines;
399
  $("#pane").innerHTML=`
400
+ <h3>${t.gradeH}</h3>
401
  <dl class="kv">
402
+ <dt>${t.splitGrade} <b>${b.split_grade}</b></dt>
403
+ <dd>${esc(t.gradeNote[b.split_grade]||b.split_grade_note||"")}</dd>
404
+ <dt>${t.answerGrade} <b>${b.answer_grade}</b></dt>
405
+ <dd>${esc(t.gradeNote[b.answer_grade]||b.answer_grade_note||"")}</dd></dl>
406
+ <h3>${t.holdoutH}</h3>
407
  <dl class="kv">
408
+ <dt>${t.kSource}</dt><dd>${esc(b.source)} · ${esc(b.target)} · ${esc(b.type)}</dd>
409
+ <dt>${t.kCut}</dt><dd>${b.cut} — ${t.cutNote}</dd>
410
+ <dt>${t.kTrainTest}</dt><dd>${b.n_train.toLocaleString()} / ${b.n_test.toLocaleString()}</dd>
411
+ <dt>${t.kOverlap}</dt><dd>${t.overlapNote}</dd>
412
+ <dt>${t.kThr}</dt><dd>${b.threshold} · ${t.posRate((b.positive_rate*100).toFixed(1))}</dd>
413
+ <dt>${t.kSha}</dt><dd>${esc(b.dataset_sha)}</dd>
414
+ <dt>${t.kLicense}</dt><dd>ChEMBL · CC BY-SA 3.0</dd></dl>
415
+ <h3>${t.noiseH} <span class="dim" style="font-weight:400;font-size:14px">(4)</span></h3>
416
+ <div class="fig">${t.noiseDesc}<br>
417
+ ${t.noisePairs(nf.pairs.toLocaleString(),nf.compounds.toLocaleString())}<br>
418
+ median ${f(nf.median)} · mean ${f(nf.mean)} · p90 ${f(nf.p90)}<br>
419
+ <b>${t.noiseSd} <span class="n">${f(nf.sd_single)}</span></b></div>
420
+ <div class="note">${t.nearNote(b.near_pct,b.near_pct>45)}</div>
421
+ <h3>${t.baseH} <span class="dim" style="font-weight:400;font-size:14px">(3)</span></h3>
422
+ <div class="tw"><table><thead><tr><th style="width:auto">·</th><th>${t.baseH}</th><th>MAE</th>
 
 
423
  <th>AUROC</th><th>PR-AUC</th></tr></thead><tbody>
424
  ${Object.entries(bl).map(([k,v])=>`<tr><td>·</td><td>${esc(k)}</td><td>${f(v.mae)}</td>
425
  <td>${f(v.auroc)}</td><td>${f(v.prauc)}</td></tr>`).join("")}
426
+ <tr class="base"><td>·</td><td>${t.noiseFloorRow}</td><td>${f(nf.sd_single)}</td><td>—</td><td>—</td></tr>
427
  </tbody></table></div>
428
+ <div class="note ${b.beats_constant?"ok":""}">${b.beats_constant?t.beatsYes:t.beatsNo}</div>`;
 
 
429
  }
430
 
431
  function paneSubmit(b){
432
  const url=`/api/board/${encodeURIComponent(b.board)}/testset`;
433
  $("#pane").innerHTML=`
434
  <ol class="steps">
435
+ <li><b>${t.step1}</b>
436
+ <p class="dim" style="font-size:13.5px">${t.step1d(b.n_test.toLocaleString())}<br>${t.licLine}</p>
437
+ <a class="btn" href="${url}" download>${t.dl}</a>
 
 
438
  <span class="hint">&nbsp;${esc(b.board.toLowerCase())}_test.json</span></li>
439
+ <li><b>${t.step2}</b>
440
  ${ME.user?`
441
+ <label>${t.fMethod} <span class="hint">${t.fMethodH}</span></label>
442
+ <input type="text" id="m" maxlength="60">
443
+ <label>${t.fTrain} <span class="hint">${t.fTrainH}</span></label>
444
+ <input type="text" id="td">
445
+ <label>${t.fPred} <span class="hint">${t.fPredH}</span></label>
446
+ <div class="drop" id="drop">${t.drop}<input type="file" id="file" accept=".json,.csv,.txt" hidden></div>
447
+ <textarea id="p" rows="6" style="margin-top:11px"></textarea>
448
+ <p style="margin-top:15px"><button class="btn" id="go">${t.send}</button>
 
 
449
  <span class="hint" id="msg"></span></p>`
450
+ :`<div class="note">${t.needLogin}
451
+ <a href="/login?next=${encodeURIComponent(location.hash)}">${t.login}</a></div>`}</li>
452
+ <li><b>${t.step3}</b><p class="dim" style="font-size:13.5px">${t.step3d}</p></li>
 
 
453
  </ol>
454
+ <div class="note">${t.ladderNote(f(b.noise_floor.sd_single),ME.daily_cap||5)}</div>`;
 
 
455
  wire(b);
456
  }
457
+ function parsePred(x){
458
+ x=x.trim(); if(x.startsWith("{"))return JSON.parse(x);
459
  const o={};
460
+ x.split(/\r?\n/).forEach((ln,i)=>{const p=ln.split(/[,\t]/);if(p.length<2)return;
461
  const k=p[0].trim();if(!k||(i===0&&isNaN(parseFloat(p[1]))))return;
462
  const v=parseFloat(p[1]);if(!isNaN(v))o[k]=v;});
463
+ if(!Object.keys(o).length)throw new Error("no readable entries");
464
  return o;
465
  }
466
  function wire(b){
467
  const drop=$("#drop"),file=$("#file"),ta=$("#p"),go=$("#go");
468
  const load=f0=>{const r=new FileReader();r.onload=()=>{ta.value=r.result;
469
+ drop.textContent=`${f0.name} — ${(f0.size/1024).toFixed(0)} KB`;};r.readAsText(f0);};
470
  if(drop){
471
  drop.onclick=()=>file.click();
472
  file.onchange=e=>e.target.files[0]&&load(e.target.files[0]);
 
477
  if(!go)return;
478
  go.onclick=async()=>{
479
  const msg=$("#msg");let preds;
480
+ try{preds=parsePred(ta.value);}catch(e){msg.textContent=t.parseFail(e.message);return;}
481
+ msg.textContent=t.sending(Object.keys(preds).length.toLocaleString());go.disabled=true;
482
  try{const r=await j("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"},
483
  body:JSON.stringify({board:b.board,method:($("#m").value||"").trim(),
484
  predictions:preds,training_data:($("#td").value||"").trim()})});
485
+ msg.textContent=t.accepted(r.submission_id);
486
+ }catch(e){let x=String(e.message||e);try{x=JSON.parse(x).detail||x;}catch(_){}msg.textContent=x;}
487
  go.disabled=false;
488
  };
489
  }
490
  boot().catch(e=>{app.innerHTML=W(`<div class="note" style="margin-top:50px">
491
+ ${(window.T&&window.T.en.loadFail)||"Load failed."} ${esc(e.message||e)}</div>`);});
492
  </script>
493
  </body>
494
  </html>