3D structure viewer for published entries
Browse files- README.md +221 -221
- app.py +412 -374
- data/anchor_scores.json +381 -381
- 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/
|
| 364 |
-
def
|
| 365 |
-
"""
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
"""
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
| 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 |
-
:
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
const
|
|
|
|
| 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="
|
|
|
|
| 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 ?
|
| 945 |
-
|
| 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="
|
| 951 |
-
|
|
|
|
| 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);
|