SeaWolf-AI commited on
Commit
4b9a7e1
ยท
verified ยท
1 Parent(s): a64dacf

3D structure viewer for published entries

Browse files
Files changed (4) hide show
  1. README.md +221 -221
  2. app.py +412 -374
  3. data/anchor_scores.json +381 -381
  4. index.html +155 -43
README.md CHANGED
@@ -1,221 +1,221 @@
1
- ---
2
- title: Open Discovery Challenge
3
- emoji: ๐Ÿงฌ
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
7
- app_port: 7860
8
- pinned: true
9
- # openid and profile come with hf_oauth by default; the extra scope list is for
10
- # permissions beyond that, and 'profile' is not a valid entry in it.
11
- hf_oauth: true
12
- short_description: Malaria drug leaderboard for AI-designed molecules
13
- tags:
14
- - drug-discovery
15
- - leaderboard
16
- - malaria
17
- - antimalarial
18
- - chemistry
19
- - cheminformatics
20
- - admet
21
- - molecular-docking
22
- - ai-for-science
23
- - benchmark
24
- - smiles
25
- - open-science
26
- ---
27
-
28
- # Open Discovery Challenge #1 โ€” Malaria
29
-
30
- **An open leaderboard for AI-discovered malaria drug candidates.** Design a molecule with
31
- any AI model, submit it as SMILES, and it is scored automatically on potency, selectivity,
32
- ADMET and novelty โ€” then ranked in the same table as approved antimalarial drugs.
33
-
34
- **Season #1 closes 30 September 2026. The top entry receives USD 1,000.**
35
-
36
- ---
37
-
38
- ## What is this leaderboard?
39
-
40
- A public drug discovery leaderboard that answers one question: **is the molecule your AI
41
- just designed actually any good?**
42
-
43
- Large language models can propose thousands of drug candidates a day. Almost nobody can
44
- tell which of them are worth anything, because the tools that judge a candidate โ€” docking,
45
- whole-cell activity prediction, ADMET, selectivity modelling โ€” are not generally
46
- available. This leaderboard makes that judgement free and public.
47
-
48
- Every submission is scored by the same rubric, and **approved drugs and inert compounds
49
- are scored alongside the entries** so you can see the scale for yourself.
50
-
51
- | | |
52
- |---|---|
53
- | **Target** | PfDHODH โ€” *Plasmodium falciparum* dihydroorotate dehydrogenase |
54
- | **Counter-target** | Human DHODH โ€” must NOT be inhibited |
55
- | **Submission format** | SMILES or InChI |
56
- | **Score** | 0โ€“100, computed automatically |
57
- | **Turnaround** | Minutes per entry (GPU queue) |
58
- | **Season #1 deadline** | 30 September 2026 |
59
- | **Prize** | USD 1,000 to the top-ranked entry |
60
- | **Who can enter** | Anyone with a Hugging Face account |
61
- | **Cost** | Free |
62
-
63
- ---
64
-
65
- ## How the leaderboard works
66
-
67
- **1. Design a candidate with any AI model.**
68
- OpenAI, Claude, Gemini, Qwen, KIMI, DeepSeek, open-weight models, or by hand. The
69
- challenge does not restrict the tool โ€” it records which model you used, so the leaderboard
70
- also shows *which AI models produce the best drug candidates*.
71
-
72
- **2. Submit the structure.**
73
- SMILES or InChI. Molecular formulas are not accepted โ€” too many isomers to evaluate.
74
- Every entry is structure-checked on arrival for reactivity, PAINS motifs and duplicates.
75
-
76
- **3. It is scored and ranked automatically.**
77
- VIDRAFT's PharmaOS and CellOS compute binding, whole-cell antimalarial activity,
78
- selectivity over the human enzyme, ADMET, novelty and synthetic accessibility. Results
79
- appear on the leaderboard within minutes.
80
-
81
- ---
82
-
83
- ## Scoring rubric
84
-
85
- | Axis | Points | What it measures |
86
- |---|---|---|
87
- | **Whole-cell activity** | 30 | Does the parasite actually die |
88
- | **Target binding** | 20 | Does it bind PfDHODH, efficiently for its size |
89
- | **Selectivity** | 20 | Binds the parasite enzyme, not the human one |
90
- | **ADMET** | 15 | Absorption, distribution, metabolism, toxicity |
91
- | **Novelty** | 10 | Structurally distinct from known antimalarials |
92
- | **Synthesis** | 5 | Can it actually be made |
93
-
94
- Three policies worth knowing before you submit:
95
-
96
- - **Novelty is multiplied by potency.** A novel but inactive molecule scores zero on this
97
- axis. So does a potent analogue of a known drug.
98
- - **ADMET, novelty and synthesis scale with how much of a candidate the molecule is.** A
99
- perfectly safe compound that does nothing has achieved nothing.
100
- - **Uncertain predictions score lower.** Entries are graded on a lower bound, so a
101
- molecule the models cannot judge confidently is marked down.
102
-
103
- The full rubric, five ready-to-use AI prompts, and the list of rejection criteria are in
104
- the **Entrant guide** tab.
105
-
106
- ---
107
-
108
- ## Why you can trust the scores
109
-
110
- **Approved drugs are on the leaderboard.** Not as decoration โ€” as a calibration ladder you
111
- can read directly.
112
-
113
- | Reference compound | Score | What it is |
114
- |---|---|---|
115
- | **DSM265** | **50.9** | Clinical-stage antimalarial, PfDHODH inhibitor |
116
- | Brequinar | 4.0 | Inhibits the **human** enzyme โ€” wrong target |
117
- | Teriflunomide | 2.8 | Approved drug, **human** DHODH โ€” wrong target |
118
- | Ibuprofen | 1.9 | Inert control |
119
- | Caffeine | 1.8 | Inert control |
120
-
121
- If the clinical candidate sits at the top and caffeine sits at the bottom, the scorer
122
- discriminates. That is a claim you can check rather than one you have to accept.
123
-
124
- Hover any row to see **every axis score and the numbers behind it** โ€” predicted potency
125
- against each enzyme, the selectivity ratio, similarity to the nearest known compound,
126
- synthetic difficulty. A score you cannot interrogate is a score you cannot trust.
127
-
128
- The predictive models behind the scoring hold **first place across 16 tracks of Polaris
129
- Hub**, the public benchmark for drug property prediction, spanning absorption,
130
- distribution, metabolism, toxicity, potency and kinase selectivity.
131
-
132
- ---
133
-
134
- ## Your molecule stays yours
135
-
136
- **Ownership remains entirely with the entrant.** VIDRAFT scores your submission and
137
- displays it. We take no patent or ownership interest, pass nothing to third parties, and
138
- put nothing into our own pipeline.
139
-
140
- You choose whether the structure is published. Two things stated plainly:
141
-
142
- - **Private hides the structure from other entrants and the public โ€” not from us.**
143
- Scoring requires the structure, so it is stored and used for nothing else.
144
- - **Publishing a structure can cost you patentability.** Disclosure can bar a later patent
145
- on that compound. **If you have commercial intent, keep it private and file first.**
146
-
147
- ---
148
-
149
- ## Frequently asked questions
150
-
151
- **Do I need to be a chemist?**
152
- No. The Entrant guide provides five prompts you can paste into any AI model. Most entrants
153
- will get further by iterating on their score than by knowing organic chemistry.
154
-
155
- **Which AI model should I use?**
156
- Any. The leaderboard tracks per-model standings โ€” best score, average and submission
157
- volume โ€” so you can see which models are actually producing good candidates rather than
158
- just being popular.
159
-
160
- **How long does scoring take?**
161
- Minutes. Entries are queued and processed on GPU. Submissions are interleaved by entrant,
162
- so a high-volume entrant cannot monopolise the queue.
163
-
164
- **Is there a submission limit?**
165
- No. Duplicate structures are rejected, and the queue takes turns between entrants.
166
-
167
- **What gets rejected?**
168
- Unparseable structures, molecular formulas, covalent warheads (Season 1 is a non-covalent
169
- track), molecular weight over 550, PAINS motifs, and duplicates of an existing entry.
170
-
171
- **What gets relegated instead of rejected?**
172
- Predicted mutagenicity or extreme insolubility. Those entries are scored in full and
173
- displayed, but rank below every clean entry โ€” a molecule that binds beautifully and is
174
- mutagenic is not a lead.
175
-
176
- **Does the score mean my compound works?**
177
- No. Scores are a **computational assessment of candidates**. They are not measurements,
178
- not claims about real efficacy or safety, and not a ranking against approved drugs.
179
- Experimental validation is a separate process.
180
-
181
- **Will there be other diseases?**
182
- Yes. Each season targets a different disease. Malaria is #1.
183
-
184
- ---
185
-
186
- ## Why malaria first
187
-
188
- Malaria killed roughly **597,000 people in 2023**, and about three quarters of them were
189
- children under five (WHO World Malaria Report 2024). Not for want of chemistry โ€” for want
190
- of a market, because the patients are poor.
191
-
192
- That is precisely the gap where people working from conviction rather than return can
193
- still change the outcome.
194
-
195
- ---
196
-
197
- ## Prize
198
-
199
- The top-ranked entry at the close of each season receives a prize. Season #1 carries
200
- **USD 1,000**, denominated in USD with the payment method agreed with the recipient.
201
- Winners are announced in the Space community tab. Ties go to whoever submitted first.
202
- Should sponsors come on board, the amount increases.
203
-
204
- The prize does not pay for your time or tokens, and is not meant to. It is a way of saying
205
- that what you did had worth.
206
-
207
- ---
208
-
209
- ## Built with
210
-
211
- **PharmaOS** โ€” molecule and target: docking against the parasite and human enzymes,
212
- binding efficiency relative to molecular size, structural distance from known antimalarial
213
- chemical space, synthetic accessibility.
214
-
215
- **CellOS** โ€” cell and organism: whole-cell antimalarial activity, ADMET, avoidance of the
216
- human enzyme, and uncertainty-aware scoring.
217
-
218
- ---
219
-
220
- **Open Discovery Challenge #1 โ€” Malaria** ยท Season closes 30 September 2026
221
- Scores are computational predictions and not claims of efficacy or safety.
 
1
+ ---
2
+ title: Open Discovery Challenge
3
+ emoji: ๐Ÿงฌ
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ # openid and profile come with hf_oauth by default; the extra scope list is for
10
+ # permissions beyond that, and 'profile' is not a valid entry in it.
11
+ hf_oauth: true
12
+ short_description: Malaria drug leaderboard for AI-designed molecules
13
+ tags:
14
+ - drug-discovery
15
+ - leaderboard
16
+ - malaria
17
+ - antimalarial
18
+ - chemistry
19
+ - cheminformatics
20
+ - admet
21
+ - molecular-docking
22
+ - ai-for-science
23
+ - benchmark
24
+ - smiles
25
+ - open-science
26
+ ---
27
+
28
+ # Open Discovery Challenge #1 โ€” Malaria
29
+
30
+ **An open leaderboard for AI-discovered malaria drug candidates.** Design a molecule with
31
+ any AI model, submit it as SMILES, and it is scored automatically on potency, selectivity,
32
+ ADMET and novelty โ€” then ranked in the same table as approved antimalarial drugs.
33
+
34
+ **Season #1 closes 30 September 2026. The top entry receives USD 1,000.**
35
+
36
+ ---
37
+
38
+ ## What is this leaderboard?
39
+
40
+ A public drug discovery leaderboard that answers one question: **is the molecule your AI
41
+ just designed actually any good?**
42
+
43
+ Large language models can propose thousands of drug candidates a day. Almost nobody can
44
+ tell which of them are worth anything, because the tools that judge a candidate โ€” docking,
45
+ whole-cell activity prediction, ADMET, selectivity modelling โ€” are not generally
46
+ available. This leaderboard makes that judgement free and public.
47
+
48
+ Every submission is scored by the same rubric, and **approved drugs and inert compounds
49
+ are scored alongside the entries** so you can see the scale for yourself.
50
+
51
+ | | |
52
+ |---|---|
53
+ | **Target** | PfDHODH โ€” *Plasmodium falciparum* dihydroorotate dehydrogenase |
54
+ | **Counter-target** | Human DHODH โ€” must NOT be inhibited |
55
+ | **Submission format** | SMILES or InChI |
56
+ | **Score** | 0โ€“100, computed automatically |
57
+ | **Turnaround** | Minutes per entry (GPU queue) |
58
+ | **Season #1 deadline** | 30 September 2026 |
59
+ | **Prize** | USD 1,000 to the top-ranked entry |
60
+ | **Who can enter** | Anyone with a Hugging Face account |
61
+ | **Cost** | Free |
62
+
63
+ ---
64
+
65
+ ## How the leaderboard works
66
+
67
+ **1. Design a candidate with any AI model.**
68
+ OpenAI, Claude, Gemini, Qwen, KIMI, DeepSeek, open-weight models, or by hand. The
69
+ challenge does not restrict the tool โ€” it records which model you used, so the leaderboard
70
+ also shows *which AI models produce the best drug candidates*.
71
+
72
+ **2. Submit the structure.**
73
+ SMILES or InChI. Molecular formulas are not accepted โ€” too many isomers to evaluate.
74
+ Every entry is structure-checked on arrival for reactivity, PAINS motifs and duplicates.
75
+
76
+ **3. It is scored and ranked automatically.**
77
+ VIDRAFT's PharmaOS and CellOS compute binding, whole-cell antimalarial activity,
78
+ selectivity over the human enzyme, ADMET, novelty and synthetic accessibility. Results
79
+ appear on the leaderboard within minutes.
80
+
81
+ ---
82
+
83
+ ## Scoring rubric
84
+
85
+ | Axis | Points | What it measures |
86
+ |---|---|---|
87
+ | **Whole-cell activity** | 30 | Does the parasite actually die |
88
+ | **Target binding** | 20 | Does it bind PfDHODH, efficiently for its size |
89
+ | **Selectivity** | 20 | Binds the parasite enzyme, not the human one |
90
+ | **ADMET** | 15 | Absorption, distribution, metabolism, toxicity |
91
+ | **Novelty** | 10 | Structurally distinct from known antimalarials |
92
+ | **Synthesis** | 5 | Can it actually be made |
93
+
94
+ Three policies worth knowing before you submit:
95
+
96
+ - **Novelty is multiplied by potency.** A novel but inactive molecule scores zero on this
97
+ axis. So does a potent analogue of a known drug.
98
+ - **ADMET, novelty and synthesis scale with how much of a candidate the molecule is.** A
99
+ perfectly safe compound that does nothing has achieved nothing.
100
+ - **Uncertain predictions score lower.** Entries are graded on a lower bound, so a
101
+ molecule the models cannot judge confidently is marked down.
102
+
103
+ The full rubric, five ready-to-use AI prompts, and the list of rejection criteria are in
104
+ the **Entrant guide** tab.
105
+
106
+ ---
107
+
108
+ ## Why you can trust the scores
109
+
110
+ **Approved drugs are on the leaderboard.** Not as decoration โ€” as a calibration ladder you
111
+ can read directly.
112
+
113
+ | Reference compound | Score | What it is |
114
+ |---|---|---|
115
+ | **DSM265** | **50.9** | Clinical-stage antimalarial, PfDHODH inhibitor |
116
+ | Brequinar | 4.0 | Inhibits the **human** enzyme โ€” wrong target |
117
+ | Teriflunomide | 2.8 | Approved drug, **human** DHODH โ€” wrong target |
118
+ | Ibuprofen | 1.9 | Inert control |
119
+ | Caffeine | 1.8 | Inert control |
120
+
121
+ If the clinical candidate sits at the top and caffeine sits at the bottom, the scorer
122
+ discriminates. That is a claim you can check rather than one you have to accept.
123
+
124
+ Hover any row to see **every axis score and the numbers behind it** โ€” predicted potency
125
+ against each enzyme, the selectivity ratio, similarity to the nearest known compound,
126
+ synthetic difficulty. A score you cannot interrogate is a score you cannot trust.
127
+
128
+ The predictive models behind the scoring hold **first place across 16 tracks of Polaris
129
+ Hub**, the public benchmark for drug property prediction, spanning absorption,
130
+ distribution, metabolism, toxicity, potency and kinase selectivity.
131
+
132
+ ---
133
+
134
+ ## Your molecule stays yours
135
+
136
+ **Ownership remains entirely with the entrant.** VIDRAFT scores your submission and
137
+ displays it. We take no patent or ownership interest, pass nothing to third parties, and
138
+ put nothing into our own pipeline.
139
+
140
+ You choose whether the structure is published. Two things stated plainly:
141
+
142
+ - **Private hides the structure from other entrants and the public โ€” not from us.**
143
+ Scoring requires the structure, so it is stored and used for nothing else.
144
+ - **Publishing a structure can cost you patentability.** Disclosure can bar a later patent
145
+ on that compound. **If you have commercial intent, keep it private and file first.**
146
+
147
+ ---
148
+
149
+ ## Frequently asked questions
150
+
151
+ **Do I need to be a chemist?**
152
+ No. The Entrant guide provides five prompts you can paste into any AI model. Most entrants
153
+ will get further by iterating on their score than by knowing organic chemistry.
154
+
155
+ **Which AI model should I use?**
156
+ Any. The leaderboard tracks per-model standings โ€” best score, average and submission
157
+ volume โ€” so you can see which models are actually producing good candidates rather than
158
+ just being popular.
159
+
160
+ **How long does scoring take?**
161
+ Minutes. Entries are queued and processed on GPU. Submissions are interleaved by entrant,
162
+ so a high-volume entrant cannot monopolise the queue.
163
+
164
+ **Is there a submission limit?**
165
+ No. Duplicate structures are rejected, and the queue takes turns between entrants.
166
+
167
+ **What gets rejected?**
168
+ Unparseable structures, molecular formulas, covalent warheads (Season 1 is a non-covalent
169
+ track), molecular weight over 550, PAINS motifs, and duplicates of an existing entry.
170
+
171
+ **What gets relegated instead of rejected?**
172
+ Predicted mutagenicity or extreme insolubility. Those entries are scored in full and
173
+ displayed, but rank below every clean entry โ€” a molecule that binds beautifully and is
174
+ mutagenic is not a lead.
175
+
176
+ **Does the score mean my compound works?**
177
+ No. Scores are a **computational assessment of candidates**. They are not measurements,
178
+ not claims about real efficacy or safety, and not a ranking against approved drugs.
179
+ Experimental validation is a separate process.
180
+
181
+ **Will there be other diseases?**
182
+ Yes. Each season targets a different disease. Malaria is #1.
183
+
184
+ ---
185
+
186
+ ## Why malaria first
187
+
188
+ Malaria killed roughly **597,000 people in 2023**, and about three quarters of them were
189
+ children under five (WHO World Malaria Report 2024). Not for want of chemistry โ€” for want
190
+ of a market, because the patients are poor.
191
+
192
+ That is precisely the gap where people working from conviction rather than return can
193
+ still change the outcome.
194
+
195
+ ---
196
+
197
+ ## Prize
198
+
199
+ The top-ranked entry at the close of each season receives a prize. Season #1 carries
200
+ **USD 1,000**, denominated in USD with the payment method agreed with the recipient.
201
+ Winners are announced in the Space community tab. Ties go to whoever submitted first.
202
+ Should sponsors come on board, the amount increases.
203
+
204
+ The prize does not pay for your time or tokens, and is not meant to. It is a way of saying
205
+ that what you did had worth.
206
+
207
+ ---
208
+
209
+ ## Built with
210
+
211
+ **PharmaOS** โ€” molecule and target: docking against the parasite and human enzymes,
212
+ binding efficiency relative to molecular size, structural distance from known antimalarial
213
+ chemical space, synthetic accessibility.
214
+
215
+ **CellOS** โ€” cell and organism: whole-cell antimalarial activity, ADMET, avoidance of the
216
+ human enzyme, and uncertainty-aware scoring.
217
+
218
+ ---
219
+
220
+ **Open Discovery Challenge #1 โ€” Malaria** ยท Season closes 30 September 2026
221
+ Scores are computational predictions and not claims of efficacy or safety.
app.py CHANGED
@@ -1,374 +1,412 @@
1
- # -*- coding: utf-8 -*-
2
- """Open Discovery Challenge - submission intake and leaderboard.
3
-
4
- Deliberately thin. Nothing here computes a score.
5
-
6
- Scoring needs docking, docking needs a GPU, and a docking call takes minutes: the tunnel
7
- in front of this service cuts a request at ~125 s, and boltz spawns grandchildren that
8
- hold the stdout pipe open so a subprocess call never returns and wedges the whole worker.
9
- That combination already took the PharmaOS API down once. So submissions land in a ledger
10
- here, a worker on the GPU box picks them up, and results come back the same way. One
11
- entrant can never block the service.
12
-
13
- Endpoints
14
- GET / leaderboard page
15
- POST /api/submit accept a structure, run the gates, queue it
16
- GET /api/leaderboard ranked table, structures masked
17
- GET /api/queue how much work is outstanding
18
-
19
- The ledger is a private dataset, not a file in this container: a Space's filesystem does
20
- not survive a restart, and losing every submission to a rebuild is not acceptable when a
21
- prize depends on them. The worker reads that same dataset directly, so nothing here hands
22
- out work or accepts scores.
23
- """
24
- import base64
25
- import hashlib
26
- import hmac
27
- import json
28
- import os
29
- import secrets
30
- import time
31
- import urllib.parse
32
- import urllib.request
33
- import uuid
34
-
35
- from fastapi import FastAPI, HTTPException, Request
36
- 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")
47
-
48
- # Hugging Face signs entrants in. Identity has to come from somewhere the entrant cannot
49
- # forge: a free-text name would let anyone post under someone else's handle, and the
50
- # per-model chart is only meaningful if entries cannot be stuffed under a rival's label.
51
- OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
52
- OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
53
- OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
54
- SPACE_HOST = os.environ.get("SPACE_HOST", "")
55
- COOKIE = "odc_session"
56
- # Served in an iframe from another origin, so our cookies are third-party. Lax ones are
57
- # discarded there; None requires Secure, which requires HTTPS - true on the Space, not on
58
- # a local http run.
59
- IN_FRAME = bool(SPACE_HOST)
60
- COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
61
- else {"samesite": "lax", "secure": False})
62
- SESSION_KEY = os.environ.get("ODC_SESSION_KEY") or secrets.token_hex(16)
63
-
64
- SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria",
65
- "target": "PfDHODH", "counter_target": "human DHODH",
66
- # keyed by axis, not by a word - the page owns the wording in each language
67
- "weights": {"activity": 30, "binding": 20, "selectivity": 20,
68
- "admet": 15, "novelty": 10, "synthesis": 5},
69
- # Each season is a different disease; the prize is awarded when a season closes.
70
- "closes": "2026-09-30", "prize_usd": 1000}
71
-
72
- app = FastAPI(title="Open Discovery Challenge")
73
-
74
-
75
- class Submission(BaseModel):
76
- structure: str # SMILES or InChI
77
- display_name: str # name, affiliation or handle - entrant's choice
78
- model_name: str = "" # which model proposed it; free text, blank allowed
79
- rationale: str = "" # optional design note
80
- # The entrant owns the molecule and decides whether anyone else sees it. Private is
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):
97
- """A stable public handle that cannot be walked back to the structure.
98
-
99
- The skeleton block of an InChIKey is a hash of connectivity, so it is safe to show
100
- and still lets anyone check two entries are the same compound. The full key and the
101
- SMILES stay in the ledger."""
102
- h = hashlib.sha256((SALT + inchikey).encode()).hexdigest()[:6].upper()
103
- return "ODC-%s" % h
104
-
105
-
106
- def mask(rec):
107
- """What the public table is allowed to see: enough to verify and compare, never
108
- enough to reconstruct. Entrants keep their chemistry until they choose otherwise."""
109
- pub = rec.get("visibility") == "public"
110
- return {
111
- "candidate_id": rec.get("candidate_id"),
112
- "skeleton": (rec.get("inchikey") or "")[:14],
113
- # released only where the entrant asked for it to be released
114
- "visibility": rec.get("visibility", "private"),
115
- "smiles": rec.get("smiles") if pub else None,
116
- "display_name": rec.get("display_name"),
117
- "hf_user": rec.get("hf_user"),
118
- "model_name": rec.get("model_name"),
119
- "mw_band": rec.get("mw_band"),
120
- "status": rec.get("status"),
121
- "total": rec.get("total"),
122
- "axes": rec.get("axes_points"),
123
- "tier": rec.get("tier", 1),
124
- "relegate_reason": rec.get("relegate_reason") or [],
125
- "submitted_at": rec.get("submitted_at"),
126
- }
127
-
128
-
129
- def band(x, step=50):
130
- if x is None:
131
- return None
132
- lo = int(x // step) * step
133
- return "%d-%d" % (lo, lo + step)
134
-
135
-
136
- def _sign(payload):
137
- raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
138
- sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
139
- return raw + "." + sig
140
-
141
-
142
- def _unsign(token):
143
- try:
144
- raw, sig = (token or "").rsplit(".", 1)
145
- except ValueError:
146
- return None
147
- good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
148
- if not hmac.compare_digest(sig, good):
149
- return None
150
- pad = "=" * (-len(raw) % 4)
151
- try:
152
- return json.loads(base64.urlsafe_b64decode(raw + pad))
153
- except Exception:
154
- return None
155
-
156
-
157
- def current_user(request: Request):
158
- return _unsign(request.cookies.get(COOKIE))
159
-
160
-
161
- def _redirect_uri(request: Request):
162
- if SPACE_HOST:
163
- return "https://%s/auth/callback" % SPACE_HOST
164
- return str(request.base_url).rstrip("/") + "/auth/callback"
165
-
166
-
167
- @app.get("/")
168
- def index():
169
- return FileResponse(os.path.join(HERE, "index.html"))
170
-
171
-
172
- @app.get("/api/me")
173
- def me(request: Request):
174
- u = current_user(request)
175
- return {"signed_in": bool(u), "user": u,
176
- "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
177
-
178
-
179
- @app.get("/login")
180
- def login(request: Request):
181
- if not (OAUTH_ID and OAUTH_SECRET):
182
- raise HTTPException(503, "OAuth๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค (README์— hf_oauth ํ•„์š”)")
183
- state = secrets.token_urlsafe(16)
184
- q = urllib.parse.urlencode({
185
- "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
186
- "response_type": "code", "scope": "openid profile", "state": state})
187
- r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS.rstrip("/"), q))
188
- # state is echoed back by the provider; comparing it to this cookie is what stops a
189
- # third party from completing someone else's sign-in
190
- r.set_cookie("odc_state", state, max_age=600, httponly=True, **COOKIE_KW)
191
- return r
192
-
193
-
194
- @app.get("/auth/callback")
195
- def callback(request: Request, code: str = "", state: str = ""):
196
- saved = request.cookies.get("odc_state")
197
- if not code:
198
- raise HTTPException(400, "์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” ๋กœ๊ทธ์ธ์„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
199
- if not saved:
200
- # the cookie was never stored, which is a browser policy problem, not tampering
201
- raise HTTPException(400,
202
- "๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฟ ํ‚ค๋ฅผ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ํŽ˜์ด์ง€๋ฅผ "
203
- "์ƒˆ ํƒญ์—์„œ ์ง์ ‘ ์—ด๊ณ (์ฃผ์†Œ์ฐฝ์˜ .hf.space ์ฃผ์†Œ) ๋‹ค์‹œ "
204
- "๋กœ๊ทธ์ธํ•ด ์ฃผ์„ธ์š”. 3์ž ์ฟ ํ‚ค ์ฐจ๋‹จ์ด ์›์ธ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
205
- if state != saved:
206
- raise HTTPException(400, "๋กœ๊ทธ์ธ ์ƒํƒœ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
207
- body = urllib.parse.urlencode({
208
- "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
209
- "grant_type": "authorization_code", "code": code,
210
- "redirect_uri": _redirect_uri(request)}).encode()
211
- tok_req = urllib.request.Request(
212
- OAUTH_ISS.rstrip("/") + "/oauth/token", data=body,
213
- headers={"Content-Type": "application/x-www-form-urlencoded"})
214
- with urllib.request.urlopen(tok_req, timeout=20) as r:
215
- tok = json.loads(r.read().decode())
216
- ui_req = urllib.request.Request(
217
- OAUTH_ISS.rstrip("/") + "/oauth/userinfo",
218
- headers={"Authorization": "Bearer " + tok["access_token"]})
219
- with urllib.request.urlopen(ui_req, timeout=20) as r:
220
- ui = json.loads(r.read().decode())
221
- user = {"name": ui.get("preferred_username") or ui.get("name"),
222
- "picture": ui.get("picture"), "at": int(time.time())}
223
- resp = RedirectResponse("/")
224
- resp.set_cookie(COOKIE, _sign(user), max_age=60 * 60 * 24 * 14,
225
- httponly=True, **COOKIE_KW)
226
- resp.delete_cookie("odc_state")
227
- return resp
228
-
229
-
230
- @app.get("/logout")
231
- def logout():
232
- r = RedirectResponse("/")
233
- r.delete_cookie(COOKIE)
234
- return r
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 ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
247
- if not s.display_name.strip():
248
- raise HTTPException(400, "ํ‘œ์‹œ ID๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”")
249
- # Season 1's target is not a covalent-mechanism enzyme, so that rule is off here.
250
- v = gates.check(s.structure, covalent_rule=False)
251
- if not v["admitted"]:
252
- return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422) # dicts: {code, text}
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"])]},
260
- status_code=409)
261
-
262
- rec = {
263
- # the candidate id is derived from the structure, so it doubles as the record key
264
- # and makes a resubmission collide by construction
265
- "id": public_id(v["inchikey"]),
266
- "candidate_id": public_id(v["inchikey"]),
267
- "smiles": v["smiles"], "inchikey": v["inchikey"],
268
- "mw": v["mw"], "mw_band": band(v["mw"]),
269
- # the HF handle is recorded separately from the free-text display name, so the
270
- # table can show who actually submitted rather than what they typed
271
- "hf_user": (user or {}).get("name"),
272
- "visibility": "public" if s.visibility == "public" else "private",
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,
297
- "display_name": "๊ธฐ์ค€๋ฌผ์งˆ", "model_name": "",
298
- "total": a["total"], "tier": a.get("tier", 1),
299
- "axes": {k: v["points"] for k, v in a["axes"].items()},
300
- # reference rows carry their reasoning too, so the overlay
301
- # explains the scale as well as the entries
302
- "detail": {k: v["detail"] for k, v in a["axes"].items()},
303
- "note": a.get("note", "")})
304
- merged = rows + anchors
305
- merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") or -1)))
306
- n = 0
307
- for e in merged:
308
- if e.get("is_anchor") or e.get("tier", 1) != 1:
309
- e["rank"] = None # anchors mark the ladder; they do not climb it
310
- else:
311
- n += 1
312
- e["rank"] = n
313
- return {"season": SEASON, "entries": merged,
314
- "counts": {"scored": len(rows), "anchors": len(anchors)}}
315
-
316
-
317
- @app.get("/api/models")
318
- def models(): # noqa: C901
319
- """Per-model standings: which model produced the best candidate, and which gets used.
320
-
321
- Deliberately two separate numbers. Popularity is not quality - a model everyone
322
- reaches for will rack up entries regardless of whether any of them score, and a model
323
- used three times could hold the top result. Reporting only a mean would hide both:
324
- one lucky hit drowns in a hundred weak entries, and a model with two good tries looks
325
- better than one with fifty. So the chart carries best, mean and count side by side.
326
-
327
- Reference compounds are excluded - they were not produced by an entrant's model.
328
- """
329
- agg = {}
330
- for r in (_board().get("entries") or {}).values():
331
- if r.get("total") is None:
332
- continue
333
- name = (r.get("model_name") or "").strip() or "๋ฏธ๊ธฐ์žฌ / unspecified"
334
- a = agg.setdefault(name, {"model": name, "n": 0, "best": None, "sum": 0.0,
335
- "entrants": set(), "top_candidate": None})
336
- a["n"] += 1
337
- a["sum"] += r["total"]
338
- if a["best"] is None or r["total"] > a["best"]:
339
- a["best"] = r["total"]
340
- a["top_candidate"] = r.get("candidate_id")
341
- who = r.get("hf_user") or r.get("display_name")
342
- if who:
343
- a["entrants"].add(who)
344
- out = []
345
- for a in agg.values():
346
- out.append({"model": a["model"], "submissions": a["n"],
347
- "best": round(a["best"], 1) if a["best"] is not None else None,
348
- "mean": round(a["sum"] / a["n"], 1) if a["n"] else None,
349
- "entrants": len(a["entrants"]),
350
- "top_candidate": a["top_candidate"]})
351
- out.sort(key=lambda x: (-(x["best"] or 0), -x["submissions"]))
352
- return {"models": out,
353
- "totals": {"models": len(out), "submissions": sum(x["submissions"] for x in out)}}
354
-
355
-
356
-
357
- # The worker no longer talks to this service at all. It reads the dataset for entries
358
- # that have no result file yet, scores them, and writes the result and the rollup back.
359
- # That removed the claim/post endpoints and the shared key they were guarded by - both
360
- # sides already authenticate to the Hub, so there is one fewer secret to leak.
361
-
362
-
363
- @app.get("/api/queue")
364
- def queue():
365
- """How much work is outstanding.
366
-
367
- This was declared directly after the worker endpoints, so removing that block took it
368
- along too. The page never calls it, so nothing looked broken - it surfaced only by
369
- exercising every route after a factory rebuild.
370
- """
371
- ids = _submissions()
372
- scored = set((_board().get("entries") or {}).keys())
373
- return {"queued": len([i for i in ids if i not in scored]),
374
- "scored": len(scored), "total": len(ids)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Open Discovery Challenge - submission intake and leaderboard.
3
+
4
+ Deliberately thin. Nothing here computes a score.
5
+
6
+ Scoring needs docking, docking needs a GPU, and a docking call takes minutes: the tunnel
7
+ in front of this service cuts a request at ~125 s, and boltz spawns grandchildren that
8
+ hold the stdout pipe open so a subprocess call never returns and wedges the whole worker.
9
+ That combination already took the PharmaOS API down once. So submissions land in a ledger
10
+ here, a worker on the GPU box picks them up, and results come back the same way. One
11
+ entrant can never block the service.
12
+
13
+ Endpoints
14
+ GET / leaderboard page
15
+ POST /api/submit accept a structure, run the gates, queue it
16
+ GET /api/leaderboard ranked table, structures masked
17
+ GET /api/queue how much work is outstanding
18
+
19
+ The ledger is a private dataset, not a file in this container: a Space's filesystem does
20
+ not survive a restart, and losing every submission to a rebuild is not acceptable when a
21
+ prize depends on them. The worker reads that same dataset directly, so nothing here hands
22
+ out work or accepts scores.
23
+ """
24
+ import base64
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import secrets
30
+ import time
31
+ import urllib.parse
32
+ import urllib.request
33
+ import uuid
34
+
35
+ from fastapi import FastAPI, HTTPException, Request
36
+ 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")
47
+
48
+ # Hugging Face signs entrants in. Identity has to come from somewhere the entrant cannot
49
+ # forge: a free-text name would let anyone post under someone else's handle, and the
50
+ # per-model chart is only meaningful if entries cannot be stuffed under a rival's label.
51
+ OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
52
+ OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
53
+ OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
54
+ SPACE_HOST = os.environ.get("SPACE_HOST", "")
55
+ COOKIE = "odc_session"
56
+ # Served in an iframe from another origin, so our cookies are third-party. Lax ones are
57
+ # discarded there; None requires Secure, which requires HTTPS - true on the Space, not on
58
+ # a local http run.
59
+ IN_FRAME = bool(SPACE_HOST)
60
+ COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
61
+ else {"samesite": "lax", "secure": False})
62
+ SESSION_KEY = os.environ.get("ODC_SESSION_KEY") or secrets.token_hex(16)
63
+
64
+ SEASON = {"name": "Open Discovery Challenge", "number": 1, "topic": "Malaria",
65
+ "target": "PfDHODH", "counter_target": "human DHODH",
66
+ # keyed by axis, not by a word - the page owns the wording in each language
67
+ "weights": {"activity": 30, "binding": 20, "selectivity": 20,
68
+ "admet": 15, "novelty": 10, "synthesis": 5},
69
+ # Each season is a different disease; the prize is awarded when a season closes.
70
+ "closes": "2026-09-30", "prize_usd": 1000}
71
+
72
+ app = FastAPI(title="Open Discovery Challenge")
73
+
74
+
75
+ class Submission(BaseModel):
76
+ structure: str # SMILES or InChI
77
+ display_name: str # name, affiliation or handle - entrant's choice
78
+ model_name: str = "" # which model proposed it; free text, blank allowed
79
+ rationale: str = "" # optional design note
80
+ # The entrant owns the molecule and decides whether anyone else sees it. Private is
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):
97
+ """A stable public handle that cannot be walked back to the structure.
98
+
99
+ The skeleton block of an InChIKey is a hash of connectivity, so it is safe to show
100
+ and still lets anyone check two entries are the same compound. The full key and the
101
+ SMILES stay in the ledger."""
102
+ h = hashlib.sha256((SALT + inchikey).encode()).hexdigest()[:6].upper()
103
+ return "ODC-%s" % h
104
+
105
+
106
+ def mask(rec):
107
+ """What the public table is allowed to see: enough to verify and compare, never
108
+ enough to reconstruct. Entrants keep their chemistry until they choose otherwise."""
109
+ pub = rec.get("visibility") == "public"
110
+ return {
111
+ "candidate_id": rec.get("candidate_id"),
112
+ "skeleton": (rec.get("inchikey") or "")[:14],
113
+ # released only where the entrant asked for it to be released
114
+ "visibility": rec.get("visibility", "private"),
115
+ "smiles": rec.get("smiles") if pub else None,
116
+ "display_name": rec.get("display_name"),
117
+ "hf_user": rec.get("hf_user"),
118
+ "model_name": rec.get("model_name"),
119
+ "mw_band": rec.get("mw_band"),
120
+ "status": rec.get("status"),
121
+ "total": rec.get("total"),
122
+ "axes": rec.get("axes_points"),
123
+ "tier": rec.get("tier", 1),
124
+ "relegate_reason": rec.get("relegate_reason") or [],
125
+ "submitted_at": rec.get("submitted_at"),
126
+ }
127
+
128
+
129
+ def band(x, step=50):
130
+ if x is None:
131
+ return None
132
+ lo = int(x // step) * step
133
+ return "%d-%d" % (lo, lo + step)
134
+
135
+
136
+ def _sign(payload):
137
+ raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
138
+ sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
139
+ return raw + "." + sig
140
+
141
+
142
+ def _unsign(token):
143
+ try:
144
+ raw, sig = (token or "").rsplit(".", 1)
145
+ except ValueError:
146
+ return None
147
+ good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
148
+ if not hmac.compare_digest(sig, good):
149
+ return None
150
+ pad = "=" * (-len(raw) % 4)
151
+ try:
152
+ return json.loads(base64.urlsafe_b64decode(raw + pad))
153
+ except Exception:
154
+ return None
155
+
156
+
157
+ def current_user(request: Request):
158
+ return _unsign(request.cookies.get(COOKIE))
159
+
160
+
161
+ def _redirect_uri(request: Request):
162
+ if SPACE_HOST:
163
+ return "https://%s/auth/callback" % SPACE_HOST
164
+ return str(request.base_url).rstrip("/") + "/auth/callback"
165
+
166
+
167
+ @app.get("/")
168
+ def index():
169
+ return FileResponse(os.path.join(HERE, "index.html"))
170
+
171
+
172
+ @app.get("/api/me")
173
+ def me(request: Request):
174
+ u = current_user(request)
175
+ return {"signed_in": bool(u), "user": u,
176
+ "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
177
+
178
+
179
+ @app.get("/login")
180
+ def login(request: Request):
181
+ if not (OAUTH_ID and OAUTH_SECRET):
182
+ raise HTTPException(503, "OAuth๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค (README์— hf_oauth ํ•„์š”)")
183
+ state = secrets.token_urlsafe(16)
184
+ q = urllib.parse.urlencode({
185
+ "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
186
+ "response_type": "code", "scope": "openid profile", "state": state})
187
+ r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS.rstrip("/"), q))
188
+ # state is echoed back by the provider; comparing it to this cookie is what stops a
189
+ # third party from completing someone else's sign-in
190
+ r.set_cookie("odc_state", state, max_age=600, httponly=True, **COOKIE_KW)
191
+ return r
192
+
193
+
194
+ @app.get("/auth/callback")
195
+ def callback(request: Request, code: str = "", state: str = ""):
196
+ saved = request.cookies.get("odc_state")
197
+ if not code:
198
+ raise HTTPException(400, "์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” ๋กœ๊ทธ์ธ์„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
199
+ if not saved:
200
+ # the cookie was never stored, which is a browser policy problem, not tampering
201
+ raise HTTPException(400,
202
+ "๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ฟ ํ‚ค๋ฅผ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ํŽ˜์ด์ง€๋ฅผ "
203
+ "์ƒˆ ํƒญ์—์„œ ์ง์ ‘ ์—ด๊ณ (์ฃผ์†Œ์ฐฝ์˜ .hf.space ์ฃผ์†Œ) ๋‹ค์‹œ "
204
+ "๋กœ๊ทธ์ธํ•ด ์ฃผ์„ธ์š”. 3์ž ์ฟ ํ‚ค ์ฐจ๋‹จ์ด ์›์ธ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
205
+ if state != saved:
206
+ raise HTTPException(400, "๋กœ๊ทธ์ธ ์ƒํƒœ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”")
207
+ body = urllib.parse.urlencode({
208
+ "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
209
+ "grant_type": "authorization_code", "code": code,
210
+ "redirect_uri": _redirect_uri(request)}).encode()
211
+ tok_req = urllib.request.Request(
212
+ OAUTH_ISS.rstrip("/") + "/oauth/token", data=body,
213
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
214
+ with urllib.request.urlopen(tok_req, timeout=20) as r:
215
+ tok = json.loads(r.read().decode())
216
+ ui_req = urllib.request.Request(
217
+ OAUTH_ISS.rstrip("/") + "/oauth/userinfo",
218
+ headers={"Authorization": "Bearer " + tok["access_token"]})
219
+ with urllib.request.urlopen(ui_req, timeout=20) as r:
220
+ ui = json.loads(r.read().decode())
221
+ user = {"name": ui.get("preferred_username") or ui.get("name"),
222
+ "picture": ui.get("picture"), "at": int(time.time())}
223
+ resp = RedirectResponse("/")
224
+ resp.set_cookie(COOKIE, _sign(user), max_age=60 * 60 * 24 * 14,
225
+ httponly=True, **COOKIE_KW)
226
+ resp.delete_cookie("odc_state")
227
+ return resp
228
+
229
+
230
+ @app.get("/logout")
231
+ def logout():
232
+ r = RedirectResponse("/")
233
+ r.delete_cookie(COOKIE)
234
+ return r
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 ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
247
+ if not s.display_name.strip():
248
+ raise HTTPException(400, "ํ‘œ์‹œ ID๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”")
249
+ # Season 1's target is not a covalent-mechanism enzyme, so that rule is off here.
250
+ v = gates.check(s.structure, covalent_rule=False)
251
+ if not v["admitted"]:
252
+ return JSONResponse({"accepted": False, "reasons": v["reject"]}, status_code=422) # dicts: {code, text}
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"])]},
260
+ status_code=409)
261
+
262
+ rec = {
263
+ # the candidate id is derived from the structure, so it doubles as the record key
264
+ # and makes a resubmission collide by construction
265
+ "id": public_id(v["inchikey"]),
266
+ "candidate_id": public_id(v["inchikey"]),
267
+ "smiles": v["smiles"], "inchikey": v["inchikey"],
268
+ "mw": v["mw"], "mw_band": band(v["mw"]),
269
+ # the HF handle is recorded separately from the free-text display name, so the
270
+ # table can show who actually submitted rather than what they typed
271
+ "hf_user": (user or {}).get("name"),
272
+ "visibility": "public" if s.visibility == "public" else "private",
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,
297
+ "display_name": "๊ธฐ์ค€๋ฌผ์งˆ", "model_name": "",
298
+ "total": a["total"], "tier": a.get("tier", 1),
299
+ "axes": {k: v["points"] for k, v in a["axes"].items()},
300
+ # reference rows carry their reasoning too, so the overlay
301
+ # explains the scale as well as the entries
302
+ "detail": {k: v["detail"] for k, v in a["axes"].items()},
303
+ "note": a.get("note", "")})
304
+ merged = rows + anchors
305
+ merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") or -1)))
306
+ n = 0
307
+ for e in merged:
308
+ if e.get("is_anchor") or e.get("tier", 1) != 1:
309
+ e["rank"] = None # anchors mark the ladder; they do not climb it
310
+ else:
311
+ n += 1
312
+ e["rank"] = n
313
+ return {"season": SEASON, "entries": merged,
314
+ "counts": {"scored": len(rows), "anchors": len(anchors)}}
315
+
316
+
317
+ @app.get("/api/models")
318
+ def models(): # noqa: C901
319
+ """Per-model standings: which model produced the best candidate, and which gets used.
320
+
321
+ Deliberately two separate numbers. Popularity is not quality - a model everyone
322
+ reaches for will rack up entries regardless of whether any of them score, and a model
323
+ used three times could hold the top result. Reporting only a mean would hide both:
324
+ one lucky hit drowns in a hundred weak entries, and a model with two good tries looks
325
+ better than one with fifty. So the chart carries best, mean and count side by side.
326
+
327
+ Reference compounds are excluded - they were not produced by an entrant's model.
328
+ """
329
+ agg = {}
330
+ for r in (_board().get("entries") or {}).values():
331
+ if r.get("total") is None:
332
+ continue
333
+ name = (r.get("model_name") or "").strip() or "๋ฏธ๊ธฐ์žฌ / unspecified"
334
+ a = agg.setdefault(name, {"model": name, "n": 0, "best": None, "sum": 0.0,
335
+ "entrants": set(), "top_candidate": None})
336
+ a["n"] += 1
337
+ a["sum"] += r["total"]
338
+ if a["best"] is None or r["total"] > a["best"]:
339
+ a["best"] = r["total"]
340
+ a["top_candidate"] = r.get("candidate_id")
341
+ who = r.get("hf_user") or r.get("display_name")
342
+ if who:
343
+ a["entrants"].add(who)
344
+ out = []
345
+ for a in agg.values():
346
+ out.append({"model": a["model"], "submissions": a["n"],
347
+ "best": round(a["best"], 1) if a["best"] is not None else None,
348
+ "mean": round(a["sum"] / a["n"], 1) if a["n"] else None,
349
+ "entrants": len(a["entrants"]),
350
+ "top_candidate": a["top_candidate"]})
351
+ out.sort(key=lambda x: (-(x["best"] or 0), -x["submissions"]))
352
+ return {"models": out,
353
+ "totals": {"models": len(out), "submissions": sum(x["submissions"] for x in out)}}
354
+
355
+
356
+
357
+ # The worker no longer talks to this service at all. It reads the dataset for entries
358
+ # that have no result file yet, scores them, and writes the result and the rollup back.
359
+ # That removed the claim/post endpoints and the shared key they were guarded by - both
360
+ # sides already authenticate to the Hub, so there is one fewer secret to leak.
361
+
362
+
363
+ @app.get("/api/mol3d")
364
+ def mol3d(smiles: str = ""):
365
+ """A 3D conformer for the viewer, as an MDL mol block.
366
+
367
+ Published structures only. The lookup is against the rollup rather than trusting the
368
+ caller: the page only ever has public strings, but an endpoint that embeds whatever
369
+ it is given would answer "is this the private entry?" for anyone willing to guess.
370
+ """
371
+ from rdkit import Chem
372
+ from rdkit.Chem import AllChem, rdMolDescriptors
373
+
374
+ s = (smiles or "").strip()
375
+ if not s:
376
+ raise HTTPException(400, "no structure given")
377
+ published = {e.get("smiles") for e in (_board().get("entries") or {}).values()
378
+ if e.get("visibility") == "public" and e.get("smiles")}
379
+ if s not in published:
380
+ raise HTTPException(404, "not a published structure")
381
+
382
+ m = Chem.MolFromSmiles(s)
383
+ if m is None:
384
+ raise HTTPException(400, "unparseable structure")
385
+ mh = Chem.AddHs(m)
386
+ # a fixed seed so the same entry always renders as the same conformer; if embedding
387
+ # fails the flat coordinates still draw, they are simply not a conformation
388
+ if AllChem.EmbedMolecule(mh, randomSeed=1) != 0:
389
+ AllChem.Compute2DCoords(mh)
390
+ else:
391
+ try:
392
+ AllChem.MMFFOptimizeMolecule(mh, maxIters=400)
393
+ except Exception:
394
+ pass
395
+ return {"mol": Chem.MolToMolBlock(mh),
396
+ "formula": rdMolDescriptors.CalcMolFormula(m),
397
+ "mw": round(rdMolDescriptors.CalcExactMolWt(m), 2),
398
+ "atoms": m.GetNumAtoms(), "atoms_h": mh.GetNumAtoms()}
399
+
400
+
401
+ @app.get("/api/queue")
402
+ def queue():
403
+ """How much work is outstanding.
404
+
405
+ This was declared directly after the worker endpoints, so removing that block took it
406
+ along too. The page never calls it, so nothing looked broken - it surfaced only by
407
+ exercising every route after a factory rebuild.
408
+ """
409
+ ids = _submissions()
410
+ scored = set((_board().get("entries") or {}).keys())
411
+ return {"queued": len([i for i in ids if i not in scored]),
412
+ "scored": len(scored), "total": len(ids)}
data/anchor_scores.json CHANGED
@@ -1,382 +1,382 @@
1
- [
2
- {
3
- "admitted": true,
4
- "gate": {
5
- "admitted": true,
6
- "reject": [],
7
- "relegate": [],
8
- "notes": [],
9
- "smiles": "Cc1cc(Nc2ccc(S(F)(F)(F)(F)F)cc2)n2nc(C(C)(F)F)nc2n1",
10
- "inchikey": "OIZSVTOIBNSVOS-UHFFFAOYSA-N",
11
- "mw": 415.34,
12
- "heavy_atoms": 27
13
- },
14
- "relegated": false,
15
- "relegate_reason": [],
16
- "axes": {
17
- "activity": {
18
- "points": 14.45,
19
- "detail": {
20
- "pAct_q10": 5.467,
21
- "fraction": 0.482
22
- }
23
- },
24
- "binding": {
25
- "points": 15.12,
26
- "detail": {
27
- "pIC50": 7.27,
28
- "LE": 0.369,
29
- "potency_term": 0.756,
30
- "efficiency_cap": 1.0
31
- }
32
- },
33
- "selectivity": {
34
- "points": 12.59,
35
- "detail": {
36
- "fold": 46.3,
37
- "selectivity_term": 0.833,
38
- "target_engagement": 0.756
39
- }
40
- },
41
- "admet": {
42
- "points": 5.89,
43
- "detail": {
44
- "geometric_mean": 0.519,
45
- "per_endpoint": {
46
- "solubility-aqsoldb": 0.405,
47
- "caco2-wang": 0.763,
48
- "hia-hou": 1.0,
49
- "bioavailability-ma": 1.0,
50
- "pgp-broccatelli": 1.0,
51
- "cyp2c9-veith": 0.349,
52
- "cyp3a4-substrate-carbonmangels": 0.638,
53
- "ld50-zhu": 0.66,
54
- "herg": 0.116,
55
- "dili": 0.05
56
- }
57
- }
58
- },
59
- "novelty": {
60
- "points": 0.0,
61
- "detail": {
62
- "max_tanimoto": 1.0,
63
- "novelty": 0.0
64
- }
65
- },
66
- "synthesis": {
67
- "points": 2.84,
68
- "detail": {
69
- "sa_score": 3.37
70
- }
71
- }
72
- },
73
- "total": 50.88,
74
- "tier": 1,
75
- "label": "DSM265",
76
- "is_anchor": true,
77
- "note": "์ž„์ƒํ›„๋ณด (์–‘์„ฑ ์•ต์ปค)",
78
- "rank": null
79
- },
80
- {
81
- "admitted": true,
82
- "gate": {
83
- "admitted": true,
84
- "reject": [],
85
- "relegate": [],
86
- "notes": [],
87
- "smiles": "Cc1c(-c2ccc(-c3ccccc3F)cc2)nc2ccc(F)cc2c1C(=O)O",
88
- "inchikey": "PHEZJEYUWHETKO-UHFFFAOYSA-N",
89
- "mw": 375.37,
90
- "heavy_atoms": 28
91
- },
92
- "relegated": false,
93
- "relegate_reason": [],
94
- "axes": {
95
- "activity": {
96
- "points": 2.58,
97
- "detail": {
98
- "pAct_q10": 4.755,
99
- "fraction": 0.086
100
- }
101
- },
102
- "binding": {
103
- "points": 0.0,
104
- "detail": {
105
- "reason": "๋„ํ‚น ์—†์Œ"
106
- }
107
- },
108
- "selectivity": {
109
- "points": 0.0,
110
- "detail": {
111
- "reason": "์Œ ์ธก์ • ์—†์Œ"
112
- }
113
- },
114
- "admet": {
115
- "points": 0.47,
116
- "detail": {
117
- "geometric_mean": 0.363,
118
- "per_endpoint": {
119
- "solubility-aqsoldb": 0.05,
120
- "caco2-wang": 0.714,
121
- "hia-hou": 1.0,
122
- "bioavailability-ma": 1.0,
123
- "pgp-broccatelli": 0.766,
124
- "cyp2c9-veith": 0.696,
125
- "cyp3a4-substrate-carbonmangels": 0.88,
126
- "ld50-zhu": 0.271,
127
- "herg": 0.115,
128
- "dili": 0.05
129
- }
130
- }
131
- },
132
- "novelty": {
133
- "points": 0.56,
134
- "detail": {
135
- "max_tanimoto": 0.348,
136
- "novelty": 0.652
137
- }
138
- },
139
- "synthesis": {
140
- "points": 0.43,
141
- "detail": {
142
- "sa_score": 2.06
143
- }
144
- }
145
- },
146
- "total": 4.04,
147
- "tier": 1,
148
- "label": "Brequinar",
149
- "is_anchor": false,
150
- "note": "์‚ฌ๋žŒ DHODH ์–ต์ œ์ œ (์„ ํƒ์„ฑ ๋ฐ˜๋ก€)",
151
- "rank": 1
152
- },
153
- {
154
- "admitted": true,
155
- "gate": {
156
- "admitted": true,
157
- "reject": [],
158
- "relegate": [],
159
- "notes": [],
160
- "smiles": "C/C(O)=C(\\C#N)C(=O)Nc1ccc(C(F)(F)F)cc1",
161
- "inchikey": "UTNUDOFZCWSZMS-YFHOEESVSA-N",
162
- "mw": 270.21,
163
- "heavy_atoms": 19
164
- },
165
- "relegated": false,
166
- "relegate_reason": [],
167
- "axes": {
168
- "activity": {
169
- "points": 1.66,
170
- "detail": {
171
- "pAct_q10": 4.362,
172
- "fraction": 0.055
173
- }
174
- },
175
- "binding": {
176
- "points": 0.0,
177
- "detail": {
178
- "reason": "๋„ํ‚น ์—†์Œ"
179
- }
180
- },
181
- "selectivity": {
182
- "points": 0.0,
183
- "detail": {
184
- "reason": "์Œ ์ธก์ • ์—†์Œ"
185
- }
186
- },
187
- "admet": {
188
- "points": 0.51,
189
- "detail": {
190
- "geometric_mean": 0.614,
191
- "per_endpoint": {
192
- "solubility-aqsoldb": 0.575,
193
- "caco2-wang": 0.851,
194
- "hia-hou": 1.0,
195
- "bioavailability-ma": 1.0,
196
- "pgp-broccatelli": 1.0,
197
- "cyp2c9-veith": 0.568,
198
- "cyp3a4-substrate-carbonmangels": 0.21,
199
- "ld50-zhu": 0.459,
200
- "herg": 1.0,
201
- "dili": 0.11
202
- }
203
- }
204
- },
205
- "novelty": {
206
- "points": 0.35,
207
- "detail": {
208
- "max_tanimoto": 0.357,
209
- "novelty": 0.643
210
- }
211
- },
212
- "synthesis": {
213
- "points": 0.28,
214
- "detail": {
215
- "sa_score": 2.27
216
- }
217
- }
218
- },
219
- "total": 2.79,
220
- "tier": 1,
221
- "label": "Teriflunomide",
222
- "is_anchor": false,
223
- "note": "์‚ฌ๋žŒ DHODH ์–ต์ œ์ œ (์„ ํƒ์„ฑ ๋ฐ˜๋ก€)",
224
- "rank": 2
225
- },
226
- {
227
- "admitted": true,
228
- "gate": {
229
- "admitted": true,
230
- "reject": [],
231
- "relegate": [],
232
- "notes": [],
233
- "smiles": "CC(C)Cc1ccc(C(C)C(=O)O)cc1",
234
- "inchikey": "HEFNNWSXXWATRW-UHFFFAOYSA-N",
235
- "mw": 206.28,
236
- "heavy_atoms": 15
237
- },
238
- "relegated": false,
239
- "relegate_reason": [],
240
- "axes": {
241
- "activity": {
242
- "points": 1.04,
243
- "detail": {
244
- "pAct_q10": 4.174,
245
- "fraction": 0.035
246
- }
247
- },
248
- "binding": {
249
- "points": 0.06,
250
- "detail": {
251
- "pIC50": 3.57,
252
- "LE": 0.326,
253
- "potency_term": 0.003,
254
- "efficiency_cap": 1.0
255
- }
256
- },
257
- "selectivity": {
258
- "points": 0.0,
259
- "detail": {
260
- "fold": 0.3,
261
- "selectivity_term": 0.0,
262
- "target_engagement": 0.0
263
- }
264
- },
265
- "admet": {
266
- "points": 0.38,
267
- "detail": {
268
- "geometric_mean": 0.74,
269
- "per_endpoint": {
270
- "solubility-aqsoldb": 0.677,
271
- "caco2-wang": 1.0,
272
- "hia-hou": 1.0,
273
- "bioavailability-ma": 1.0,
274
- "pgp-broccatelli": 1.0,
275
- "cyp2c9-veith": 1.0,
276
- "cyp3a4-substrate-carbonmangels": 1.0,
277
- "ld50-zhu": 0.212,
278
- "herg": 1.0,
279
- "dili": 0.528
280
- }
281
- }
282
- },
283
- "novelty": {
284
- "points": 0.21,
285
- "detail": {
286
- "max_tanimoto": 0.386,
287
- "novelty": 0.614
288
- }
289
- },
290
- "synthesis": {
291
- "points": 0.17,
292
- "detail": {
293
- "sa_score": 2.19
294
- }
295
- }
296
- },
297
- "total": 1.86,
298
- "tier": 1,
299
- "label": "Ibuprofen",
300
- "is_anchor": true,
301
- "note": "์Œ์„ฑ๋Œ€์กฐ",
302
- "rank": null
303
- },
304
- {
305
- "admitted": true,
306
- "gate": {
307
- "admitted": true,
308
- "reject": [],
309
- "relegate": [],
310
- "notes": [],
311
- "smiles": "Cn1c(=O)c2c(ncn2C)n(C)c1=O",
312
- "inchikey": "RYYVLZVUVIJVGH-UHFFFAOYSA-N",
313
- "mw": 194.19,
314
- "heavy_atoms": 14
315
- },
316
- "relegated": false,
317
- "relegate_reason": [],
318
- "axes": {
319
- "activity": {
320
- "points": 1.1,
321
- "detail": {
322
- "pAct_q10": 4.197,
323
- "fraction": 0.037
324
- }
325
- },
326
- "binding": {
327
- "points": 0.06,
328
- "detail": {
329
- "pIC50": 3.62,
330
- "LE": 0.354,
331
- "potency_term": 0.003,
332
- "efficiency_cap": 1.0
333
- }
334
- },
335
- "selectivity": {
336
- "points": 0.0,
337
- "detail": {
338
- "fold": 1.3,
339
- "selectivity_term": 0.058,
340
- "target_engagement": 0.0
341
- }
342
- },
343
- "admet": {
344
- "points": 0.27,
345
- "detail": {
346
- "geometric_mean": 0.496,
347
- "per_endpoint": {
348
- "solubility-aqsoldb": 1.0,
349
- "caco2-wang": 1.0,
350
- "hia-hou": 1.0,
351
- "bioavailability-ma": 1.0,
352
- "pgp-broccatelli": 1.0,
353
- "cyp2c9-veith": 1.0,
354
- "cyp3a4-substrate-carbonmangels": 0.05,
355
- "ld50-zhu": 0.096,
356
- "herg": 0.1,
357
- "dili": 0.58
358
- }
359
- }
360
- },
361
- "novelty": {
362
- "points": 0.23,
363
- "detail": {
364
- "max_tanimoto": 0.367,
365
- "novelty": 0.633
366
- }
367
- },
368
- "synthesis": {
369
- "points": 0.18,
370
- "detail": {
371
- "sa_score": 2.3
372
- }
373
- }
374
- },
375
- "total": 1.84,
376
- "tier": 1,
377
- "label": "Caffeine",
378
- "is_anchor": true,
379
- "note": "์Œ์„ฑ๋Œ€์กฐ",
380
- "rank": null
381
- }
382
  ]
 
1
+ [
2
+ {
3
+ "admitted": true,
4
+ "gate": {
5
+ "admitted": true,
6
+ "reject": [],
7
+ "relegate": [],
8
+ "notes": [],
9
+ "smiles": "Cc1cc(Nc2ccc(S(F)(F)(F)(F)F)cc2)n2nc(C(C)(F)F)nc2n1",
10
+ "inchikey": "OIZSVTOIBNSVOS-UHFFFAOYSA-N",
11
+ "mw": 415.34,
12
+ "heavy_atoms": 27
13
+ },
14
+ "relegated": false,
15
+ "relegate_reason": [],
16
+ "axes": {
17
+ "activity": {
18
+ "points": 14.45,
19
+ "detail": {
20
+ "pAct_q10": 5.467,
21
+ "fraction": 0.482
22
+ }
23
+ },
24
+ "binding": {
25
+ "points": 15.12,
26
+ "detail": {
27
+ "pIC50": 7.27,
28
+ "LE": 0.369,
29
+ "potency_term": 0.756,
30
+ "efficiency_cap": 1.0
31
+ }
32
+ },
33
+ "selectivity": {
34
+ "points": 12.59,
35
+ "detail": {
36
+ "fold": 46.3,
37
+ "selectivity_term": 0.833,
38
+ "target_engagement": 0.756
39
+ }
40
+ },
41
+ "admet": {
42
+ "points": 5.89,
43
+ "detail": {
44
+ "geometric_mean": 0.519,
45
+ "per_endpoint": {
46
+ "solubility-aqsoldb": 0.405,
47
+ "caco2-wang": 0.763,
48
+ "hia-hou": 1.0,
49
+ "bioavailability-ma": 1.0,
50
+ "pgp-broccatelli": 1.0,
51
+ "cyp2c9-veith": 0.349,
52
+ "cyp3a4-substrate-carbonmangels": 0.638,
53
+ "ld50-zhu": 0.66,
54
+ "herg": 0.116,
55
+ "dili": 0.05
56
+ }
57
+ }
58
+ },
59
+ "novelty": {
60
+ "points": 0.0,
61
+ "detail": {
62
+ "max_tanimoto": 1.0,
63
+ "novelty": 0.0
64
+ }
65
+ },
66
+ "synthesis": {
67
+ "points": 2.84,
68
+ "detail": {
69
+ "sa_score": 3.37
70
+ }
71
+ }
72
+ },
73
+ "total": 50.88,
74
+ "tier": 1,
75
+ "label": "DSM265",
76
+ "is_anchor": true,
77
+ "note": "์ž„์ƒํ›„๋ณด (์–‘์„ฑ ์•ต์ปค)",
78
+ "rank": null
79
+ },
80
+ {
81
+ "admitted": true,
82
+ "gate": {
83
+ "admitted": true,
84
+ "reject": [],
85
+ "relegate": [],
86
+ "notes": [],
87
+ "smiles": "Cc1c(-c2ccc(-c3ccccc3F)cc2)nc2ccc(F)cc2c1C(=O)O",
88
+ "inchikey": "PHEZJEYUWHETKO-UHFFFAOYSA-N",
89
+ "mw": 375.37,
90
+ "heavy_atoms": 28
91
+ },
92
+ "relegated": false,
93
+ "relegate_reason": [],
94
+ "axes": {
95
+ "activity": {
96
+ "points": 2.58,
97
+ "detail": {
98
+ "pAct_q10": 4.755,
99
+ "fraction": 0.086
100
+ }
101
+ },
102
+ "binding": {
103
+ "points": 0.0,
104
+ "detail": {
105
+ "reason": "๋„ํ‚น ์—†์Œ"
106
+ }
107
+ },
108
+ "selectivity": {
109
+ "points": 0.0,
110
+ "detail": {
111
+ "reason": "์Œ ์ธก์ • ์—†์Œ"
112
+ }
113
+ },
114
+ "admet": {
115
+ "points": 0.47,
116
+ "detail": {
117
+ "geometric_mean": 0.363,
118
+ "per_endpoint": {
119
+ "solubility-aqsoldb": 0.05,
120
+ "caco2-wang": 0.714,
121
+ "hia-hou": 1.0,
122
+ "bioavailability-ma": 1.0,
123
+ "pgp-broccatelli": 0.766,
124
+ "cyp2c9-veith": 0.696,
125
+ "cyp3a4-substrate-carbonmangels": 0.88,
126
+ "ld50-zhu": 0.271,
127
+ "herg": 0.115,
128
+ "dili": 0.05
129
+ }
130
+ }
131
+ },
132
+ "novelty": {
133
+ "points": 0.56,
134
+ "detail": {
135
+ "max_tanimoto": 0.348,
136
+ "novelty": 0.652
137
+ }
138
+ },
139
+ "synthesis": {
140
+ "points": 0.43,
141
+ "detail": {
142
+ "sa_score": 2.06
143
+ }
144
+ }
145
+ },
146
+ "total": 4.04,
147
+ "tier": 1,
148
+ "label": "Brequinar",
149
+ "is_anchor": false,
150
+ "note": "์‚ฌ๋žŒ DHODH ์–ต์ œ์ œ (์„ ํƒ์„ฑ ๋ฐ˜๋ก€)",
151
+ "rank": 1
152
+ },
153
+ {
154
+ "admitted": true,
155
+ "gate": {
156
+ "admitted": true,
157
+ "reject": [],
158
+ "relegate": [],
159
+ "notes": [],
160
+ "smiles": "C/C(O)=C(\\C#N)C(=O)Nc1ccc(C(F)(F)F)cc1",
161
+ "inchikey": "UTNUDOFZCWSZMS-YFHOEESVSA-N",
162
+ "mw": 270.21,
163
+ "heavy_atoms": 19
164
+ },
165
+ "relegated": false,
166
+ "relegate_reason": [],
167
+ "axes": {
168
+ "activity": {
169
+ "points": 1.66,
170
+ "detail": {
171
+ "pAct_q10": 4.362,
172
+ "fraction": 0.055
173
+ }
174
+ },
175
+ "binding": {
176
+ "points": 0.0,
177
+ "detail": {
178
+ "reason": "๋„ํ‚น ์—†์Œ"
179
+ }
180
+ },
181
+ "selectivity": {
182
+ "points": 0.0,
183
+ "detail": {
184
+ "reason": "์Œ ์ธก์ • ์—†์Œ"
185
+ }
186
+ },
187
+ "admet": {
188
+ "points": 0.51,
189
+ "detail": {
190
+ "geometric_mean": 0.614,
191
+ "per_endpoint": {
192
+ "solubility-aqsoldb": 0.575,
193
+ "caco2-wang": 0.851,
194
+ "hia-hou": 1.0,
195
+ "bioavailability-ma": 1.0,
196
+ "pgp-broccatelli": 1.0,
197
+ "cyp2c9-veith": 0.568,
198
+ "cyp3a4-substrate-carbonmangels": 0.21,
199
+ "ld50-zhu": 0.459,
200
+ "herg": 1.0,
201
+ "dili": 0.11
202
+ }
203
+ }
204
+ },
205
+ "novelty": {
206
+ "points": 0.35,
207
+ "detail": {
208
+ "max_tanimoto": 0.357,
209
+ "novelty": 0.643
210
+ }
211
+ },
212
+ "synthesis": {
213
+ "points": 0.28,
214
+ "detail": {
215
+ "sa_score": 2.27
216
+ }
217
+ }
218
+ },
219
+ "total": 2.79,
220
+ "tier": 1,
221
+ "label": "Teriflunomide",
222
+ "is_anchor": false,
223
+ "note": "์‚ฌ๋žŒ DHODH ์–ต์ œ์ œ (์„ ํƒ์„ฑ ๋ฐ˜๋ก€)",
224
+ "rank": 2
225
+ },
226
+ {
227
+ "admitted": true,
228
+ "gate": {
229
+ "admitted": true,
230
+ "reject": [],
231
+ "relegate": [],
232
+ "notes": [],
233
+ "smiles": "CC(C)Cc1ccc(C(C)C(=O)O)cc1",
234
+ "inchikey": "HEFNNWSXXWATRW-UHFFFAOYSA-N",
235
+ "mw": 206.28,
236
+ "heavy_atoms": 15
237
+ },
238
+ "relegated": false,
239
+ "relegate_reason": [],
240
+ "axes": {
241
+ "activity": {
242
+ "points": 1.04,
243
+ "detail": {
244
+ "pAct_q10": 4.174,
245
+ "fraction": 0.035
246
+ }
247
+ },
248
+ "binding": {
249
+ "points": 0.06,
250
+ "detail": {
251
+ "pIC50": 3.57,
252
+ "LE": 0.326,
253
+ "potency_term": 0.003,
254
+ "efficiency_cap": 1.0
255
+ }
256
+ },
257
+ "selectivity": {
258
+ "points": 0.0,
259
+ "detail": {
260
+ "fold": 0.3,
261
+ "selectivity_term": 0.0,
262
+ "target_engagement": 0.0
263
+ }
264
+ },
265
+ "admet": {
266
+ "points": 0.38,
267
+ "detail": {
268
+ "geometric_mean": 0.74,
269
+ "per_endpoint": {
270
+ "solubility-aqsoldb": 0.677,
271
+ "caco2-wang": 1.0,
272
+ "hia-hou": 1.0,
273
+ "bioavailability-ma": 1.0,
274
+ "pgp-broccatelli": 1.0,
275
+ "cyp2c9-veith": 1.0,
276
+ "cyp3a4-substrate-carbonmangels": 1.0,
277
+ "ld50-zhu": 0.212,
278
+ "herg": 1.0,
279
+ "dili": 0.528
280
+ }
281
+ }
282
+ },
283
+ "novelty": {
284
+ "points": 0.21,
285
+ "detail": {
286
+ "max_tanimoto": 0.386,
287
+ "novelty": 0.614
288
+ }
289
+ },
290
+ "synthesis": {
291
+ "points": 0.17,
292
+ "detail": {
293
+ "sa_score": 2.19
294
+ }
295
+ }
296
+ },
297
+ "total": 1.86,
298
+ "tier": 1,
299
+ "label": "Ibuprofen",
300
+ "is_anchor": true,
301
+ "note": "์Œ์„ฑ๋Œ€์กฐ",
302
+ "rank": null
303
+ },
304
+ {
305
+ "admitted": true,
306
+ "gate": {
307
+ "admitted": true,
308
+ "reject": [],
309
+ "relegate": [],
310
+ "notes": [],
311
+ "smiles": "Cn1c(=O)c2c(ncn2C)n(C)c1=O",
312
+ "inchikey": "RYYVLZVUVIJVGH-UHFFFAOYSA-N",
313
+ "mw": 194.19,
314
+ "heavy_atoms": 14
315
+ },
316
+ "relegated": false,
317
+ "relegate_reason": [],
318
+ "axes": {
319
+ "activity": {
320
+ "points": 1.1,
321
+ "detail": {
322
+ "pAct_q10": 4.197,
323
+ "fraction": 0.037
324
+ }
325
+ },
326
+ "binding": {
327
+ "points": 0.06,
328
+ "detail": {
329
+ "pIC50": 3.62,
330
+ "LE": 0.354,
331
+ "potency_term": 0.003,
332
+ "efficiency_cap": 1.0
333
+ }
334
+ },
335
+ "selectivity": {
336
+ "points": 0.0,
337
+ "detail": {
338
+ "fold": 1.3,
339
+ "selectivity_term": 0.058,
340
+ "target_engagement": 0.0
341
+ }
342
+ },
343
+ "admet": {
344
+ "points": 0.27,
345
+ "detail": {
346
+ "geometric_mean": 0.496,
347
+ "per_endpoint": {
348
+ "solubility-aqsoldb": 1.0,
349
+ "caco2-wang": 1.0,
350
+ "hia-hou": 1.0,
351
+ "bioavailability-ma": 1.0,
352
+ "pgp-broccatelli": 1.0,
353
+ "cyp2c9-veith": 1.0,
354
+ "cyp3a4-substrate-carbonmangels": 0.05,
355
+ "ld50-zhu": 0.096,
356
+ "herg": 0.1,
357
+ "dili": 0.58
358
+ }
359
+ }
360
+ },
361
+ "novelty": {
362
+ "points": 0.23,
363
+ "detail": {
364
+ "max_tanimoto": 0.367,
365
+ "novelty": 0.633
366
+ }
367
+ },
368
+ "synthesis": {
369
+ "points": 0.18,
370
+ "detail": {
371
+ "sa_score": 2.3
372
+ }
373
+ }
374
+ },
375
+ "total": 1.84,
376
+ "tier": 1,
377
+ "label": "Caffeine",
378
+ "is_anchor": true,
379
+ "note": "์Œ์„ฑ๋Œ€์กฐ",
380
+ "rank": null
381
+ }
382
  ]
index.html CHANGED
@@ -225,15 +225,42 @@
225
  border-radius:8px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;
226
  color:#31465e;line-height:1.5;word-break:break-all;position:relative}
227
  #ov .osmi .cp{position:absolute;top:6px;right:6px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  .who{color:var(--muted);font-size:12.5px}
229
- /* Long model identifiers wrapped onto four lines and made every row three times
230
- taller than it needed to be. Truncate; the full value is on the tooltip. */
231
- td:nth-child(3), td:nth-child(4){max-width:150px}
232
- td:nth-child(3) > *, td:nth-child(4) > *{display:block;max-width:150px;
233
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
234
- td:nth-child(2){max-width:300px}
235
- .hf-user{color:var(--accent);font-weight:700;text-decoration:none;white-space:nowrap}
236
- .hf-user:hover{text-decoration:underline}
237
  .tag{display:inline-block;font-size:9.5px;font-weight:800;letter-spacing:.7px;
238
  padding:2px 7px;border-radius:5px;margin-left:6px;vertical-align:1px}
239
  .tag.a{background:#f6e2ba;color:var(--anchor)}
@@ -265,6 +292,16 @@
265
  </head>
266
  <body>
267
  <div id="ov"></div>
 
 
 
 
 
 
 
 
 
 
268
  <div class="wrap">
269
 
270
  <header>
@@ -441,7 +478,9 @@ const T = {
441
  synthesis:d=>`ํ•ฉ์„ฑ๋‚œ์ด๋„ SA ${d.sa_score ?? "โ€“"} (1 ์‰ฌ์›€ ~ 10 ์–ด๋ ค์›€)`,
442
  },
443
  ov_pred:"์˜ˆ์ธก์น˜", ov_pf:"๋ง๋ผ๋ฆฌ์•„ ํšจ์†Œ", ov_hs:"์‚ฌ๋žŒ ํšจ์†Œ", ov_sel:"์„ ํƒ๋„",
444
- private_note:"์ œ์ถœ์ž๊ฐ€ ๋น„๊ณต๊ฐœ๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค", copy_smiles:"๋ถ„์ž์‹ ๋ณต์‚ฌ",
 
 
445
  private_full:"์ œ์ถœ์ž๊ฐ€ ๋น„๊ณต๊ฐœ๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค. ํ›„๋ณด ID์™€ ๊ณจ๊ฒฉ ํ•ด์‹œ๋กœ๋งŒ ์‹๋ณ„๋ฉ๋‹ˆ๋‹ค.",
446
  reasons:{
447
  parse: r=>"๊ตฌ์กฐ๋ฅผ ํ•ด์„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (SMILES ๋˜๋Š” InChI๋กœ ์ œ์ถœํ•˜์„ธ์š”)",
@@ -535,7 +574,9 @@ const T = {
535
  synthesis:d=>`SA score ${d.sa_score ?? "โ€“"} (1 easy - 10 hard)`,
536
  },
537
  ov_pred:"Predicted", ov_pf:"parasite enzyme", ov_hs:"human enzyme", ov_sel:"selectivity",
538
- private_note:"structure withheld by the entrant", copy_smiles:"Copy SMILES",
 
 
539
  private_full:"The entrant chose to keep this structure private. It is identified only by its candidate ID and skeleton hash.",
540
  reasons:{
541
  parse: r=>"Structure could not be parsed (submit SMILES or InChI)",
@@ -666,20 +707,6 @@ function sortVal(e, k){
666
  return (e.axes||{})[k] ?? -1;
667
  }
668
 
669
- function esc(s){
670
- return String(s ?? "").replace(/[&<>"']/g, c=>({
671
- "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
672
- })[c]);
673
- }
674
-
675
- function hfProfile(user){
676
- if(!user) return "";
677
- const label = esc(user);
678
- const url = `https://huggingface.co/${encodeURIComponent(user)}`;
679
- return `<a class="hf-user" href="${url}" target="_blank" rel="noopener noreferrer"`
680
- + ` title="Hugging Face: ${label}">๐Ÿค— ${label}</a>`;
681
- }
682
-
683
  function render(){
684
  const d = t(), W = DATA.season.weights, max = Math.max(1, ...DATA.entries.map(e=>e.total||0));
685
  $("#wts").innerHTML = Object.entries(W)
@@ -705,22 +732,24 @@ function render(){
705
 
706
  $("#rows").innerHTML = rows.length ? rows.map(e=>{
707
  const a = e.axes||{};
708
- const cls = [e.is_anchor?"anchor":"", e.tier===2?"tier2":""].join(" ").trim();
709
- const _who = e.hf_user || e.display_name || "";
710
- const user = e.is_anchor ? '<span class="who">\u2014</span>'
711
- : (e.hf_user ? hfProfile(e.hf_user)
712
- : `<span title="${esc(_who)}">${esc(e.display_name||"\u2013")}</span>`);
713
- const _m = e.is_anchor ? (e.note || d.ref) : (e.model_name || "\u2013");
714
- const who = `<span class="who" title="${esc(_m)}">${esc(_m)}</span>`;
 
715
  // the structure is the name now; a withheld one shows its ends, middle blanked.
716
  // Only a published structure gets a copy button - there is nothing to copy otherwise.
717
- const isPub = e.visibility === "public" && !!e.smiles;
718
- const shown = isPub ? e.smiles : e.smiles_masked;
719
- const clip = shown ? (shown.length > 26 ? shown.slice(0, 26) + "\u2026" : shown) : "";
720
  const id = e.is_anchor ? `<b>${e.candidate_id}</b>`
721
  : (shown
722
  ? `<span class="smiwrap"><span class="smi2" title="${shown}">${clip}</span>`
723
- + (isPub ? `<button class="cp" data-smi="${e.smiles}" title="${d.copy_smiles}">\u29c9</button>` : "")
 
724
  + `</span>`
725
  : `<span class="cid">${e.candidate_id}</span>`);
726
  const tag = e.is_anchor ? `<span class="tag a">${d.anchor}</span>`
@@ -735,6 +764,7 @@ function render(){
735
  }).join("") : `<tr><td colspan="11" class="who">${d.empty}</td></tr>`;
736
 
737
  wireCopy($("#rows"));
 
738
 
739
  // rows carry their entry so the overlay never has to look anything up again
740
  [...document.querySelectorAll("#rows tr")].forEach((tr, i) => {
@@ -939,20 +969,101 @@ function overlayHTML(e){
939
  const pred = (r.aff_pf!=null || r.aff_hs!=null) ? `<div class="osum">
940
  ${d.ov_pred} ยท ${d.ov_pf} <b>${ic50(r.aff_pf)}</b> ยท ${d.ov_hs} <b>${ic50(r.aff_hs)}</b>
941
  ${sel.fold?` ยท ${d.ov_sel} <b>${sel.fold}x</b>`:""}</div>` : "";
942
- const flag = (e.relegate_reason && e.relegate_reason.length)
943
- ? `<div class="oflag">${e.relegate_reason.map(reasonText).join(" / ")}</div>` : "";
944
- const who = e.is_anchor ? esc(e.note||"")
945
- : [esc(e.hf_user||""), esc(e.model_name||"")].filter(Boolean).join(" ยท ");
946
- const full = (e.visibility === "public" && e.smiles) ? e.smiles : e.smiles_masked;
947
  const canCopy = e.visibility === "public" && e.smiles;
948
  const smi = e.is_anchor ? ""
949
- : `<div class="osmi">${full || ""}`
950
- + (canCopy ? `<button class="cp" data-smi="${e.smiles}" title="${d.copy_smiles}">\u29c9</button>` : "")
951
- + (e.visibility === "public" ? "" : `<br>๐Ÿ”’ ${d.private_full}`) + `</div>`;
 
952
  return `<h4>${e.candidate_id}</h4><div class="oid">${who}${e.skeleton?" ยท "+e.skeleton:""}</div>`
953
  + rows + pred + smi + flag + `<div class="ofoot">${d.ov_foot}</div>`;
954
  }
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  function wireCopy(root){
957
  root.querySelectorAll(".cp").forEach(b => b.onclick = async (ev) => {
958
  // without this the click also reaches the row and toggles the overlay
@@ -979,6 +1090,7 @@ function showOverlay(e, rect){
979
  OV.style.top = top + "px";
980
  OV.style.left = left + "px";
981
  wireCopy(OV);
 
982
  }
983
  function hideOverlay(){ OV.style.display = "none"; }
984
  OV.onmouseenter = () => clearTimeout(OV._t);
 
225
  border-radius:8px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;
226
  color:#31465e;line-height:1.5;word-break:break-all;position:relative}
227
  #ov .osmi .cp{position:absolute;top:6px;right:6px}
228
+ #ov .osmi .d3{position:absolute;top:6px;right:32px}
229
+ .d3{flex:none;border:1px solid var(--line);background:#fff;border-radius:6px;
230
+ height:22px;padding:0 6px;line-height:1;cursor:pointer;color:var(--muted);
231
+ font-size:10px;font-weight:700;letter-spacing:.02em}
232
+ .d3:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
233
+ /* the viewer is a modal, not a panel: a molecule you are turning over deserves the
234
+ whole window, and the table underneath would only compete for the drag */
235
+ #m3{position:fixed;inset:0;z-index:120;display:none;background:rgba(16,23,34,.55);
236
+ backdrop-filter:blur(3px)}
237
+ #m3.on{display:flex;align-items:center;justify-content:center}
238
+ #m3 .box{background:#fff;border-radius:16px;width:min(720px,94vw);
239
+ box-shadow:0 24px 70px rgba(12,18,28,.35);overflow:hidden}
240
+ #m3 .hd{display:flex;align-items:center;gap:10px;padding:12px 14px;
241
+ border-bottom:1px solid var(--line)}
242
+ #m3 .hd b{font-size:13px;color:#16223a}
243
+ #m3 .hd .f{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted)}
244
+ #m3 .hd .x{margin-left:auto;border:0;background:none;font-size:20px;line-height:1;
245
+ cursor:pointer;color:var(--muted);padding:0 4px}
246
+ #m3 .stage{position:relative;height:min(430px,52vh);background:#0d1420}
247
+ #m3 .stage canvas{display:block}
248
+ #m3 .msg{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
249
+ color:#93a6c0;font-size:12px}
250
+ #m3 .ft{display:flex;align-items:center;gap:8px;padding:10px 14px;flex-wrap:wrap}
251
+ #m3 .ft .sm{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:#4a5d78;
252
+ word-break:break-all;flex:1;min-width:0}
253
+ #m3 .ft button{border:1px solid var(--line);background:#fff;border-radius:7px;
254
+ padding:5px 10px;font-size:11px;cursor:pointer;color:#33465f}
255
+ #m3 .ft button:hover{border-color:var(--accent);color:var(--accent)}
256
+ #m3 .ft button.on{border-color:var(--accent);background:var(--accent);color:#fff}
257
  .who{color:var(--muted);font-size:12.5px}
258
+ /* Long model identifiers wrapped onto four lines and made every row three times
259
+ taller than it needed to be. Truncate; the full value is on the tooltip. */
260
+ td:nth-child(3), td:nth-child(4){max-width:150px}
261
+ td:nth-child(3) > *, td:nth-child(4) > *{display:block;max-width:150px;
262
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
263
+ td:nth-child(2){max-width:300px}
 
 
264
  .tag{display:inline-block;font-size:9.5px;font-weight:800;letter-spacing:.7px;
265
  padding:2px 7px;border-radius:5px;margin-left:6px;vertical-align:1px}
266
  .tag.a{background:#f6e2ba;color:var(--anchor)}
 
292
  </head>
293
  <body>
294
  <div id="ov"></div>
295
+ <div id="m3"><div class="box">
296
+ <div class="hd"><b id="m3f"></b><span class="f" id="m3w"></span>
297
+ <button class="x" id="m3x" aria-label="close">ร—</button></div>
298
+ <div class="stage"><div id="m3v" style="width:100%;height:100%;position:relative"></div>
299
+ <div class="msg" id="m3m"></div></div>
300
+ <div class="ft"><span class="sm" id="m3s"></span>
301
+ <button id="m3st" class="on" data-s="stick">Stick</button>
302
+ <button id="m3sp" data-s="sphere">Space-fill</button>
303
+ <button id="m3rt" class="on" title="spin">โ†ป</button></div>
304
+ </div></div>
305
  <div class="wrap">
306
 
307
  <header>
 
478
  synthesis:d=>`ํ•ฉ์„ฑ๋‚œ์ด๋„ SA ${d.sa_score ?? "โ€“"} (1 ์‰ฌ์›€ ~ 10 ์–ด๋ ค์›€)`,
479
  },
480
  ov_pred:"์˜ˆ์ธก์น˜", ov_pf:"๋ง๋ผ๋ฆฌ์•„ ํšจ์†Œ", ov_hs:"์‚ฌ๋žŒ ํšจ์†Œ", ov_sel:"์„ ํƒ๋„",
481
+ private_note:"์ œ์ถœ์ž๊ฐ€ ๋น„๊ณต๊ฐœ๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค", copy_smiles:"๋ถ„์ž์‹ ๋ณต์‚ฌ", view_3d:"3D ์ž…์ฒด ๊ตฌ์กฐ๋กœ ๋ณด๊ธฐ",
482
+ v3_loading:"์ž…์ฒด ๊ตฌ์กฐ ์ƒ์„ฑ ์ค‘\u2026", v3_atoms:"์›์ž",
483
+ v3_failed:"์ž…์ฒด ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค",
484
  private_full:"์ œ์ถœ์ž๊ฐ€ ๋น„๊ณต๊ฐœ๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค. ํ›„๋ณด ID์™€ ๊ณจ๊ฒฉ ํ•ด์‹œ๋กœ๋งŒ ์‹๋ณ„๋ฉ๋‹ˆ๋‹ค.",
485
  reasons:{
486
  parse: r=>"๊ตฌ์กฐ๋ฅผ ํ•ด์„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (SMILES ๋˜๋Š” InChI๋กœ ์ œ์ถœํ•˜์„ธ์š”)",
 
574
  synthesis:d=>`SA score ${d.sa_score ?? "โ€“"} (1 easy - 10 hard)`,
575
  },
576
  ov_pred:"Predicted", ov_pf:"parasite enzyme", ov_hs:"human enzyme", ov_sel:"selectivity",
577
+ private_note:"structure withheld by the entrant", copy_smiles:"Copy SMILES", view_3d:"View in 3D",
578
+ v3_loading:"building conformer\u2026", v3_atoms:"atoms",
579
+ v3_failed:"could not build a 3D conformer",
580
  private_full:"The entrant chose to keep this structure private. It is identified only by its candidate ID and skeleton hash.",
581
  reasons:{
582
  parse: r=>"Structure could not be parsed (submit SMILES or InChI)",
 
707
  return (e.axes||{})[k] ?? -1;
708
  }
709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  function render(){
711
  const d = t(), W = DATA.season.weights, max = Math.max(1, ...DATA.entries.map(e=>e.total||0));
712
  $("#wts").innerHTML = Object.entries(W)
 
732
 
733
  $("#rows").innerHTML = rows.length ? rows.map(e=>{
734
  const a = e.axes||{};
735
+ const cls = [e.is_anchor?"anchor":"", e.tier===2?"tier2":""].join(" ").trim();
736
+ const _who = e.hf_user || e.display_name || "";
737
+ const user = e.is_anchor ? '<span class="who">\u2014</span>'
738
+ : `<span title="${_who}">`
739
+ + (e.hf_user ? `\u{1F917} ${e.hf_user}` : (e.display_name||"\u2013"))
740
+ + '</span>';
741
+ const _m = e.is_anchor ? (e.note || d.ref) : (e.model_name || "\u2013");
742
+ const who = `<span class="who" title="${_m}">${_m}</span>`;
743
  // the structure is the name now; a withheld one shows its ends, middle blanked.
744
  // Only a published structure gets a copy button - there is nothing to copy otherwise.
745
+ const isPub = e.visibility === "public" && !!e.smiles;
746
+ const shown = isPub ? e.smiles : e.smiles_masked;
747
+ const clip = shown ? (shown.length > 26 ? shown.slice(0, 26) + "\u2026" : shown) : "";
748
  const id = e.is_anchor ? `<b>${e.candidate_id}</b>`
749
  : (shown
750
  ? `<span class="smiwrap"><span class="smi2" title="${shown}">${clip}</span>`
751
+ + (isPub ? `<button class="d3" data-smi="${e.smiles}" title="${d.view_3d}">3D</button>`
752
+ + `<button class="cp" data-smi="${e.smiles}" title="${d.copy_smiles}">\u29c9</button>` : "")
753
  + `</span>`
754
  : `<span class="cid">${e.candidate_id}</span>`);
755
  const tag = e.is_anchor ? `<span class="tag a">${d.anchor}</span>`
 
764
  }).join("") : `<tr><td colspan="11" class="who">${d.empty}</td></tr>`;
765
 
766
  wireCopy($("#rows"));
767
+ wire3D($("#rows"));
768
 
769
  // rows carry their entry so the overlay never has to look anything up again
770
  [...document.querySelectorAll("#rows tr")].forEach((tr, i) => {
 
969
  const pred = (r.aff_pf!=null || r.aff_hs!=null) ? `<div class="osum">
970
  ${d.ov_pred} ยท ${d.ov_pf} <b>${ic50(r.aff_pf)}</b> ยท ${d.ov_hs} <b>${ic50(r.aff_hs)}</b>
971
  ${sel.fold?` ยท ${d.ov_sel} <b>${sel.fold}x</b>`:""}</div>` : "";
972
+ const flag = (e.relegate_reason && e.relegate_reason.length)
973
+ ? `<div class="oflag">${e.relegate_reason.map(reasonText).join(" / ")}</div>` : "";
974
+ const who = e.is_anchor ? (e.note||"") : [e.hf_user, e.model_name].filter(Boolean).join(" ยท ");
975
+ const full = (e.visibility === "public" && e.smiles) ? e.smiles : e.smiles_masked;
 
976
  const canCopy = e.visibility === "public" && e.smiles;
977
  const smi = e.is_anchor ? ""
978
+ : `<div class="osmi">${full || ""}`
979
+ + (canCopy ? `<button class="d3" data-smi="${e.smiles}" title="${d.view_3d}">3D</button>`
980
+ + `<button class="cp" data-smi="${e.smiles}" title="${d.copy_smiles}">\u29c9</button>` : "")
981
+ + (e.visibility === "public" ? "" : `<br>๐Ÿ”’ ${d.private_full}`) + `</div>`;
982
  return `<h4>${e.candidate_id}</h4><div class="oid">${who}${e.skeleton?" ยท "+e.skeleton:""}</div>`
983
  + rows + pred + smi + flag + `<div class="ofoot">${d.ov_foot}</div>`;
984
  }
985
 
986
+ // ---------------------------------------------------------------- 3D viewer
987
+ // 3Dmol is loaded on first use. It is ~2 MB and most visits never open a structure, so
988
+ // paying for it on every page load would be charging everyone for a minority feature.
989
+ let _v3 = null, _3ready = null, _spin = true, _style = "stick";
990
+
991
+ // out of the grid and onto the body: the modal was declared beside the hover overlay,
992
+ // inside the layout, and the grid's min-width:0 collapsed it to nothing
993
+ document.body.appendChild($("#m3"));
994
+
995
+ function load3Dmol(){
996
+ if(_3ready) return _3ready;
997
+ _3ready = new Promise((res, rej) => {
998
+ const s = document.createElement("script");
999
+ s.src = "https://3Dmol.org/build/3Dmol-min.js";
1000
+ s.onload = res; s.onerror = () => rej(new Error("3Dmol"));
1001
+ document.head.appendChild(s);
1002
+ });
1003
+ return _3ready;
1004
+ }
1005
+
1006
+ function paint3(){
1007
+ if(!_v3) return;
1008
+ _v3.setStyle({}, _style === "sphere"
1009
+ ? {sphere:{scale:0.42, colorscheme:"Jmol"}}
1010
+ : {stick:{radius:0.17, colorscheme:"Jmol"}, sphere:{scale:0.22, colorscheme:"Jmol"}});
1011
+ _v3.render();
1012
+ }
1013
+
1014
+ async function open3D(smi){
1015
+ const M = $("#m3"), msg = $("#m3m");
1016
+ M.classList.add("on");
1017
+ $("#m3s").textContent = smi;
1018
+ $("#m3f").textContent = ""; $("#m3w").textContent = "";
1019
+ msg.textContent = t().v3_loading;
1020
+ try{
1021
+ const [_, r] = await Promise.all([
1022
+ load3Dmol(),
1023
+ fetch("api/mol3d?smiles=" + encodeURIComponent(smi)).then(x => x.json())
1024
+ ]);
1025
+ if(!r.mol) throw new Error("mol");
1026
+ $("#m3f").textContent = r.formula;
1027
+ $("#m3w").textContent = r.mw + " Da \u00b7 " + r.atoms + " " + t().v3_atoms;
1028
+ const host = $("#m3v");
1029
+ host.innerHTML = "";
1030
+ _v3 = $3Dmol.createViewer(host, {backgroundColor:"#0d1420"});
1031
+ _v3.addModel(r.mol, "mol");
1032
+ paint3();
1033
+ _v3.zoomTo();
1034
+ _v3.render();
1035
+ _v3.spin(_spin ? "y" : false, 0.6);
1036
+ msg.textContent = "";
1037
+ }catch(err){
1038
+ msg.textContent = t().v3_failed;
1039
+ }
1040
+ }
1041
+
1042
+ function close3D(){
1043
+ $("#m3").classList.remove("on");
1044
+ // the WebGL context is not reclaimed by hiding the element; leaving one per open would
1045
+ // eventually exhaust the browser's context budget and blank the canvas
1046
+ try{ if(_v3){ _v3.spin(false); _v3.clear(); } }catch(_){}
1047
+ _v3 = null; $("#m3v").innerHTML = "";
1048
+ }
1049
+
1050
+ $("#m3x").onclick = close3D;
1051
+ $("#m3").onclick = (e) => { if(e.target.id === "m3") close3D(); };
1052
+ document.addEventListener("keydown", e => { if(e.key === "Escape") close3D(); });
1053
+ $("#m3st").onclick = () => { _style = "stick"; $("#m3st").classList.add("on");
1054
+ $("#m3sp").classList.remove("on"); paint3(); };
1055
+ $("#m3sp").onclick = () => { _style = "sphere"; $("#m3sp").classList.add("on");
1056
+ $("#m3st").classList.remove("on"); paint3(); };
1057
+ $("#m3rt").onclick = () => { _spin = !_spin; $("#m3rt").classList.toggle("on", _spin);
1058
+ try{ if(_v3) _v3.spin(_spin ? "y" : false, 0.6); }catch(_){} };
1059
+
1060
+ function wire3D(root){
1061
+ root.querySelectorAll(".d3").forEach(b => b.onclick = (ev) => {
1062
+ ev.stopPropagation();
1063
+ open3D(b.dataset.smi);
1064
+ });
1065
+ }
1066
+
1067
  function wireCopy(root){
1068
  root.querySelectorAll(".cp").forEach(b => b.onclick = async (ev) => {
1069
  // without this the click also reaches the row and toggles the overlay
 
1090
  OV.style.top = top + "px";
1091
  OV.style.left = left + "px";
1092
  wireCopy(OV);
1093
+ wire3D(OV);
1094
  }
1095
  function hideOverlay(){ OV.style.display = "none"; }
1096
  OV.onmouseenter = () => clearTimeout(OV._t);