karimox HungryTorch scienta-hermes[bot] commited on
Commit
feb0811
·
0 Parent(s):

Deploy v0.1.2

Browse files

Co-authored-by: HungryTorch <HungryTorch@users.noreply.huggingface.co>
Co-authored-by: scienta-hermes[bot] <scienta-hermes[bot]@users.noreply.huggingface.co>

.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
LICENSE-STRABLE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026 The STRABLE developers.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PRIMO Benchmark
3
+ emoji: 🧬
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 5.50.0
8
+ python_version: "3.13"
9
+ app_file: app.py
10
+ pinned: false
11
+ hf_oauth: true
12
+ thumbnail: https://huggingface.co/spaces/PRIMOmics/primo-eval/resolve/main/assets/primo-social-preview.png
13
+ ---
14
+
15
+ # PRIMO: Patient Representations in Multi-Omics
16
+
17
+ **A blind benchmark for omics foundation models.**
18
+
19
+ PRIMO grades how well a model turns a patient's omics data into a useful
20
+ **patient embedding**. You select one modality, embed its datasets, and upload
21
+ **one** file; a fixed probe scores each hidden task, and the results roll up into a blind,
22
+ per-category leaderboard. The datasets are opaque (`d002`, `d003`…) and you never
23
+ see the disease, tissue, or target, which leaves you grading the *embedding*
24
+ itself with no room for per-task tuning.
25
+
26
+ A top bar navigates five pages: **Leaderboard** (one ranking at a time, picked
27
+ with the Modality, Therapeutic area and Task pills), **Tasks**, a **Submit** form
28
+ (sign in with Hugging Face), **Contribute**, and **Method**. Every page has its
29
+ own URL, as `?tab=contribute`, and every view too, as
30
+ `?modality=bulk-rna&area=oncology&task=all`, so both are shareable deep links.
31
+ Old `?board=oncology-bulk-rna` links still open their view.
32
+
33
+ The last panel of the leaderboard names the omics layers and the therapeutic
34
+ areas PRIMO does not cover yet, and links to Contribute. They are declared in
35
+ `boards.py` (`OPEN_BOARDS`) and drop out on their own once the registry covers
36
+ that slice. Task categories deliberately get none, because a
37
+ category is pinned to a single metric, so an open one would advertise a probe
38
+ that does not exist.
39
+
40
+ 🌐 Website: http://primomics.org/ ·
41
+ 📦 Data: https://huggingface.co/datasets/PRIMOmics/primo
42
+
43
+ ## What's in the data
44
+
45
+ PRIMO benchmarks any omics modality. It includes **bulk RNA** and **single-cell
46
+ RNA** tasks, all with real clinical labels or treatment-induced expression
47
+ responses from published cohorts. Tasks are grouped in four therapeutic areas:
48
+
49
+ - **Immunology & inflammation**: Crohn's disease, ulcerative colitis, celiac
50
+ disease, psoriasis, atopic dermatitis, hidradenitis suppurativa, rheumatoid
51
+ arthritis, systemic sclerosis, Sjögren's disease and multiple sclerosis
52
+ - **Oncology**: breast, ovarian, prostate, bladder, lung and rectal cancer,
53
+ melanoma, and acute leukaemias
54
+ - **Infectious diseases**: sepsis, COVID-19 and tuberculosis
55
+ - **Metabolic diseases**: fatty liver disease (MASH)
56
+
57
+ A task that fits no area is marked **Other**: today COPD, kidney graft
58
+ rejection, graft-versus-host disease and lung fibrosis. It has no area board,
59
+ but it is still ranked on its modality and category boards.
60
+
61
+ Perturbation-response tasks cover adalimumab transfer between inflammatory skin
62
+ diseases, mouse-to-human dupilumab transfer, and paired pre/post treatment
63
+ cohorts in COPD (inhaled corticosteroids), prostate (enzalutamide), rectal
64
+ (galunisertib) and breast cancer (neoadjuvant chemotherapy).
65
+
66
+ ## Submission format
67
+
68
+ One file, one row per (`dataset_id`, `sample_id`), spanning one modality:
69
+
70
+ - **CSV / TSV / Parquet**: a `dataset_id` column, a `sample_id` column, and one
71
+ numeric column per embedding dimension. Embedding dim may differ per dataset
72
+ (pad short datasets with blank columns; blank/NaN padding columns are dropped
73
+ per dataset).
74
+ - **NPZ**: `dataset_ids`, `sample_ids`, and a 2-D `embeddings` array.
75
+
76
+ The valid `dataset_id`s and how to download each dataset's `expression.h5ad` are
77
+ listed in the public `datasets.yaml` manifest. Alignment is by join, so row
78
+ order does not matter; every labelled sample of a task must be present with no
79
+ NaN/inf, or that task is skipped.
80
+
81
+ Bulk H5AD files have one row per required submission sample. Single-cell H5AD
82
+ files have one sparse raw-count row per cell; opaque cell ids are in
83
+ `obs_names`, and the only public cell metadata is `obs["sample_id"]`, which maps
84
+ each cell to its opaque collection sample. Submissions remain sample-level: emit
85
+ exactly one embedding for every unique `sample_id`, not one embedding per cell.
86
+
87
+ ## How it works
88
+
89
+ Each dataset is embedded once and scored on every hidden task defined for it.
90
+ Learned embedding rows are scaled to unit length; gene-space baselines keep
91
+ their row scale. Then, per task: standardise on the outer training fold, fit
92
+ logistic regression (classification) or ridge regression with the penalty
93
+ chosen by subject-grouped inner cross-validation, and predict each held-out
94
+ fold. Ridge penalties use an exact spectral search over the fixed grid; when
95
+ regression tasks have identical samples and fold assignments, their matrix
96
+ factorizations are shared while each target still selects its own penalty. We
97
+ calculate the native metric per fold and average it over frozen,
98
+ subject-grouped partitions: **AUROC**
99
+ (classification) or **Pearson r** (regression). Binary tasks with fewer than 30
100
+ subjects use leave-pair-out instead: each positive-negative pair of subjects is
101
+ held out once, the probe is fit on all other subjects, and the AUROC is the
102
+ share of held-out sample pairs ranked correctly. A few tasks instead use a
103
+ fixed train/test split: the probe is fit once on the training portion and
104
+ scored on the held-out portion, with the same subject-grouped penalty
105
+ selection.
106
+
107
+ Tasks are grouped into **four families**, each reported in its own native
108
+ metric. Two families are never merged into one column:
109
+
110
+ - **Treatment outcome**: response to anti-TNF therapy (**AUROC**)
111
+ - **Clinical scores**: disease-severity regression (**Pearson r**)
112
+ - **Endotype**: molecular-subtype classification (**AUROC**)
113
+ - **Perturbation response**: hidden post-minus-pre DEG decoding (**centered
114
+ Spearman**), where 1 is perfect, 0 is uninformative, and -1 is an inverse gene
115
+ ranking.
116
+
117
+ A leaderboard shows one column per family, in its own metric. Elo orders the
118
+ rows, and no column averages across metrics.
119
+
120
+ ## Boards
121
+
122
+ Results are shown as **boards**, self-contained leaderboards over a slice of the
123
+ registry: the whole modality, one therapeutic area, one task family. A board
124
+ ranks only the models that covered **all of its tasks**, so a submission that
125
+ skipped Infectious diseases is still ranked on Oncology. Modality is where we draw
126
+ the line: an area or category board never spans two modalities, because an AUROC
127
+ on bulk RNA and an AUROC on single-cell are not measuring the same thing.
128
+
129
+ Partial and failed submissions still get feedback, and their scores always appear
130
+ in each board's **per-task** table even when they are not ranked.
131
+
132
+ ## Public tasks
133
+
134
+ The tasks listed in `public_tasks/manifest.yaml` of the data repo have
135
+ downloadable labels. They are tagged **Public** in the task tables and are
136
+ scored like every other task.
137
+
138
+ When the version has public tasks, a **Public tasks** pill shows. Set to
139
+ **Hide**, it re-ranks the view on its private tasks only, without a page reload.
140
+ It shows every task by default. The Tasks page always shows every task.
141
+
142
+ ## Make a submission
143
+
144
+ `quickstart.py` is the shortest path: it downloads one modality and embeds its
145
+ datasets
146
+ (log2(CPM+1) → PCA for bulk; per-cell log2(CP10K+1) → sample mean → PCA for
147
+ single-cell) and writes the file the Submit tab wants. Swap its `embed`
148
+ function for your encoder and nothing else changes. `example_submission.csv`
149
+ shows the expected shape in four lines.
150
+
151
+ ```bash
152
+ pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
153
+ python quickstart.py --modality bulk-rna --out submission.parquet
154
+ ```
155
+
156
+ ## Run the scorer locally
157
+
158
+ ```bash
159
+ pip install -r requirements.txt
160
+ export HF_TOKEN=... # read access to the PRIMO datasets
161
+ python evaluator.py --modality bulk-rna --submission my_embeddings.parquet
162
+ ```
163
+
164
+ It reads the data commits of the current board version (see "Board versions"
165
+ below). Long local runs can be resumed task by task with `--checkpoint-dir PATH`.
166
+ Checkpoints are namespaced by the submission contents, representation,
167
+ reference features and scoring configuration, and are invalidated when private
168
+ task inputs change.
169
+
170
+ `--reference PATH` scores the `+ HVG` variant: the submission rows (after their
171
+ usual normalisation) are joined by sample with a gene-space file of the same
172
+ layout, and every concatenated row is L2-normalised.
173
+
174
+ ## Baselines
175
+
176
+ `task_results.csv` carries an `is_baseline` flag. Reference submissions we
177
+ produce ourselves are published with it set, rendered as `name (baseline)`, and
178
+ **ranked in place**. The published `HVG-1200-genes` entry is the **HVG-1200
179
+ log-normalized linear baseline**: bulk samples use log2(CPM+1), while single-cell
180
+ profiles use per-cell log2(CP10K+1) followed by sample-level mean pooling. It
181
+ keeps 1,200 highest-variance genes and the resulting row scale rather than
182
+ receiving the L2 row normalisation applied to learned embeddings, then uses
183
+ train-fold standardisation and the regularisation-tuned linear probe. Gene
184
+ selection is label-blind but fit over every sample of a dataset, so this is an
185
+ information-rich transductive reference rather than a naïve raw-count baseline
186
+ or an expected lower bound. `scripts/baselines.py` builds it from the public data.
187
+
188
+ ## `+ HVG` entries
189
+
190
+ Every Space submission is scored a second time concatenated with the
191
+ `HVG-1200-genes` features and stored as `<model> + HVG`, with the parent's
192
+ owner, credits and `submission_id`. The features are kept privately at
193
+ `baselines/HVG-1200-genes/submission.parquet` in `PRIMOmics/primo-results`.
194
+ `scripts/baselines.py --publish` uploads them and logs the commit that holds them.
195
+ The reference is pinned by the board version. A new reference opens a new
196
+ version, and the rescore below scores the baseline and every `+ HVG` entry again
197
+ with it.
198
+
199
+ ## Board versions
200
+
201
+ A board version is a minor release of primo-eval: version `0.1` is scored by the
202
+ `v0.1.x` releases. `versions.yaml` lists every version with its release tag, the
203
+ day it opened, the Hub commits its scores read, and its `notes`: one line on what
204
+ changed. The commits are `data` in `PRIMOmics/primo`, `labels` in
205
+ `PRIMOmics/primo-labels`, and `reference`, the commit of `PRIMOmics/primo-results`
206
+ that holds the HVG features. `current` is the version this code scores.
207
+
208
+ The scorer reads these commits, not the latest ones, so a push to a data repo
209
+ changes no score until a release opens a new version. `requirements.txt` pins the
210
+ packages that can move a score (numpy, pandas, joblib, scikit-learn, scipy) to the
211
+ versions the Space runs.
212
+
213
+ Each result row records the version that scored it, and a board shows only the
214
+ rows of its version. Rows written before versions existed carry none, so they are
215
+ shown on no board.
216
+
217
+ The "Release notes" section, at the bottom of the leaderboard, lists every version
218
+ with its notes and links to the board of each one. `?version=0.1` opens a closed
219
+ version, read-only: its registry at its pinned commits and the rows it scored,
220
+ with a notice under the top bar that links back to the current version. Every link keeps
221
+ the version, except Submit: a submission always goes to the current version.
222
+
223
+ When a release opens a new version, `rescore.py` fills its board from the archived
224
+ embedding files of the previous version, so nobody has to submit again. Each live
225
+ submission is scored again, plain and `+ HVG`, and keeps its name, credits,
226
+ submission id and date. The `HVG-1200-genes` baseline is scored again from the
227
+ reference features. An entry keeps its tasks, plus the tasks the new version adds.
228
+ If an entry fails, or gets no score on a task it held, nothing is published. The
229
+ release steps are in `CONTRIBUTING.md`.
230
+
231
+ ## Space configuration
232
+
233
+ - **`hf_oauth: true`** (set above) turns on the Submit tab's *Sign in with
234
+ Hugging Face* button; submitting requires a logged-in HF account.
235
+ - `primo submit` calls the **`/submit`** endpoint of `submit_api.py`, with the
236
+ fields of the form and the caller's token. The endpoint reads the token only
237
+ to learn the account name, and never stores it. The form and the endpoint
238
+ share the same checks and one queue, so one submission runs at a time.
239
+ - Set an **`HF_TOKEN`** Space secret (fine-grained) with: **read** on
240
+ `PRIMOmics/primo` (the public `datasets.yaml` manifest) and
241
+ `PRIMOmics/primo-labels` (the private `tasks.yaml` registry +
242
+ `<task_id>/labels.csv` and perturbation `<task_id>/targets.npz`), and **read + write** on
243
+ `PRIMOmics/primo-results` (the persisted leaderboard).
244
+ - Results persist as one normalized `task_results.csv` (`model_name, task_id,
245
+ score, repeat_scores, diagnostics, submitted_at, is_baseline, hf_username,
246
+ version`) in the results dataset; the
247
+ leaderboard is recomputed from it by joining the registry, so it survives
248
+ Space restarts.
249
+ - Among the rows of its version, a board keeps the **latest result for each
250
+ `(model name, is_baseline, task)`**,
251
+ so submissions accumulate: a later one fills in the tasks it covers and leaves
252
+ the rest standing, and resubmitting a task replaces just that result. Because a
253
+ task belongs to exactly one modality, one name can hold bulk and single-cell
254
+ results at once without either displacing the other.
255
+ - A model name is owned by the account that first submitted it: `hf_username`
256
+ locks it, and Submit refuses a name somebody else holds. It is never rendered
257
+ anywhere: it works as a lock rather than as a credit. Names colliding with a
258
+ per-task column (`Task`, `Family`, `Area`, `Modality`, `Metric`, `Best`) are
259
+ refused too, since model names become column headers, and so are names
260
+ containing the `+ HVG` tag.
261
+ - The boards show a **Submissions count** per model — the most times any one of
262
+ its task results was resubmitted in the board's version — so a task tuned
263
+ against by repeated resubmission is visible. An account can publish 4
264
+ submissions in 24 hours, and a file can weigh 500 MB at most.
265
+ - Submitter contact metadata (HF username, email, paper / model links, notes)
266
+ persists to a separate `submissions.csv` in the same **private** results
267
+ dataset, and never reaches the public leaderboard.
268
+
269
+ ## Moving to another Hugging Face org
270
+
271
+ The three dataset repos are derived from one constant, `ORG` in `evaluator.py`.
272
+ The rest of the org name is spelled out and has to be changed by hand:
273
+
274
+ - `PUBLIC_REPO` in `quickstart.py` (standalone, so it cannot import `ORG`)
275
+ - `SOCIAL_PREVIEW_URL` in `app.py`
276
+ - the links in this file, including the YAML `thumbnail:` above, and in
277
+ `pages/*.md`
278
+ - outside this folder: `DATA_REPO` in `src/primomics/data.py`, `SPACE` in
279
+ `src/primomics/submit.py`, the links of the top-level `README.md`, and
280
+ the deploy command in `CONTRIBUTING.md`
281
+
282
+ The theme follows the PRIMO charter: Funnel Display for headings, Funnel Sans for
283
+ everything else, Scienta Navy `#080F5F` / PRIMO Cyan `#16B3C0` on Paper
284
+ `#F3F8F8`. `colorFrom`/`colorTo` above stay `indigo`/`blue` because Hugging Face
285
+ only accepts eight named colours and none of them is cyan.
app.py ADDED
@@ -0,0 +1,884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio front-end for the PRIMO public benchmark.
2
+
3
+ A top bar navigates five pages: Leaderboard (one ranking, chosen with the
4
+ Modality, Therapeutic area and Task pills), Tasks ("what is actually being
5
+ tested?"), Submit ("how do I enter?"), Contribute ("what is missing, and how do
6
+ I add it?"), Method ("can I trust this?"). Every page is addressable --
7
+ ``?tab=contribute`` opens a tab and ``?modality=bulk-rna&area=oncology&task=all``
8
+ a view; old ``?board=<slug>`` links still open their view. The tab strip is
9
+ hidden in CSS; the top bar is the navigation.
10
+ ``&version=<number>`` shows a closed board version, read-only: its registry at
11
+ its pinned commits and the rows it scored. Submit always shows the current one.
12
+
13
+ Choose one modality and upload its embedding file (rows keyed by ``dataset_id``
14
+ + ``sample_id``); a fixed task probe scores each task (a dataset may carry
15
+ several hidden targets). Results roll up into boards -- the whole modality, one
16
+ therapeutic area, one task family -- and each board ranks the models that
17
+ covered every task our baseline scored, one column per category in its native
18
+ metric (AUROC, Pearson, or centered Spearman). The rows are ordered by Elo; no
19
+ column averages across metrics.
20
+
21
+ Disclosure policy -- what the public pages may show:
22
+ per task disease, tissue, area, modality, what is predicted, class names, n, metric
23
+ aggregated the public archives the cohorts sit in, and their licences
24
+ never study accessions, dataset_id -> cohort, hub keys, per-sample labels,
25
+ pairing identifiers or post-treatment responses
26
+ The public tasks are the exception: their labels and responses are published in
27
+ the public data repo, and the pages tag them ``Public``.
28
+ Naming the accession behind ``d002`` would put every label one GEO download
29
+ away, so ``sources`` is collapsed to its archive (NCBI GEO / EMBL-EBI
30
+ ArrayExpress) and ``citation`` is never rendered at all. Submitters can publish
31
+ an optional institution, paper link and an author-submission check; legacy private
32
+ usernames remain hidden.
33
+
34
+ Failures are surfaced by who owns them: a bad file tells the submitter exactly
35
+ what to fix; an evaluator-side failure says "our side, please retry" and logs
36
+ the traceback rather than blaming the submission.
37
+
38
+ A page load is ONE fetch: ``_init`` pulls the registry, the boards and the
39
+ persisted results once and threads that state into every page, rather than each
40
+ page fetching for itself. The state stays in the session, so a click on a pill
41
+ re-renders the view from it, with no reload and no new fetch.
42
+ """
43
+
44
+ import os
45
+ import traceback
46
+ from datetime import datetime, timezone
47
+ from pathlib import Path
48
+ from uuid import uuid4
49
+
50
+ import gradio as gr
51
+ import pandas as pd
52
+ from boards import (
53
+ ALL,
54
+ PUBLIC,
55
+ Board,
56
+ area_choices,
57
+ board_view,
58
+ build_boards,
59
+ category_choices,
60
+ modality_choices,
61
+ view_board,
62
+ without_public,
63
+ )
64
+ from evaluator import (
65
+ EMBEDDING,
66
+ EvaluatorError,
67
+ SubmissionError,
68
+ _norm_id,
69
+ fetch_manifest,
70
+ fetch_public_task_ids,
71
+ fetch_tasks_registry,
72
+ manifest_ids,
73
+ score_all,
74
+ scoreable_tasks,
75
+ )
76
+ from leaderboard import RESERVED_COLUMNS, source_repositories
77
+ from render import (
78
+ BOARD_SUB,
79
+ CREDIT,
80
+ VERSION_PARAM,
81
+ cta_html,
82
+ evaluation_confirmation,
83
+ evaluation_status,
84
+ hero_html,
85
+ history_html,
86
+ nav_html,
87
+ render_tasks,
88
+ render_view,
89
+ )
90
+ from results import (
91
+ BASELINE_TAG,
92
+ HVG_TAG,
93
+ INSTITUTION,
94
+ IS_AUTHOR_SUBMISSION,
95
+ OWNER,
96
+ PAPER_LINK,
97
+ RESULT_COLUMNS,
98
+ SUBMISSION_ID,
99
+ append_results,
100
+ append_submission,
101
+ archive_submission,
102
+ fetch_hvg_reference,
103
+ hvg_name,
104
+ owner_of,
105
+ read_results,
106
+ task_rows,
107
+ version_rows,
108
+ )
109
+ from submit_api import (
110
+ ERROR,
111
+ MAX_FILE_SIZE,
112
+ PUBLISHED,
113
+ REFUSED,
114
+ SUBMISSIONS,
115
+ SUBMISSIONS_PER_DAY,
116
+ SUBMITTED_AT,
117
+ recent_submissions,
118
+ reply,
119
+ route,
120
+ )
121
+ from versions import CURRENT, VERSIONS, Version
122
+
123
+ HERE = Path(__file__).parent
124
+ PAGES_DIR = HERE / "pages"
125
+ CSS_PATH = str(HERE / "primo.css")
126
+ ALLOWED_PATHS = [str(HERE / "assets")]
127
+ SOCIAL_PREVIEW_URL = (
128
+ "https://huggingface.co/spaces/PRIMOmics/primo-eval/resolve/main/"
129
+ "assets/primo-social-preview.png"
130
+ )
131
+ PAGE_HEAD = f"""
132
+ <meta property="og:title" content="PRIMO Benchmark" />
133
+ <meta property="og:description" content="A blind benchmark for omics foundation models." />
134
+ <meta property="og:image" content="{SOCIAL_PREVIEW_URL}" />
135
+ <meta name="twitter:card" content="summary_large_image" />
136
+ <meta name="twitter:title" content="PRIMO Benchmark" />
137
+ <meta name="twitter:description" content="A blind benchmark for omics foundation models." />
138
+ <meta name="twitter:image" content="{SOCIAL_PREVIEW_URL}" />
139
+ """
140
+
141
+ TOKEN = os.environ.get("HF_TOKEN")
142
+ TAB_IDS = ("leaderboard", "tasks", "submit", "contribute", "method")
143
+ LEGACY_TABS = {"boards": "leaderboard", "board": "leaderboard"}
144
+ DEFAULT_MODALITY = "bulk-rna"
145
+ SHOW = "show"
146
+ HIDE = "hide"
147
+ TOGGLE_CHOICES = [("Show", SHOW), ("Hide", HIDE)]
148
+
149
+ THEME = gr.themes.Base()
150
+
151
+ VIEW_URL_JS = """
152
+ (modality, area, task, hvg, pub) => {
153
+ const url = new URL(window.location.href);
154
+ const values = { modality, area, task, hvg, public: pub };
155
+ const defaults = {
156
+ modality: "bulk-rna", area: "all", task: "all", hvg: "show", public: "show",
157
+ };
158
+ for (const [key, value] of Object.entries(values)) {
159
+ if (!value || value === defaults[key]) url.searchParams.delete(key);
160
+ else url.searchParams.set(key, value);
161
+ }
162
+ url.searchParams.delete("board");
163
+ window.history.replaceState(null, "", url);
164
+ }
165
+ """
166
+
167
+ SUBMIT_HEAD = (
168
+ '<div class="pm-head"><div><h1>Submit a model</h1>'
169
+ "<p>One model, one modality. Partial coverage is fine within that modality. "
170
+ "You are ranked on every board you cover in full.</p></div></div>"
171
+ )
172
+ PAGE_HEADS = {
173
+ "contribute": (
174
+ '<div class="pm-head"><div><h1>Contribute</h1>'
175
+ "<p>What PRIMO is missing, and how to add it.</p></div></div>"
176
+ ),
177
+ "method": (
178
+ '<div class="pm-head"><div><h1>Method</h1>'
179
+ "<p>How a model is scored, and what the pages show.</p></div></div>"
180
+ ),
181
+ }
182
+
183
+ MODALITY_CHOICES = [
184
+ ("Bulk RNAseq", "bulk-rna"),
185
+ ("Single-cell RNAseq", "single-cell-rna"),
186
+ ]
187
+
188
+ TABLE_SORT_JS = """
189
+ () => {
190
+ if (window.pmTableSortBound) return;
191
+ window.pmTableSortBound = true;
192
+
193
+ const value = (cell) => {
194
+ const text = cell.dataset.sortValue || cell.textContent.trim();
195
+ if (!text || text.toLowerCase() === "n/a") return { missing: true, text };
196
+ const number = Number(text.replaceAll(",", ""));
197
+ return Number.isFinite(number) ? { missing: false, number, text } : { missing: false, text };
198
+ };
199
+
200
+ const sort = (header) => {
201
+ const table = header.closest("table.pm-table");
202
+ const body = table?.tBodies[0];
203
+ if (!body) return;
204
+ const index = Number(header.dataset.sortIndex);
205
+ const direction = header.dataset.sortDirection === "asc" ? -1 : 1;
206
+ const rows = Array.from(body.rows);
207
+
208
+ rows.sort((left, right) => {
209
+ const a = value(left.cells[index]);
210
+ const b = value(right.cells[index]);
211
+ if (a.missing || b.missing) return a.missing === b.missing ? 0 : a.missing ? 1 : -1;
212
+ if (a.number !== undefined && b.number !== undefined) return direction * (a.number - b.number);
213
+ return direction * a.text.localeCompare(b.text, undefined, { numeric: true });
214
+ });
215
+ rows.forEach((row) => body.append(row));
216
+ table.querySelectorAll("th.pm-sort").forEach((cell) => {
217
+ cell.dataset.sortDirection = "";
218
+ cell.setAttribute("aria-sort", "none");
219
+ });
220
+ header.dataset.sortDirection = direction === 1 ? "asc" : "desc";
221
+ table.dataset.sorted = index === 0 && direction === 1 ? "" : "true";
222
+ header.setAttribute("aria-sort", direction === 1 ? "ascending" : "descending");
223
+ };
224
+
225
+ document.addEventListener("click", (event) => {
226
+ const header = event.target.closest("th.pm-sort");
227
+ if (header) sort(header);
228
+ });
229
+ document.addEventListener("click", (event) => {
230
+ if (!event.target.closest("#pm-cite-btn")) return;
231
+ document.querySelector("#pm-cite-dialog")?.showModal();
232
+ });
233
+ document.addEventListener("click", (event) => {
234
+ const evaluateButton = event.target.closest(
235
+ "#pm-evaluate button, button#pm-evaluate"
236
+ );
237
+ if (!evaluateButton) return;
238
+ if (evaluateButton.dataset.confirmed === "true") {
239
+ delete evaluateButton.dataset.confirmed;
240
+ return;
241
+ }
242
+
243
+ event.preventDefault();
244
+ event.stopImmediatePropagation();
245
+ const dialog = document.querySelector("#pm-evaluation-confirmation");
246
+ if (!dialog) return;
247
+ dialog.returnValue = "";
248
+ dialog.addEventListener("close", () => {
249
+ if (dialog.returnValue !== "confirm") return;
250
+ evaluateButton.dataset.confirmed = "true";
251
+ evaluateButton.click();
252
+ }, { once: true });
253
+ dialog.showModal();
254
+ }, true);
255
+ document.addEventListener("keydown", (event) => {
256
+ if (event.key !== "Enter" && event.key !== " ") return;
257
+ const header = event.target.closest("th.pm-sort");
258
+ if (!header) return;
259
+ event.preventDefault();
260
+ sort(header);
261
+ });
262
+ }
263
+ """
264
+
265
+
266
+ def _page_text(name: str) -> str:
267
+ """One ``pages/<tab>.md`` per prose tab, named after the tab it fills.
268
+
269
+ Editing the site's words never means touching Python. The data pages (Boards,
270
+ Board, Tasks) are generated markup instead.
271
+ """
272
+ return (PAGES_DIR / f"{name}.md").read_text()
273
+
274
+
275
+ def _download_help(modality: str | None) -> str:
276
+ """Show the quickstart command for the selected modality."""
277
+ if not modality:
278
+ return "Select a modality to get its download command."
279
+ return (
280
+ f"```bash\npython quickstart.py --modality {modality} "
281
+ "--out submission.parquet\n```"
282
+ )
283
+
284
+
285
+ def _registry_by_id(version: Version) -> dict[str, dict]:
286
+ """The scoreable tasks of ``version`` by task_id, flagged ``public`` if so.
287
+
288
+ ``PRIMO_FIXTURE=1`` swaps the token-gated fetch for the test registry, so the
289
+ app runs offline for local smoke-testing.
290
+ """
291
+ if os.environ.get("PRIMO_FIXTURE"):
292
+ from conftest import REGISTRY
293
+
294
+ return {task_id: dict(task) for task_id, task in REGISTRY.items()}
295
+ datasets = manifest_ids(fetch_manifest(TOKEN, version.data))
296
+ registry = scoreable_tasks(fetch_tasks_registry(TOKEN, version.labels), datasets)
297
+ by_id = {_norm_id(task["task_id"]): task for task in registry}
298
+ public = fetch_public_task_ids(TOKEN, version.data)
299
+ return {
300
+ task_id: {**task, PUBLIC: task_id in public} for task_id, task in by_id.items()
301
+ }
302
+
303
+
304
+ PageState = tuple[dict[str, dict], list[Board], pd.DataFrame, Version]
305
+
306
+
307
+ def _page_state(version: Version = CURRENT) -> PageState:
308
+ """Registry, boards and results of ``version`` -- one fetch per render.
309
+
310
+ A failed fetch yields empty structures so the page renders a "come back
311
+ later" state instead of a stack trace. Every page is built from one of these,
312
+ threaded through rather than refetched, so a page load is one round trip.
313
+ """
314
+ try:
315
+ by_id = _registry_by_id(version)
316
+ rows = version_rows(read_results(TOKEN), version.number)
317
+ return by_id, build_boards(by_id), rows, version
318
+ except Exception: # noqa: BLE001
319
+ traceback.print_exc()
320
+ return {}, [], pd.DataFrame(columns=RESULT_COLUMNS), version
321
+
322
+
323
+ def _about_text(by_id: dict[str, dict]) -> str:
324
+ """Methodology + the archives the cohorts live in, never their accessions.
325
+
326
+ The placeholder is substituted, not ``.format``-ed: a page of prose is free
327
+ to contain a brace, and a stray one must not blow up the tab. An unreachable
328
+ registry leaves ``by_id`` empty and the sentence falls back to a generic one.
329
+ """
330
+ repositories = source_repositories(list(by_id.values()))
331
+ return _page_text("about").replace(
332
+ "{repositories}", " and ".join(repositories) or "public archives"
333
+ )
334
+
335
+
336
+ def _summary(result: dict, model_name: str) -> str:
337
+ lines = [
338
+ f"**{model_name}**: covered {result['n_datasets_scored']}/"
339
+ f"{result['n_datasets_total']} datasets",
340
+ "",
341
+ ]
342
+ for category, stats in sorted(result["categories"].items()):
343
+ lines.append(f"- **{category}** ({stats['metric']}) = {stats['mean']:.3f}")
344
+ if result["full_coverage"]:
345
+ lines.append("\n✅ **full coverage.** You are ranked on every board.")
346
+ else:
347
+ lines.append(
348
+ "\n⚠️ **partial coverage.** You are ranked on the boards whose scored "
349
+ "tasks you covered in full, and your scores always appear in each "
350
+ "board's **per-task** table."
351
+ )
352
+ if result["missing"]:
353
+ lines.append(f"- missing from file: {result['missing']}")
354
+ if result["incomplete"]:
355
+ lines.append(f"- could not score: {result['incomplete']}")
356
+ return "\n".join(lines)
357
+
358
+
359
+ def _results_frame() -> pd.DataFrame:
360
+ """Every published row, or an empty frame if the fetch fails.
361
+
362
+ A Hugging Face hiccup must not block a submission, so an empty frame leaves
363
+ the name free rather than raising: fail-open on the ownership check and
364
+ on the limit of submissions per day.
365
+ """
366
+ try:
367
+ return read_results(TOKEN)
368
+ except Exception: # noqa: BLE001
369
+ traceback.print_exc()
370
+ return pd.DataFrame(columns=RESULT_COLUMNS)
371
+
372
+
373
+ def _claimed_by(df: pd.DataFrame, model: str) -> str:
374
+ """Who already owns this model name, or ``""``."""
375
+ return owner_of(df, model)
376
+
377
+
378
+ def _score_with_hvg(submission_path: str, modality: str) -> dict | None:
379
+ """Score the submission concatenated with the HVG features, or None on failure.
380
+
381
+ The plain scoring already validated the file, so a failure here is ours: it
382
+ is logged and the plain entry is saved alone rather than blocked.
383
+ """
384
+ try:
385
+ return score_all(
386
+ submission_path,
387
+ TOKEN,
388
+ modality=modality,
389
+ representation=EMBEDDING,
390
+ reference=fetch_hvg_reference(TOKEN),
391
+ )
392
+ except Exception: # noqa: BLE001
393
+ traceback.print_exc()
394
+ return None
395
+
396
+
397
+ def run_submission(
398
+ submission_path: str | None,
399
+ modality: str | None,
400
+ model_name: str,
401
+ institution: str,
402
+ is_author_submission: bool,
403
+ email: str,
404
+ paper_link: str,
405
+ hf_model_link: str,
406
+ notes: str,
407
+ username: str | None,
408
+ ) -> dict:
409
+ """Check, score and save one submission, for the web form and ``/submit``.
410
+
411
+ The reply holds the ``status``, ``message`` and ``submission_id`` that
412
+ ``primo submit`` reads.
413
+ """
414
+
415
+ def _refuse(message: str) -> dict:
416
+ return reply(REFUSED, message)
417
+
418
+ if not username:
419
+ return _refuse("Please sign in with Hugging Face to submit.")
420
+ if not modality:
421
+ return _refuse("Please select a modality.")
422
+ if not submission_path:
423
+ return _refuse("Please upload a submission file.")
424
+ if not model_name or not model_name.strip():
425
+ return _refuse("Please enter a model name.")
426
+ if not email or not email.strip():
427
+ return _refuse("Please enter a contact email.")
428
+ model = model_name.strip()
429
+ if BASELINE_TAG in model.lower():
430
+ return _refuse(
431
+ f"`{BASELINE_TAG}` is reserved for our reference submissions. Please "
432
+ "pick another model name."
433
+ )
434
+ if HVG_TAG.lower() in model.lower():
435
+ return _refuse(
436
+ f"`{HVG_TAG}` is reserved for the entry we derive from every "
437
+ "submission. Please pick another model name."
438
+ )
439
+ if model in RESERVED_COLUMNS:
440
+ return _refuse(
441
+ f"`{model}` is a column of the per-task table. Please pick another "
442
+ "model name."
443
+ )
444
+ published = _results_frame()
445
+ claimed = _claimed_by(published, model)
446
+ if claimed and claimed != username:
447
+ return _refuse(
448
+ f"The model name `{model}` is already submitted by another HF account. "
449
+ "Resubmission of the same model is allowed only from the same HF account. "
450
+ "Please pick another name."
451
+ )
452
+ if (
453
+ recent_submissions(published, username, datetime.now(timezone.utc))
454
+ >= SUBMISSIONS_PER_DAY
455
+ ):
456
+ return _refuse(
457
+ f"An account can publish {SUBMISSIONS_PER_DAY} submissions in 24 "
458
+ "hours. Please try again later."
459
+ )
460
+ try:
461
+ result = score_all(
462
+ submission_path, TOKEN, modality=modality, representation=EMBEDDING
463
+ )
464
+ except SubmissionError as error:
465
+ return _refuse(f"❌ {error}")
466
+ except EvaluatorError as error:
467
+ traceback.print_exc()
468
+ return reply(
469
+ ERROR,
470
+ "⚠️ We couldn't evaluate your submission. This is on our side, not your "
471
+ f"file. Please try again in a moment.\n\n`{error}`",
472
+ )
473
+ except Exception as error: # noqa: BLE001
474
+ traceback.print_exc()
475
+ return reply(ERROR, f"⚠️ Unexpected evaluation error (our side): {error}")
476
+
477
+ summary = _summary(result, model)
478
+ now = datetime.now(timezone.utc)
479
+ submitted_at = now.strftime(SUBMITTED_AT)
480
+ submission_id = f"{now.strftime('%Y%m%dT%H%M%S.%fZ')}-{uuid4().hex}"
481
+ credits = {
482
+ OWNER: username,
483
+ INSTITUTION: (institution or "").strip(),
484
+ IS_AUTHOR_SUBMISSION: bool(is_author_submission),
485
+ PAPER_LINK: (paper_link or "").strip(),
486
+ }
487
+ rows = task_rows(model, result, submitted_at, submission_id, credits)
488
+ if not rows:
489
+ summary += (
490
+ "\n\n⚠️ No valid scores were produced, so the model was not added to "
491
+ "any board."
492
+ )
493
+ return _refuse(summary)
494
+
495
+ hvg_result = _score_with_hvg(submission_path, modality)
496
+ hvg_rows = (
497
+ []
498
+ if hvg_result is None
499
+ else task_rows(
500
+ hvg_name(model), hvg_result, submitted_at, submission_id, credits
501
+ )
502
+ )
503
+ if hvg_result is not None:
504
+ summary += "\n\n" + _summary(hvg_result, hvg_name(model))
505
+ if not hvg_rows:
506
+ summary += (
507
+ f"\n\n⚠️ The `{hvg_name(model)}` entry could not be scored (our side), "
508
+ "so only the plain entry is saved; an earlier "
509
+ f"`{hvg_name(model)}` entry may still show older scores."
510
+ )
511
+ rows += hvg_rows
512
+
513
+ meta = {
514
+ "model_name": model,
515
+ "submitted_at": submitted_at,
516
+ SUBMISSION_ID: submission_id,
517
+ "email": email.strip(),
518
+ **credits,
519
+ "hf_model_link": (hf_model_link or "").strip(),
520
+ "notes": (notes or "").strip(),
521
+ }
522
+ try:
523
+ archive_submission(
524
+ submission_path, model, submission_id, modality, submitted_at, TOKEN
525
+ )
526
+ except Exception as error: # noqa: BLE001
527
+ traceback.print_exc()
528
+ summary += f"\n\n⚠️ scored, but the embedding archive was not saved: {error}"
529
+ return reply(ERROR, summary)
530
+ try:
531
+ append_results(rows, TOKEN)
532
+ except Exception as error: # noqa: BLE001
533
+ traceback.print_exc()
534
+ summary += f"\n\n⚠️ archived, but the leaderboard was not saved: {error}"
535
+ return reply(ERROR, summary, submission_id)
536
+
537
+ summary += f"\n\n✅ **{model} is now shown on the relevant boards.**"
538
+ if hvg_rows:
539
+ summary += f" So is **{hvg_name(model)}**."
540
+ try:
541
+ append_submission(meta, TOKEN)
542
+ except Exception as error: # noqa: BLE001
543
+ traceback.print_exc()
544
+ summary += f"\n\n⚠️ Contact metadata was not saved: {error}"
545
+ return reply(PUBLISHED, summary, submission_id)
546
+
547
+
548
+ def evaluate(
549
+ submission_path: str,
550
+ modality: str | None,
551
+ model_name: str,
552
+ institution: str,
553
+ is_author_submission: bool,
554
+ email: str,
555
+ paper_link: str,
556
+ hf_model_link: str,
557
+ notes: str,
558
+ profile: gr.OAuthProfile | None,
559
+ ):
560
+ """The web form: the shared submission path, then the page's answer."""
561
+ return run_submission(
562
+ submission_path,
563
+ modality,
564
+ model_name,
565
+ institution,
566
+ is_author_submission,
567
+ email,
568
+ paper_link,
569
+ hf_model_link,
570
+ notes,
571
+ None if profile is None else profile.username,
572
+ )["message"]
573
+
574
+
575
+ def _landing_tab(params: dict) -> str:
576
+ """Which page a visitor lands on: ``?tab=``, old board links included.
577
+
578
+ An unknown ``?tab=`` falls through to the leaderboard rather than selecting
579
+ nothing, which would render the Space with every panel collapsed.
580
+ """
581
+ tab = params.get("tab")
582
+ tab = LEGACY_TABS.get(tab, tab)
583
+ return tab if tab in TAB_IDS else "leaderboard"
584
+
585
+
586
+ def _initial_view(
587
+ params: dict, by_id: dict[str, dict], boards: list[Board]
588
+ ) -> tuple[str, str, str]:
589
+ """The pills a visitor lands on: an old ``?board=``, else the query, else Bulk."""
590
+ old = next((b for b in boards if b.slug == params.get("board")), None)
591
+ if old is not None:
592
+ return board_view(old, by_id)
593
+ modalities = [slug for _, slug in modality_choices(by_id)]
594
+ modality = params.get("modality")
595
+ if modality not in modalities:
596
+ modality = (
597
+ DEFAULT_MODALITY
598
+ if DEFAULT_MODALITY in modalities or not modalities
599
+ else modalities[0]
600
+ )
601
+ areas = [slug for _, slug in area_choices(by_id, modality)]
602
+ categories = [slug for _, slug in category_choices(by_id, modality)]
603
+ area = params.get("area") if params.get("area") in areas else ALL
604
+ category = params.get("task") if params.get("task") in categories else ALL
605
+ return modality, area, category
606
+
607
+
608
+ def _view_name(by_id: dict[str, dict], modality: str, area: str, category: str) -> str:
609
+ """The view as its pills read, ``Bulk RNAseq · Oncology · All tasks``."""
610
+ modalities = {slug: name for name, slug in modality_choices(by_id)}
611
+ areas = {slug: name for name, slug in area_choices(by_id, modality)}
612
+ categories = {slug: name for name, slug in category_choices(by_id, modality)}
613
+ return " · ".join(
614
+ (
615
+ modalities.get(modality, modality),
616
+ "All areas" if area == ALL else areas.get(area, area),
617
+ "All tasks" if category == ALL else categories.get(category, category),
618
+ )
619
+ )
620
+
621
+
622
+ def _render_view(
623
+ state: PageState, modality: str, area: str, category: str, hvg: str, public: str
624
+ ) -> str:
625
+ """The tables of the view the pills select, on the version on screen.
626
+
627
+ A pill clicked before the page has loaded has no state yet, and changes nothing.
628
+ """
629
+ if state is None:
630
+ return gr.skip()
631
+ by_id, _, df, _ = state
632
+ board = view_board(by_id, modality, area, category)
633
+ if public == HIDE:
634
+ board = without_public(board, by_id)
635
+ name = _view_name(by_id, modality, area, category)
636
+ return render_view(board, df, by_id, name, hvg != HIDE)
637
+
638
+
639
+ def _on_modality(state: PageState, modality: str, hvg: str, public: str):
640
+ """A new modality brings its own area and task pills, both back on All."""
641
+ by_id = state[0]
642
+ return (
643
+ gr.Radio(choices=area_choices(by_id, modality), value=ALL),
644
+ gr.Radio(choices=category_choices(by_id, modality), value=ALL),
645
+ _render_view(state, modality, ALL, ALL, hvg, public),
646
+ )
647
+
648
+
649
+ def _viewed_version(params: dict) -> Version:
650
+ """The version a visitor asked for; Submit and unknown numbers get the current."""
651
+ if params.get("tab") == "submit":
652
+ return CURRENT
653
+ number = params.get(VERSION_PARAM)
654
+ return next((listed for listed in VERSIONS if listed.number == number), CURRENT)
655
+
656
+
657
+ def _init(request: gr.Request):
658
+ """Render every page from one fetch, on the tab and the view the query asks.
659
+
660
+ The fetched state is returned too, so a click on a pill re-renders the view
661
+ without a second round trip.
662
+ """
663
+ params = dict(request.query_params) if request else {}
664
+ state = _page_state(_viewed_version(params))
665
+ by_id, boards, df, version = state
666
+ tab = _landing_tab(params)
667
+ modality, area, category = _initial_view(params, by_id, boards)
668
+ hvg = HIDE if params.get("hvg") == HIDE else SHOW
669
+ public = HIDE if params.get("public") == HIDE else SHOW
670
+ return (
671
+ gr.Tabs(selected=tab),
672
+ nav_html(tab, version),
673
+ hero_html(boards, df, version),
674
+ _render_view(state, modality, area, category, hvg, public),
675
+ gr.Radio(choices=modality_choices(by_id), value=modality),
676
+ gr.Radio(choices=area_choices(by_id, modality), value=area),
677
+ gr.Radio(choices=category_choices(by_id, modality), value=category),
678
+ gr.Radio(value=hvg),
679
+ gr.Radio(value=public, visible=any(t.get(PUBLIC) for t in by_id.values())),
680
+ state,
681
+ render_tasks(by_id),
682
+ _about_text(by_id),
683
+ history_html(df, version),
684
+ cta_html(boards, version),
685
+ )
686
+
687
+
688
+ def build_demo() -> gr.Blocks:
689
+ with gr.Blocks(
690
+ title="PRIMO Benchmark",
691
+ theme=THEME,
692
+ head=PAGE_HEAD,
693
+ css_paths=[CSS_PATH],
694
+ js=TABLE_SORT_JS,
695
+ fill_width=True,
696
+ ) as demo:
697
+ page_state = gr.State(None)
698
+ with gr.Column(elem_id="pm-site"):
699
+ nav = gr.HTML(padding=False)
700
+ with gr.Tabs(elem_id="pm-pages") as pages:
701
+ with gr.Tab("Leaderboard", id="leaderboard"):
702
+ hero = gr.HTML(padding=False)
703
+ gr.HTML(BOARD_SUB, padding=False)
704
+ with gr.Row(elem_id="pm-controls"):
705
+ modality_f = gr.Radio(
706
+ label="Modality", elem_classes=["pm-seg", "pm-seg--main"]
707
+ )
708
+ area_f = gr.Radio(
709
+ label="Therapeutic area",
710
+ elem_classes=["pm-seg", "pm-seg--line"],
711
+ )
712
+ task_f = gr.Radio(
713
+ label="Task", elem_classes=["pm-seg", "pm-seg--line"]
714
+ )
715
+ hvg_f = gr.Radio(
716
+ TOGGLE_CHOICES,
717
+ value=SHOW,
718
+ label="+ HVG models",
719
+ elem_classes=["pm-seg", "pm-seg--minor"],
720
+ )
721
+ public_f = gr.Radio(
722
+ TOGGLE_CHOICES,
723
+ value=SHOW,
724
+ label="Public tasks",
725
+ visible=False,
726
+ elem_classes=["pm-seg", "pm-seg--minor"],
727
+ )
728
+ board_html = gr.HTML(padding=False)
729
+ history = gr.HTML(padding=False)
730
+ cta = gr.HTML(padding=False)
731
+ with gr.Tab("Tasks", id="tasks"):
732
+ tasks_html = gr.HTML(padding=False)
733
+ with gr.Tab("Submit", id="submit"):
734
+ gr.HTML(SUBMIT_HEAD, padding=False)
735
+ with gr.Column(elem_classes=["pm-body"]):
736
+ with gr.Row(elem_id="pm-submit"):
737
+ with gr.Column(scale=3):
738
+ gr.Markdown(
739
+ _page_text("submit"),
740
+ elem_classes=["pm-prose", "pm-submit-text"],
741
+ )
742
+ with gr.Column(scale=2, elem_id="pm-form"):
743
+ gr.LoginButton()
744
+ modality_in = gr.Radio(
745
+ MODALITY_CHOICES,
746
+ label="Modality",
747
+ info="One model submission covers one modality.",
748
+ elem_classes=["pm-seg", "pm-seg--form"],
749
+ )
750
+ download_md = gr.Markdown(
751
+ _download_help(None), elem_classes=["pm-hint"]
752
+ )
753
+ model_tb = gr.Textbox(
754
+ label="Model name",
755
+ placeholder="e.g. my-model-v1",
756
+ info="Shown on the leaderboard.",
757
+ )
758
+ institution_tb = gr.Textbox(
759
+ label="Institution (optional)",
760
+ placeholder="e.g. Example University",
761
+ info="Shown on the leaderboard.",
762
+ )
763
+ author_submission_cb = gr.Checkbox(
764
+ label="Submitted by the model's authors",
765
+ info="Adds an Authors check next to the model name.",
766
+ )
767
+ email_tb = gr.Textbox(
768
+ label="Email address",
769
+ placeholder="Your contact email",
770
+ info="Contact for this submission, kept private.",
771
+ )
772
+ notes_tb = gr.Textbox(
773
+ label="Training data / notes (optional)",
774
+ placeholder="e.g. pretrained on atlas X",
775
+ info="About the model or its training data.",
776
+ )
777
+ paper_tb = gr.Textbox(
778
+ label="Paper link (optional)",
779
+ placeholder="https://arxiv.org/abs/...",
780
+ info="Links the model name on the leaderboard.",
781
+ )
782
+ hf_tb = gr.Textbox(
783
+ label="Hugging Face model link (optional)",
784
+ placeholder="https://huggingface.co/...",
785
+ )
786
+ file_in = gr.File(
787
+ label="Submission (.csv / .tsv / .parquet / .npz)",
788
+ type="filepath",
789
+ )
790
+ run_btn = gr.Button(
791
+ "Evaluate",
792
+ elem_id="pm-evaluate",
793
+ elem_classes=[
794
+ "pm-btn",
795
+ "pm-btn--primary",
796
+ "pm-btn--block",
797
+ ],
798
+ )
799
+ status_html = gr.HTML(
800
+ evaluation_status(), visible=False
801
+ )
802
+ gr.HTML(evaluation_confirmation())
803
+ result_md = gr.Markdown()
804
+ with gr.Tab("Contribute", id="contribute"):
805
+ gr.HTML(PAGE_HEADS["contribute"], padding=False)
806
+ with gr.Column(elem_classes=["pm-body", "pm-prose"]):
807
+ gr.Markdown(_page_text("contribute"))
808
+ with gr.Tab("Method", id="method"):
809
+ gr.HTML(PAGE_HEADS["method"], padding=False)
810
+ with gr.Column(elem_classes=["pm-body", "pm-prose"]):
811
+ about_md = gr.Markdown()
812
+ gr.HTML(CREDIT, padding=False)
813
+
814
+ evaluation_event = run_btn.click(
815
+ lambda: gr.update(visible=True),
816
+ None,
817
+ status_html,
818
+ show_progress="hidden",
819
+ ).then(
820
+ evaluate,
821
+ [
822
+ file_in,
823
+ modality_in,
824
+ model_tb,
825
+ institution_tb,
826
+ author_submission_cb,
827
+ email_tb,
828
+ paper_tb,
829
+ hf_tb,
830
+ notes_tb,
831
+ ],
832
+ result_md,
833
+ concurrency_id=SUBMISSIONS,
834
+ )
835
+ gr.api(route(run_submission), api_name="submit", concurrency_id=SUBMISSIONS)
836
+ evaluation_event.then(
837
+ lambda: gr.update(visible=False),
838
+ None,
839
+ status_html,
840
+ show_progress="hidden",
841
+ )
842
+ modality_in.change(_download_help, modality_in, download_md)
843
+
844
+ pills = [modality_f, area_f, task_f, hvg_f, public_f]
845
+ modality_f.input(
846
+ _on_modality,
847
+ [page_state, modality_f, hvg_f, public_f],
848
+ [area_f, task_f, board_html],
849
+ show_progress="hidden",
850
+ ).then(None, pills, None, js=VIEW_URL_JS)
851
+ for pill in pills[1:]:
852
+ pill.input(
853
+ _render_view,
854
+ [page_state, *pills],
855
+ board_html,
856
+ show_progress="hidden",
857
+ ).then(None, pills, None, js=VIEW_URL_JS)
858
+ demo.load(
859
+ _init,
860
+ None,
861
+ [
862
+ pages,
863
+ nav,
864
+ hero,
865
+ board_html,
866
+ *pills,
867
+ page_state,
868
+ tasks_html,
869
+ about_md,
870
+ history,
871
+ cta,
872
+ ],
873
+ )
874
+ return demo
875
+
876
+
877
+ if __name__ == "__main__":
878
+ build_demo().launch(
879
+ server_name="0.0.0.0",
880
+ server_port=7860,
881
+ ssr_mode=False,
882
+ allowed_paths=ALLOWED_PATHS,
883
+ max_file_size=MAX_FILE_SIZE,
884
+ )
assets/hugging-face.svg ADDED
assets/primo-mark.webp ADDED
assets/primo-social-preview.png ADDED
assets/scienta-lab.svg ADDED
boards.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The slices of PRIMO a visitor can rank models on: registry tasks -> boards.
2
+
3
+ A *board* is a self-contained leaderboard over a subset of the registry -- every
4
+ task of a modality, of a therapeutic area, or of a task family. Coverage is
5
+ judged INSIDE a board, so a model that covered every Oncology task is ranked
6
+ on the Oncology board even though it skipped Infectious diseases. Each board is
7
+ a real leaderboard, not a view filter.
8
+
9
+ Modality stays the wall for every board, so models from different omics layers
10
+ are never ranked together. The leaderboard page picks a board with three pills:
11
+ modality, therapeutic area and task family. ``view_board`` builds the board of
12
+ any such view, area and family crossed included.
13
+
14
+ ``OPEN_BOARDS`` names the slices PRIMO does NOT cover, so the page states its own
15
+ gaps instead of implying the registry is the whole territory. They are a
16
+ hand-written constant, not a roadmap: a slice belongs here when a contributor
17
+ could plausibly bring it, and it disappears on its own the day the registry
18
+ covers it.
19
+
20
+ Modalities and therapeutic areas get open slices; task categories deliberately do
21
+ not. A category is an enum whose members are each pinned to a single metric, so
22
+ naming an open one ("biomarker discovery") would advertise a scoring rule that
23
+ does not exist. Modalities and areas only need a cohort.
24
+
25
+ Pure functions over registry dicts -- no Gradio, no network, no HTML.
26
+ """
27
+
28
+ import re
29
+ from dataclasses import dataclass, replace
30
+
31
+ from evaluator import _norm_id
32
+
33
+ METRIC_LABEL = {
34
+ "auroc": "AUROC",
35
+ "pearson": "Pearson",
36
+ "residual_spearman": "Residual Spearman score",
37
+ "centered_spearman": "Centered Spearman score",
38
+ }
39
+ MODALITY_LABEL = {
40
+ "bulk RNA": "Bulk RNAseq",
41
+ "Single Cell RNA": "Single-cell RNAseq",
42
+ "single-cell RNA": "Single-cell RNAseq",
43
+ }
44
+
45
+ MODALITY_GROUP = "Per modality"
46
+ AREA_GROUP = "Per therapeutic areas"
47
+ CATEGORY_GROUP = "Task Category"
48
+ OTHER_AREA = "Other"
49
+ PUBLIC = "public"
50
+
51
+ CODE_LENGTH = 3
52
+ FALLBACK_CODE = "n/a"
53
+ DISPLAY_LABEL = {"perturbation_prediction": "Perturbation response"}
54
+
55
+ CATEGORY_BLURB = {
56
+ "treatment_outcome": "Will this patient respond to the drug?",
57
+ "clinical_scores": "How severe is this patient's disease?",
58
+ "prognosis": "How will this patient's disease turn out?",
59
+ "endotype": "Which molecular subtype is this patient?",
60
+ "perturbation_prediction": "How will treatment change this patient's expression?",
61
+ }
62
+
63
+ MAX_LISTED = 3
64
+
65
+
66
+ def label(value: str) -> str:
67
+ """``treatment_outcome`` -> ``Treatment outcome``; leaves free text alone."""
68
+ return DISPLAY_LABEL.get(value, value.replace("_", " ").capitalize())
69
+
70
+
71
+ def metric_label(metric: str) -> str:
72
+ return METRIC_LABEL.get(metric, metric)
73
+
74
+
75
+ def modality_label(modality: str) -> str:
76
+ """Return the visitor-facing label for a stored modality identifier."""
77
+ return MODALITY_LABEL.get(modality, modality)
78
+
79
+
80
+ def short_code(name: str) -> str:
81
+ """The board's letter tag, standing in for what used to be a per-board emoji.
82
+
83
+ Derived from the name rather than looked up, because a lookup table is what
84
+ breaks: spatial transcriptomics or metabolomics would land on a shrug the day
85
+ somebody adds them. Colour carries the group; these letters only carry the
86
+ board.
87
+ """
88
+ letters = re.sub(r"[^a-z]", "", name.lower())
89
+ return letters[:CODE_LENGTH].upper() or FALLBACK_CODE
90
+
91
+
92
+ def slugify(*parts: str) -> str:
93
+ """URL-safe key for a board, stable enough to paste into a link."""
94
+ joined = "-".join(str(p) for p in parts)
95
+ return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", joined.lower())).strip("-")
96
+
97
+
98
+ def distinct(tasks, key: str) -> list[str]:
99
+ """Distinct non-empty values of ``key`` across registry tasks, case-insensitive."""
100
+ return sorted({str(t[key]) for t in tasks if t.get(key)}, key=str.lower)
101
+
102
+
103
+ def distinct_listed(tasks, key: str) -> list[str]:
104
+ """Distinct values of a list-valued ``key`` (a cohort can span several diseases)."""
105
+ values: set[str] = set()
106
+ for task in tasks:
107
+ values.update(str(value) for value in task.get(key) or [])
108
+ return sorted(values, key=str.lower)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Board:
113
+ """One enterable leaderboard: a named task set plus the numbers on its card."""
114
+
115
+ slug: str
116
+ group: str
117
+ name: str
118
+ code: str
119
+ blurb: str
120
+ modality: str
121
+ task_ids: frozenset[str]
122
+ n_cohorts: int
123
+ n_patients: int
124
+ n_diseases: int
125
+
126
+ @property
127
+ def n_tasks(self) -> int:
128
+ return len(self.task_ids)
129
+
130
+
131
+ def _join(values: list[str]) -> str:
132
+ """``a, b and c``, truncated -- card blurbs must not wrap forever.
133
+
134
+ A truncated list drops the "and": ``a, b and c…`` reads as an ellipsis stuck
135
+ to ``c``, where ``a, b, c…`` reads as the list continuing.
136
+ """
137
+ shown = values[:MAX_LISTED]
138
+ if not shown:
139
+ return "n/a"
140
+ if len(values) > MAX_LISTED:
141
+ return f"{', '.join(shown)}…"
142
+ if len(shown) == 1:
143
+ return shown[0]
144
+ return f"{', '.join(shown[:-1])} and {shown[-1]}"
145
+
146
+
147
+ def _cohort_stats(tasks: list[dict]) -> tuple[int, int, int]:
148
+ """Cohorts, patients and diseases behind a task set.
149
+
150
+ Counted per COHORT, not per task and not per dataset. Three clinical scores
151
+ read off the same biopsies are one cohort, and so are the two treatment arms
152
+ and the transfer split cut from one anti-TNF trial -- summing datasets would
153
+ advertise those patients twice. Entries on one cohort can differ in
154
+ ``n_samples`` (labels get dropped, an arm is a subset), so the widest one
155
+ stands for it.
156
+
157
+ A registry written before ``cohort_id`` existed falls back to ``dataset_id``,
158
+ which is the old behaviour rather than a crash.
159
+ """
160
+ patients_by_cohort: dict[str, int] = {}
161
+ diseases: set[str] = set()
162
+ for task in tasks:
163
+ cohort = str(task.get("cohort_id") or _norm_id(task.get("dataset_id", "")))
164
+ n_subjects = int(task.get("n_subjects") or task.get("n_samples") or 0)
165
+ patients_by_cohort[cohort] = max(patients_by_cohort.get(cohort, 0), n_subjects)
166
+ diseases.update(str(d) for d in task.get("diseases") or [])
167
+ return (
168
+ len(patients_by_cohort),
169
+ sum(patients_by_cohort.values()),
170
+ len(diseases),
171
+ )
172
+
173
+
174
+ def _blurb(group: str, name: str, tasks: list[dict]) -> str:
175
+ if group == MODALITY_GROUP:
176
+ display = modality_label(name)
177
+ return f"Every PRIMO task, scored from {display[:1].lower()}{display[1:]} profiles."
178
+ if group == CATEGORY_GROUP:
179
+ return CATEGORY_BLURB.get(name, label(name))
180
+ families = [label(c) for c in distinct(tasks, "category")]
181
+ return f"{_join(families)} across {_join(distinct_listed(tasks, 'diseases'))}."
182
+
183
+
184
+ def _board(group: str, name: str, modality: str, tasks: list[dict]) -> Board:
185
+ """Build one board from a task slice."""
186
+ n_cohorts, n_patients, n_diseases = _cohort_stats(tasks)
187
+ display = modality_label(name) if group == MODALITY_GROUP else label(name)
188
+ return Board(
189
+ slug=slugify(modality) if group == MODALITY_GROUP else slugify(name, modality),
190
+ group=group,
191
+ name=display,
192
+ code=short_code(display),
193
+ blurb=_blurb(group, name, tasks),
194
+ modality=modality,
195
+ task_ids=frozenset(_norm_id(t["task_id"]) for t in tasks),
196
+ n_cohorts=n_cohorts,
197
+ n_patients=n_patients,
198
+ n_diseases=n_diseases,
199
+ )
200
+
201
+
202
+ def without_public(board: Board, by_id: dict[str, dict]) -> Board:
203
+ """The same board restricted to its private tasks, card numbers recounted.
204
+
205
+ Parameters
206
+ ----------
207
+ board : Board
208
+ Board built from ``by_id``.
209
+ by_id : dict[str, dict]
210
+ Task registry keyed by task id, public tasks flagged.
211
+
212
+ Returns
213
+ -------
214
+ Board
215
+ The board with the same slug and name; its task set may be empty.
216
+ """
217
+ private = frozenset(t for t in board.task_ids if not by_id[t].get(PUBLIC))
218
+ n_cohorts, n_patients, n_diseases = _cohort_stats([by_id[t] for t in private])
219
+ return replace(
220
+ board,
221
+ task_ids=private,
222
+ n_cohorts=n_cohorts,
223
+ n_patients=n_patients,
224
+ n_diseases=n_diseases,
225
+ )
226
+
227
+
228
+ def build_boards(by_id: dict[str, dict]) -> list[Board]:
229
+ """Every board the registry supports, modality boards first.
230
+
231
+ A facet value that yields no task simply yields no card, so a registry
232
+ written before therapeutic areas existed degrades to modality boards only
233
+ instead of breaking the page.
234
+
235
+ The "Other" area gets no board: it mixes unrelated diseases, so its ranking
236
+ would mean nothing. Its tasks are still ranked on the modality and category
237
+ boards.
238
+ """
239
+ boards: list[Board] = []
240
+ for modality in distinct(by_id.values(), "modality"):
241
+ within = [t for t in by_id.values() if str(t.get("modality")) == modality]
242
+ boards.append(_board(MODALITY_GROUP, modality, modality, within))
243
+ for area in distinct(within, "therapeutic_area"):
244
+ if area == OTHER_AREA:
245
+ continue
246
+ tasks = [t for t in within if str(t.get("therapeutic_area")) == area]
247
+ boards.append(_board(AREA_GROUP, area, modality, tasks))
248
+ for category in distinct(within, "category"):
249
+ tasks = [t for t in within if str(t.get("category")) == category]
250
+ boards.append(_board(CATEGORY_GROUP, category, modality, tasks))
251
+ return boards
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class OpenBoard:
256
+ """A slice nobody can be ranked on yet: a stated gap, not a leaderboard.
257
+
258
+ Deliberately not a ``Board``: it has no tasks, no cohorts and no patients, and
259
+ zeroing those fields would print "0 patients" where the page invites a cohort.
260
+ """
261
+
262
+ group: str
263
+ name: str
264
+ blurb: str
265
+
266
+
267
+ OPEN_BOARDS: tuple[OpenBoard, ...] = (
268
+ OpenBoard(
269
+ MODALITY_GROUP,
270
+ "Single-cell RNAseq",
271
+ "Dissociated tissue, labelled at the patient level. No cohort yet.",
272
+ ),
273
+ OpenBoard(
274
+ MODALITY_GROUP,
275
+ "Proteomics",
276
+ "Plasma or tissue proteins paired with clinical follow-up. No cohort yet.",
277
+ ),
278
+ OpenBoard(
279
+ MODALITY_GROUP,
280
+ "Spatial transcriptomics",
281
+ "Expression kept in place in the tissue, with patient outcomes. No cohort yet.",
282
+ ),
283
+ OpenBoard(
284
+ AREA_GROUP,
285
+ "Neuroscience",
286
+ "Neurodegenerative cohorts, such as Alzheimer's or Parkinson's. No cohort yet.",
287
+ ),
288
+ OpenBoard(
289
+ AREA_GROUP,
290
+ "Cardiovascular",
291
+ "Heart or vessel disease with outcomes or treatment response. No cohort yet.",
292
+ ),
293
+ )
294
+
295
+
296
+ def in_group(boards: list[Board], group: str) -> list[Board]:
297
+ """The boards of one group, biggest first."""
298
+ return sorted(
299
+ (b for b in boards if b.group == group), key=lambda b: (-b.n_tasks, b.name)
300
+ )
301
+
302
+
303
+ def open_in_group(boards: list[Board], group: str) -> list[OpenBoard]:
304
+ """Open slices of one group, minus any slice the registry has since covered.
305
+
306
+ The day a single-cell cohort lands, its board is built from the registry and
307
+ the matching open slice drops out with no edit here.
308
+ """
309
+ covered = {board.name.lower() for board in in_group(boards, group)}
310
+ return [
311
+ board
312
+ for board in OPEN_BOARDS
313
+ if board.group == group and board.name.lower() not in covered
314
+ ]
315
+
316
+
317
+ ALL = "all"
318
+
319
+
320
+ def _within(by_id: dict[str, dict], modality: str) -> list[dict]:
321
+ """The tasks of the modality whose slug is ``modality``."""
322
+ return [t for t in by_id.values() if slugify(str(t.get("modality"))) == modality]
323
+
324
+
325
+ def modality_choices(by_id: dict[str, dict]) -> list[tuple[str, str]]:
326
+ """One ``(label, slug)`` pill per modality of the registry."""
327
+ choices = {
328
+ slugify(m): modality_label(m) for m in distinct(by_id.values(), "modality")
329
+ }
330
+ return [(name, slug) for slug, name in choices.items()]
331
+
332
+
333
+ def area_choices(by_id: dict[str, dict], modality: str) -> list[tuple[str, str]]:
334
+ """The therapeutic-area pills of a modality, "All" first and no "Other"."""
335
+ areas = distinct(_within(by_id, modality), "therapeutic_area")
336
+ return [("All", ALL)] + [(a, slugify(a)) for a in areas if a != OTHER_AREA]
337
+
338
+
339
+ def category_choices(by_id: dict[str, dict], modality: str) -> list[tuple[str, str]]:
340
+ """The task-category pills of a modality, "All" first."""
341
+ categories = distinct(_within(by_id, modality), "category")
342
+ return [("All", ALL)] + [(label(c), slugify(c)) for c in categories]
343
+
344
+
345
+ def view_board(
346
+ by_id: dict[str, dict], modality: str, area: str, category: str
347
+ ) -> Board:
348
+ """The board of one view: a modality's tasks, narrowed by area and category."""
349
+ within = _within(by_id, modality)
350
+ tasks = [
351
+ t
352
+ for t in within
353
+ if area in (ALL, slugify(str(t.get("therapeutic_area"))))
354
+ and category in (ALL, slugify(str(t.get("category"))))
355
+ ]
356
+ raw_modality = str(within[0]["modality"]) if within else modality
357
+ if area == ALL and category == ALL:
358
+ return _board(MODALITY_GROUP, raw_modality, raw_modality, tasks)
359
+ if category == ALL:
360
+ name = str(tasks[0]["therapeutic_area"]) if tasks else area
361
+ return _board(AREA_GROUP, name, raw_modality, tasks)
362
+ name = str(tasks[0]["category"]) if tasks else category
363
+ return _board(CATEGORY_GROUP, name, raw_modality, tasks)
364
+
365
+
366
+ def board_view(board: Board, by_id: dict[str, dict]) -> tuple[str, str, str]:
367
+ """The ``(modality, area, category)`` pills that show an old ``?board=`` link."""
368
+ task = next((by_id[t] for t in sorted(board.task_ids) if t in by_id), {})
369
+ area = ALL
370
+ category = ALL
371
+ if board.group == AREA_GROUP and task:
372
+ area = slugify(str(task.get("therapeutic_area")))
373
+ if board.group == CATEGORY_GROUP and task:
374
+ category = slugify(str(task.get("category")))
375
+ return slugify(board.modality), area, category
evaluator.py ADDED
@@ -0,0 +1,1725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone probe for the PRIMO public benchmark.
2
+
3
+ Loads one embedding submission for one modality (rows keyed by
4
+ ``dataset_id`` + ``sample_id``), then scores each TASK = (dataset, target): a
5
+ dataset is embedded once and reused across all its tasks. Per task it fits a
6
+ fixed probe—linear for scalar targets and multi-output ridge for response
7
+ matrices—over repeated frozen CV folds or a fixed transfer split. Binary tasks with
8
+ fewer than 30 subjects use leave-pair-out over subject pairs instead. Turning those
9
+ predictions into scores + leaderboard aggregates is the job of ``scoring.py``;
10
+ this module only reads data and runs the probe.
11
+
12
+ Failures are split by who caused them: a bad submission raises/records a
13
+ ``SubmissionError`` (the submitter fixes it); anything on our side, such as a
14
+ failed Hugging Face fetch or an unexpected bug, raises ``EvaluatorError`` so it
15
+ is never silently charged against the submitter.
16
+
17
+ Reads the public ``datasets.yaml`` manifest (what the submitter embeds) plus the
18
+ private ``tasks.yaml`` registry, each task's private ``labels.csv``, and hidden
19
+ perturbation ``targets.npz`` matrices where applicable. Each read is at the commit
20
+ that ``versions.py`` pins for the current board version, unless a caller passes
21
+ another ``revision``. It imports only ``scoring.py``, ``versions.py`` and the
22
+ packages of ``requirements.txt``, so it runs unchanged inside a public Hugging Face
23
+ Space.
24
+
25
+ Deps: numpy, pandas, joblib, scikit-learn, pyyaml. ``huggingface_hub`` is used
26
+ only by the fetch helpers / CLI, imported lazily.
27
+ """
28
+
29
+ import argparse
30
+ import hashlib
31
+ import itertools
32
+ import json
33
+ import logging
34
+ import os
35
+ import time
36
+ import zipfile
37
+ from collections import defaultdict
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ import numpy as np
42
+ import pandas as pd
43
+ import yaml
44
+ from joblib import Parallel, cpu_count, delayed, parallel_backend
45
+ from scoring import (
46
+ METRICS,
47
+ TaskScore,
48
+ category_means,
49
+ compute_residual_sample_spearman,
50
+ compute_target_centered_sample_spearman,
51
+ )
52
+ from sklearn.linear_model import LogisticRegressionCV, Ridge
53
+ from sklearn.model_selection import GroupKFold, StratifiedGroupKFold
54
+ from sklearn.preprocessing import StandardScaler
55
+ from versions import CURRENT
56
+
57
+ SAMPLE_ID = "sample_id"
58
+ SUBJECT_ID = "subject_id"
59
+ DATASET_ID = "dataset_id"
60
+ TASK_ID = "task_id"
61
+ LABEL = "label"
62
+ FOLD = "fold"
63
+ FOLD_PREFIX = f"{FOLD}_"
64
+ SPLIT = "split"
65
+ SPLIT_TRAIN = "train"
66
+ SPLIT_TEST = "test"
67
+ PAIR_ID = "pair_id"
68
+ CONTROL_ID = "control_id"
69
+ PERTURBED_ID = "perturbed_id"
70
+ PAIRING = "pairing"
71
+ PAIRED = "paired"
72
+ CARTESIAN = "cartesian"
73
+ PERTURBATION = "perturbation"
74
+ RESIDUAL_SPEARMAN = "residual_spearman"
75
+ CENTERED_SPEARMAN = "centered_spearman"
76
+ ORG = "PRIMOmics"
77
+ PUBLIC_REPO = f"{ORG}/primo"
78
+ LABELS_REPO = f"{ORG}/primo-labels"
79
+ RESULTS_REPO = f"{ORG}/primo-results"
80
+ MANIFEST_FILENAME = "datasets.yaml"
81
+ PUBLIC_TASKS_MANIFEST = "public_tasks/manifest.yaml"
82
+ TASKS_FILENAME = "tasks.yaml"
83
+ LABELS_FILENAME = "labels.csv"
84
+ TARGETS_FILENAME = "targets.npz"
85
+ MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
86
+
87
+ RIDGE_ALPHAS = np.logspace(-6.0, 6.0, 25)
88
+ PERTURBATION_TEST_Z_CLIP = 20.0
89
+ LOGREG_CS = np.logspace(-6.0, 6.0, 13)
90
+ INNER_CV = 5
91
+ INNER_CV_JOBS = 4
92
+ OUTER_CV_JOBS = min(10, cpu_count())
93
+ OUTER_PARALLEL_MIN_SAMPLES = 30
94
+ LOGREG_TOL = 1e-3
95
+ MAX_ITER = 5000
96
+ RANDOM_STATE = 0
97
+ MIN_CV_SUBJECTS = 30
98
+ CHECKPOINT_PROTOCOL = "grouped-inner-cv-spectral-ridge-v2"
99
+
100
+ EMBEDDING = "embedding"
101
+ GENE_SPACE = "gene_space"
102
+ REPRESENTATIONS = (EMBEDDING, GENE_SPACE)
103
+
104
+ FETCH_ATTEMPTS = 3
105
+ FETCH_BACKOFF = 1.0
106
+
107
+ NPZ_ID_KEYS = ("sample_ids", "sample_id", "ids")
108
+ NPZ_EMB_KEYS = ("embeddings", "embedding", "emb", "X")
109
+ NPZ_DSID_KEYS = ("dataset_ids", "dataset_id")
110
+ NOT_NPZ = "not a .npz archive: save it with np.savez, or use .csv/.parquet"
111
+
112
+
113
+ class SubmissionError(ValueError):
114
+ """A submission the evaluator cannot score (bad format, missing samples...)."""
115
+
116
+
117
+ class EvaluatorError(RuntimeError):
118
+ """A failure on our side (data fetch, unexpected bug), not the submitter's.
119
+
120
+ Raised instead of being recorded as a per-dataset skip, so a transient
121
+ Hugging Face hiccup or an evaluator bug never silently costs a submitter
122
+ their coverage.
123
+ """
124
+
125
+
126
+ @dataclass
127
+ class TaskOutcome:
128
+ """What happened to one scoreable task: scored / missing / invalid."""
129
+
130
+ task_id: str
131
+ dataset_id: str
132
+ status: str
133
+ score: TaskScore | None = None
134
+ reason: str | None = None
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class PerturbationTargets:
139
+ """Hidden response vectors and their aligned gene identifiers."""
140
+
141
+ delta: np.ndarray
142
+ gene_ids: np.ndarray
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class _PendingTask:
147
+ """Fetched task inputs awaiting either individual or compatible batch scoring."""
148
+
149
+ position: int
150
+ task: dict
151
+ emb: pd.DataFrame
152
+ labels: pd.DataFrame
153
+ targets: PerturbationTargets | None
154
+ fingerprint: str
155
+
156
+
157
+ def _digest_file(digest: "hashlib._Hash", path: str | Path) -> None:
158
+ """Feed a file's bytes into ``digest`` in chunks."""
159
+ with open(path, "rb") as handle:
160
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
161
+ digest.update(chunk)
162
+
163
+
164
+ class _TaskCheckpoints:
165
+ """Persist completed task outcomes under one submission-scoped directory."""
166
+
167
+ def __init__(
168
+ self,
169
+ directory: str | Path,
170
+ submission: str | Path,
171
+ modality: str | None,
172
+ representation: str,
173
+ reference: str | Path | None = None,
174
+ ) -> None:
175
+ digest = hashlib.sha256()
176
+ _digest_file(digest, submission)
177
+ digest.update(f"{CHECKPOINT_PROTOCOL}\0{modality}\0{representation}".encode())
178
+ if reference is not None:
179
+ digest.update(b"\0reference\0")
180
+ _digest_file(digest, reference)
181
+ digest.update(
182
+ json.dumps(
183
+ {
184
+ "ridge_alphas": RIDGE_ALPHAS.tolist(),
185
+ "logreg_cs": LOGREG_CS.tolist(),
186
+ "inner_cv": INNER_CV,
187
+ "logreg_tol": LOGREG_TOL,
188
+ "max_iter": MAX_ITER,
189
+ "min_cv_subjects": MIN_CV_SUBJECTS,
190
+ "random_state": RANDOM_STATE,
191
+ },
192
+ sort_keys=True,
193
+ ).encode()
194
+ )
195
+ self.directory = Path(directory) / digest.hexdigest()
196
+ self.directory.mkdir(parents=True, exist_ok=True)
197
+
198
+ def _path(self, task_id: str) -> Path:
199
+ safe_id = hashlib.sha256(task_id.encode()).hexdigest()[:16]
200
+ return self.directory / f"task-{safe_id}.json"
201
+
202
+ def load(self, task_id: str, fingerprint: str) -> TaskOutcome | None:
203
+ path = self._path(task_id)
204
+ if not path.exists():
205
+ return None
206
+ try:
207
+ payload = json.loads(path.read_text())
208
+ if payload["fingerprint"] != fingerprint:
209
+ return None
210
+ raw_score = payload.get("score")
211
+ score = None
212
+ if raw_score is not None:
213
+ raw_score["repeat_scores"] = tuple(raw_score["repeat_scores"])
214
+ score = TaskScore(**raw_score)
215
+ return TaskOutcome(
216
+ task_id=payload["task_id"],
217
+ dataset_id=payload["dataset_id"],
218
+ status=payload["status"],
219
+ score=score,
220
+ reason=payload.get("reason"),
221
+ )
222
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
223
+ logging.warning("ignoring invalid task checkpoint: %s", path)
224
+ return None
225
+
226
+ def save(self, outcome: TaskOutcome, fingerprint: str) -> None:
227
+ path = self._path(outcome.task_id)
228
+ score = None
229
+ if outcome.score is not None:
230
+ score = {
231
+ "task_id": outcome.score.task_id,
232
+ "dataset_id": outcome.score.dataset_id,
233
+ "category": outcome.score.category,
234
+ "metric": outcome.score.metric,
235
+ "score": outcome.score.score,
236
+ "n_samples": outcome.score.n_samples,
237
+ "repeat_scores": list(outcome.score.repeat_scores),
238
+ "diagnostics": outcome.score.diagnostics,
239
+ }
240
+ payload = {
241
+ "fingerprint": fingerprint,
242
+ "task_id": outcome.task_id,
243
+ "dataset_id": outcome.dataset_id,
244
+ "status": outcome.status,
245
+ "score": score,
246
+ "reason": outcome.reason,
247
+ }
248
+ temporary = path.with_suffix(".tmp")
249
+ temporary.write_text(json.dumps(payload, sort_keys=True) + "\n")
250
+ temporary.replace(path)
251
+
252
+
253
+ def _task_fingerprint(
254
+ task: dict,
255
+ labels: pd.DataFrame,
256
+ targets: PerturbationTargets | None,
257
+ ) -> str:
258
+ """Fingerprint every private input that can affect a task checkpoint."""
259
+ digest = hashlib.sha256(
260
+ json.dumps(task, sort_keys=True, separators=(",", ":")).encode()
261
+ )
262
+ digest.update(json.dumps(list(labels.columns)).encode())
263
+ digest.update(json.dumps([str(dtype) for dtype in labels.dtypes]).encode())
264
+ digest.update(pd.util.hash_pandas_object(labels, index=True).values.tobytes())
265
+ if targets is not None:
266
+ digest.update(targets.delta.tobytes())
267
+ digest.update(targets.gene_ids.astype(str).tobytes())
268
+ return digest.hexdigest()
269
+
270
+
271
+ def _regression_geometry(labels: pd.DataFrame) -> str:
272
+ """Identify tasks whose design matrices and evaluation splits are identical."""
273
+ columns = [SAMPLE_ID, SUBJECT_ID, *_fold_columns(labels)]
274
+ if SPLIT in labels:
275
+ columns.append(SPLIT)
276
+ frame = labels[columns]
277
+ digest = hashlib.sha256(json.dumps(columns).encode())
278
+ digest.update(pd.util.hash_pandas_object(frame, index=True).values.tobytes())
279
+ return digest.hexdigest()
280
+
281
+
282
+ def _outcome_from_score(task: dict, score: TaskScore) -> TaskOutcome:
283
+ """Map a completed score to the public scored/invalid outcome contract."""
284
+ task_id = _norm_id(task[TASK_ID])
285
+ dataset_id = _norm_id(task[DATASET_ID])
286
+ if not np.isfinite(score.score):
287
+ return TaskOutcome(
288
+ task_id,
289
+ dataset_id,
290
+ "invalid",
291
+ reason="degenerate score (constant predictions)",
292
+ )
293
+ return TaskOutcome(task_id, dataset_id, "scored", score=score)
294
+
295
+
296
+ def _norm_id(value: object) -> str:
297
+ """Normalise an id so int/str/float spellings of the same id join."""
298
+ if isinstance(value, float) and value.is_integer():
299
+ return str(int(value))
300
+ return str(value).strip()
301
+
302
+
303
+ def load_tasks_registry(path: str | Path) -> list[dict]:
304
+ """Read the private ``tasks.yaml`` registry (one entry per task)."""
305
+ with open(path) as handle:
306
+ data = yaml.safe_load(handle)
307
+ tasks = data.get("tasks", []) if isinstance(data, dict) else (data or [])
308
+ ids = [_norm_id(t[TASK_ID]) for t in tasks]
309
+ if len(set(ids)) != len(ids):
310
+ raise EvaluatorError(f"duplicate task_id in the registry: {ids}")
311
+ return tasks
312
+
313
+
314
+ def _fold_columns(labels: pd.DataFrame) -> list[str]:
315
+ """Return the ordered external-CV fold columns in private labels."""
316
+ repeated = [column for column in labels if column.startswith(FOLD_PREFIX)]
317
+ invalid = [
318
+ column for column in repeated if not column.removeprefix(FOLD_PREFIX).isdigit()
319
+ ]
320
+ if invalid:
321
+ raise EvaluatorError(f"labels.csv has invalid repeat-fold columns: {invalid}")
322
+ first = [FOLD] if FOLD in labels.columns else []
323
+ return [
324
+ *first,
325
+ *sorted(repeated, key=lambda column: int(column.removeprefix(FOLD_PREFIX))),
326
+ ]
327
+
328
+
329
+ def load_labels(path: str | Path) -> pd.DataFrame:
330
+ """Read private labels with repeated CV folds or one transfer split."""
331
+ df = pd.read_csv(path)
332
+ is_perturbation = PAIR_ID in df.columns
333
+ required = (
334
+ {SAMPLE_ID, SUBJECT_ID, PAIR_ID}
335
+ if is_perturbation
336
+ else {SAMPLE_ID, SUBJECT_ID, LABEL}
337
+ )
338
+ missing = required - set(df.columns)
339
+ if missing:
340
+ raise EvaluatorError(f"labels.csv missing columns: {sorted(missing)}")
341
+ _subject_groups(df)
342
+ has_folds = bool(_fold_columns(df))
343
+ has_split = SPLIT in df.columns
344
+ pairing_values = (
345
+ set(df[PAIRING].astype(str)) if is_perturbation and PAIRING in df else set()
346
+ )
347
+ is_cartesian = pairing_values == {CARTESIAN}
348
+ if sum((has_folds, has_split, is_cartesian)) != 1:
349
+ raise EvaluatorError(
350
+ "labels.csv needs exactly one evaluation assignment: folds, transfer "
351
+ "split, or Cartesian pairing."
352
+ )
353
+ if is_cartesian:
354
+ missing = {CONTROL_ID, PERTURBED_ID, PAIRING} - set(df.columns)
355
+ if missing:
356
+ raise EvaluatorError(
357
+ f"Cartesian labels.csv missing columns: {sorted(missing)}"
358
+ )
359
+ return df
360
+
361
+
362
+ def load_targets(path: str | Path) -> PerturbationTargets:
363
+ """Read and validate one task's hidden perturbation responses."""
364
+ with np.load(path, allow_pickle=False) as data:
365
+ missing = {"delta", "gene_ids"} - set(data.files)
366
+ if missing:
367
+ raise EvaluatorError(f"targets.npz missing arrays: {sorted(missing)}")
368
+ delta = np.asarray(data["delta"], dtype=float)
369
+ gene_ids = np.asarray(data["gene_ids"]).astype(str)
370
+ if delta.ndim != 2:
371
+ raise EvaluatorError(f"targets delta must be 2D, got shape {delta.shape}")
372
+ if gene_ids.ndim != 1 or len(gene_ids) != delta.shape[1]:
373
+ raise EvaluatorError("targets gene_ids must align with delta columns")
374
+ if not np.isfinite(delta).all():
375
+ raise EvaluatorError("targets delta contains NaN or inf values")
376
+ return PerturbationTargets(delta=delta, gene_ids=gene_ids)
377
+
378
+
379
+ def load_submission(path: str | Path) -> dict[str, pd.DataFrame]:
380
+ """Load a multi-dataset submission into ``{dataset_id: raw_block_frame}``."""
381
+ ext = Path(path).suffix.lower()
382
+ if ext == ".npz":
383
+ frame = _npz_to_frame(path)
384
+ elif ext in (".parquet", ".pq"):
385
+ frame = pd.read_parquet(path)
386
+ elif ext in (".tsv", ".txt"):
387
+ frame = pd.read_csv(path, sep="\t")
388
+ elif ext == ".csv":
389
+ frame = pd.read_csv(path)
390
+ else:
391
+ raise SubmissionError(
392
+ f"unsupported submission type '{ext}'. Use .csv/.tsv/.parquet/.npz"
393
+ )
394
+ return _split_by_dataset(frame)
395
+
396
+
397
+ def _npz_to_frame(path: str | Path) -> pd.DataFrame:
398
+ try:
399
+ data = np.load(path, allow_pickle=False)
400
+ except (ValueError, EOFError, zipfile.BadZipFile) as err:
401
+ raise SubmissionError(NOT_NPZ) from err
402
+ if not isinstance(data, np.lib.npyio.NpzFile):
403
+ raise SubmissionError(NOT_NPZ)
404
+ ds_key = next((k for k in NPZ_DSID_KEYS if k in data), None)
405
+ id_key = next((k for k in NPZ_ID_KEYS if k in data), None)
406
+ emb_key = next((k for k in NPZ_EMB_KEYS if k in data), None)
407
+ if ds_key is None or id_key is None or emb_key is None:
408
+ raise SubmissionError(
409
+ f".npz needs a dataset-id array {NPZ_DSID_KEYS}, a sample-id array "
410
+ f"{NPZ_ID_KEYS}, and an embedding array {NPZ_EMB_KEYS}; "
411
+ f"found {list(data.keys())}"
412
+ )
413
+ try:
414
+ emb = np.asarray(data[emb_key])
415
+ sample_ids = np.asarray(data[id_key])
416
+ dataset_ids = np.asarray(data[ds_key])
417
+ except (ValueError, zipfile.BadZipFile) as err:
418
+ raise SubmissionError(
419
+ f"cannot read the .npz arrays ({err}). Save ids as text, e.g. "
420
+ "np.asarray(ids, dtype=str), or use .csv/.parquet"
421
+ ) from err
422
+ if emb.ndim != 2:
423
+ raise SubmissionError(f"embeddings must be 2D, got shape {emb.shape}")
424
+ frame = pd.DataFrame(emb)
425
+ frame.insert(0, SAMPLE_ID, sample_ids)
426
+ frame.insert(0, DATASET_ID, dataset_ids)
427
+ return frame
428
+
429
+
430
+ def _split_by_dataset(frame: pd.DataFrame) -> dict[str, pd.DataFrame]:
431
+ """Group a submission by ``dataset_id`` into per-dataset blocks (raw frames)."""
432
+ lower = {str(c).lower(): c for c in frame.columns}
433
+ if DATASET_ID not in lower:
434
+ raise SubmissionError(
435
+ f"submission needs a '{DATASET_ID}' column; found {list(frame.columns)}"
436
+ )
437
+ ds_col = lower[DATASET_ID]
438
+ blocks: dict[str, pd.DataFrame] = {}
439
+ for raw_ds, group in frame.groupby(ds_col, sort=False):
440
+ dataset_id = _norm_id(raw_ds)
441
+ if dataset_id in blocks:
442
+ raise SubmissionError(
443
+ f"dataset_id '{dataset_id}' appears under multiple spellings"
444
+ )
445
+ blocks[dataset_id] = group.drop(columns=[ds_col]).dropna(axis=1, how="all")
446
+ if not blocks:
447
+ raise SubmissionError("empty submission")
448
+ return blocks
449
+
450
+
451
+ def _to_embedding_frame(frame: pd.DataFrame) -> pd.DataFrame:
452
+ lower = {str(c).lower(): c for c in frame.columns}
453
+ if SAMPLE_ID not in lower:
454
+ raise SubmissionError(
455
+ f"submission needs a '{SAMPLE_ID}' column; found {list(frame.columns)}"
456
+ )
457
+ id_col = lower[SAMPLE_ID]
458
+ ids = [_norm_id(v) for v in frame[id_col]]
459
+ emb = frame.drop(columns=[id_col])
460
+ non_numeric = [c for c in emb.columns if not pd.api.types.is_numeric_dtype(emb[c])]
461
+ if non_numeric:
462
+ raise SubmissionError(
463
+ f"embedding columns must be numeric; non-numeric: {non_numeric[:5]}"
464
+ )
465
+ if emb.shape[1] == 0:
466
+ raise SubmissionError("submission has no embedding columns")
467
+ out = emb.astype(float)
468
+ out.index = ids
469
+ if out.index.has_duplicates:
470
+ dups = out.index[out.index.duplicated()].unique().tolist()
471
+ raise SubmissionError(f"duplicate sample_ids in submission: {dups[:5]}")
472
+ return out
473
+
474
+
475
+ def _l2_normalize_rows(emb: pd.DataFrame) -> pd.DataFrame:
476
+ """Put every sample on the unit sphere; an embedding dimension has no unit."""
477
+ norms = np.linalg.norm(emb.to_numpy(dtype=float), axis=1, keepdims=True)
478
+ return pd.DataFrame(
479
+ emb.to_numpy(dtype=float) / np.where(norms == 0.0, 1.0, norms),
480
+ index=emb.index,
481
+ columns=emb.columns,
482
+ )
483
+
484
+
485
+ def _load_reference(path: str | Path) -> dict[str, pd.DataFrame]:
486
+ """Read the gene-space reference concatenated to submissions, by dataset.
487
+
488
+ The reference is ours, so any defect in it is an ``EvaluatorError``.
489
+ """
490
+ try:
491
+ return {
492
+ dataset_id: _to_embedding_frame(block)
493
+ for dataset_id, block in load_submission(path).items()
494
+ }
495
+ except (OSError, ValueError) as error:
496
+ message = f"could not load the reference features: {error}"
497
+ raise EvaluatorError(message) from error
498
+
499
+
500
+ def _concat_reference(emb: pd.DataFrame, reference: pd.DataFrame) -> pd.DataFrame:
501
+ """Append the reference columns to each sample, then L2-normalize the row.
502
+
503
+ Samples absent from either side are dropped, so a labelled sample the
504
+ submission lacks still fails ``_align`` exactly as without a reference. One
505
+ the reference lacks is caught first by ``_check_reference_covers``.
506
+ """
507
+ joined = emb.add_prefix("model_").join(
508
+ reference.add_prefix("reference_"), how="inner"
509
+ )
510
+ return _l2_normalize_rows(joined)
511
+
512
+
513
+ def _check_reference_covers(
514
+ labels: pd.DataFrame, reference: pd.DataFrame, task_id: str
515
+ ) -> None:
516
+ """Raise if the reference lacks a labelled sample: that defect is ours."""
517
+ lab_ids = [_norm_id(v) for v in labels[SAMPLE_ID]]
518
+ present = set(reference.index)
519
+ missing = [s for s in lab_ids if s not in present]
520
+ if missing:
521
+ raise EvaluatorError(
522
+ f"reference features lack {len(missing)}/{len(lab_ids)} labelled "
523
+ f"samples of {task_id}, e.g. {missing[:5]}"
524
+ )
525
+
526
+
527
+ def _align(labels: pd.DataFrame, emb: pd.DataFrame) -> np.ndarray:
528
+ """Return the embedding matrix in labels order, or raise if a sample is missing."""
529
+ lab_ids = [_norm_id(v) for v in labels[SAMPLE_ID]]
530
+ present = set(emb.index)
531
+ missing = [s for s in lab_ids if s not in present]
532
+ if missing:
533
+ raise SubmissionError(
534
+ f"{len(missing)}/{len(lab_ids)} labelled samples missing from "
535
+ f"submission, e.g. {missing[:5]}"
536
+ )
537
+ matrix = emb.loc[lab_ids].to_numpy(dtype=float)
538
+ if not np.isfinite(matrix).all():
539
+ raise SubmissionError("submission contains NaN or inf values")
540
+ return matrix
541
+
542
+
543
+ def _subject_groups(labels: pd.DataFrame) -> np.ndarray:
544
+ """Return normalized private subject ids, rejecting unusable metadata."""
545
+ if SUBJECT_ID not in labels:
546
+ raise EvaluatorError(f"labels.csv missing columns: ['{SUBJECT_ID}']")
547
+ if labels[SUBJECT_ID].isna().any():
548
+ raise EvaluatorError("labels.csv has missing subject_id values")
549
+ groups = np.array([_norm_id(value) for value in labels[SUBJECT_ID]])
550
+ if np.any(groups == ""):
551
+ raise EvaluatorError("labels.csv has empty subject_id values")
552
+ return groups
553
+
554
+
555
+ def _group_inner_cv(groups_train: np.ndarray) -> list[tuple[np.ndarray, np.ndarray]]:
556
+ """Subject-grouped inner folds for ridge penalty selection."""
557
+ n_groups = len(np.unique(groups_train))
558
+ n_splits = min(INNER_CV, n_groups)
559
+ if n_splits < 2:
560
+ raise SubmissionError("an outer training fold has fewer than two subjects")
561
+ splitter = GroupKFold(n_splits=n_splits)
562
+ rows = np.zeros(len(groups_train))
563
+ return list(splitter.split(rows, groups=groups_train))
564
+
565
+
566
+ def _ridge_fold_losses(
567
+ x_train: np.ndarray,
568
+ y_train: np.ndarray,
569
+ x_test: np.ndarray,
570
+ y_test: np.ndarray,
571
+ ) -> np.ndarray:
572
+ """Return validation MSE for every alpha from one exact SVD."""
573
+ return np.mean(_ridge_fold_target_losses(x_train, y_train, x_test, y_test), axis=1)
574
+
575
+
576
+ def _ridge_fold_target_losses(
577
+ x_train: np.ndarray,
578
+ y_train: np.ndarray,
579
+ x_test: np.ndarray,
580
+ y_test: np.ndarray,
581
+ ) -> np.ndarray:
582
+ """Return per-target validation MSE while sharing one factorization."""
583
+ y_train = np.asarray(y_train)
584
+ y_test = np.asarray(y_test)
585
+ if y_train.ndim == 1:
586
+ y_train = y_train[:, None]
587
+ y_test = y_test[:, None]
588
+ x_offset = np.mean(x_train, axis=0)
589
+ y_offset = np.mean(y_train, axis=0)
590
+ x_centered = x_train - x_offset
591
+ y_centered = y_train - y_offset
592
+ u, singular_values, vt = np.linalg.svd(x_centered, full_matrices=False)
593
+ projected_x = (x_test - x_offset) @ vt.T
594
+ projected_y = u.T @ y_centered
595
+ shrinkage = singular_values[:, None] / (
596
+ singular_values[:, None] ** 2 + RIDGE_ALPHAS[None, :]
597
+ )
598
+ losses = []
599
+ for index in range(len(RIDGE_ALPHAS)):
600
+ predictions = projected_x @ (shrinkage[:, index, None] * projected_y) + y_offset
601
+ losses.append(np.mean((predictions - y_test) ** 2, axis=0))
602
+ return np.asarray(losses)
603
+
604
+
605
+ def _ridge_alpha(matrix: np.ndarray, target: np.ndarray, groups: np.ndarray) -> float:
606
+ """Select the grouped-CV ridge penalty without refitting for every alpha."""
607
+ losses = [
608
+ _ridge_fold_losses(matrix[train], target[train], matrix[test], target[test])
609
+ for train, test in _group_inner_cv(groups)
610
+ ]
611
+ return float(RIDGE_ALPHAS[np.argmin(np.mean(losses, axis=0))])
612
+
613
+
614
+ def _ridge_alphas_by_target(
615
+ matrix: np.ndarray, targets: np.ndarray, groups: np.ndarray
616
+ ) -> np.ndarray:
617
+ """Select independent penalties while sharing each fold's factorization."""
618
+ losses = [
619
+ _ridge_fold_target_losses(
620
+ matrix[train], targets[train], matrix[test], targets[test]
621
+ )
622
+ for train, test in _group_inner_cv(groups)
623
+ ]
624
+ mean_losses = np.mean(losses, axis=0)
625
+ return RIDGE_ALPHAS[np.argmin(mean_losses, axis=0)]
626
+
627
+
628
+ def _ridge_predict(
629
+ x_train: np.ndarray,
630
+ y_train: np.ndarray,
631
+ x_test: np.ndarray,
632
+ groups_train: np.ndarray,
633
+ ) -> np.ndarray:
634
+ """Fit ridge with an exact grouped-CV spectral alpha search."""
635
+ alpha = _ridge_alpha(x_train, y_train, groups_train)
636
+ return Ridge(alpha=alpha).fit(x_train, y_train).predict(x_test)
637
+
638
+
639
+ def _ridge_predict_by_target(
640
+ x_train: np.ndarray,
641
+ y_train: np.ndarray,
642
+ x_test: np.ndarray,
643
+ groups_train: np.ndarray,
644
+ ) -> np.ndarray:
645
+ """Fit independent targets while reusing their spectral CV decompositions."""
646
+ alphas = _ridge_alphas_by_target(x_train, y_train, groups_train)
647
+ return Ridge(alpha=alphas).fit(x_train, y_train).predict(x_test)
648
+
649
+
650
+ def _relabelled_groups(groups: np.ndarray, seed: int) -> np.ndarray:
651
+ """Reorder group tie-breaks without shuffling label counts between groups."""
652
+ unique, codes = np.unique(groups, return_inverse=True)
653
+ return np.random.default_rng(seed).permutation(len(unique))[codes]
654
+
655
+
656
+ def _inner_cv(
657
+ y_train: np.ndarray, groups_train: np.ndarray
658
+ ) -> list[tuple[np.ndarray, np.ndarray]]:
659
+ """Stratified subject-grouped folds for logistic penalty selection."""
660
+ classes = np.unique(y_train)
661
+ support = min(len(np.unique(groups_train[y_train == label])) for label in classes)
662
+ n_splits = min(INNER_CV, support)
663
+ if n_splits < 2:
664
+ raise SubmissionError(
665
+ "an outer training fold has fewer than two subjects in a class"
666
+ )
667
+ rows = np.zeros(len(y_train))
668
+ relabelled_groups = _relabelled_groups(groups_train, RANDOM_STATE)
669
+ splitter = StratifiedGroupKFold(n_splits=n_splits)
670
+ splits = list(splitter.split(rows, y_train, relabelled_groups))
671
+ expected_classes = set(classes)
672
+ if not all(
673
+ set(np.unique(y_train[train])) == expected_classes
674
+ and set(np.unique(y_train[test])) == expected_classes
675
+ for train, test in splits
676
+ ):
677
+ raise SubmissionError(
678
+ "could not construct inner subject-grouped folds with every class"
679
+ )
680
+ return splits
681
+
682
+
683
+ def fit_predict(
684
+ task_type: str,
685
+ x_train: np.ndarray,
686
+ y_train: np.ndarray,
687
+ x_test: np.ndarray,
688
+ classes: np.ndarray,
689
+ groups_train: np.ndarray,
690
+ inner_cv_jobs: int = INNER_CV_JOBS,
691
+ logreg_tol: float | None = None,
692
+ ) -> np.ndarray:
693
+ """Fit the fixed linear probe on train, return predictions for test."""
694
+ scaler = StandardScaler().fit(x_train)
695
+ x_train = scaler.transform(x_train)
696
+ x_test = scaler.transform(x_test)
697
+ logreg_tol = LOGREG_TOL if logreg_tol is None else logreg_tol
698
+ if task_type == "classification":
699
+ if len(np.unique(y_train)) < 2:
700
+ raise SubmissionError("a training fold has a single class")
701
+ scoring = "roc_auc" if len(classes) == 2 else "roc_auc_ovr_weighted"
702
+ clf = LogisticRegressionCV(
703
+ Cs=LOGREG_CS,
704
+ cv=_inner_cv(y_train, groups_train),
705
+ scoring=scoring,
706
+ solver="lbfgs",
707
+ max_iter=MAX_ITER,
708
+ n_jobs=inner_cv_jobs,
709
+ random_state=RANDOM_STATE,
710
+ tol=logreg_tol,
711
+ ).fit(x_train, y_train)
712
+ proba = clf.predict_proba(x_test)
713
+ col = {c: i for i, c in enumerate(clf.classes_)}
714
+ out = np.zeros((x_test.shape[0], len(classes)))
715
+ for j, c in enumerate(classes):
716
+ if c in col:
717
+ out[:, j] = proba[:, col[c]]
718
+ return out
719
+ return _ridge_predict(x_train, y_train, x_test, groups_train)
720
+
721
+
722
+ def _fold_score(
723
+ metric: str,
724
+ task_type: str,
725
+ y: np.ndarray,
726
+ folds: np.ndarray,
727
+ matrix: np.ndarray,
728
+ classes: np.ndarray,
729
+ groups: np.ndarray,
730
+ inner_cv_jobs: int = INNER_CV_JOBS,
731
+ logreg_tol: float | None = None,
732
+ ) -> float:
733
+ """Return the mean native metric across one external CV partition."""
734
+ scores = []
735
+ for fold in sorted(np.unique(folds)):
736
+ test = folds == fold
737
+ predictions = fit_predict(
738
+ task_type,
739
+ matrix[~test],
740
+ y[~test],
741
+ matrix[test],
742
+ classes,
743
+ groups[~test],
744
+ inner_cv_jobs,
745
+ logreg_tol,
746
+ )
747
+ try:
748
+ scores.append(METRICS[metric](y[test], predictions, classes))
749
+ except ValueError as error:
750
+ raise SubmissionError(
751
+ f"CV fold {fold} is not scoreable: {error}"
752
+ ) from error
753
+ if not np.isfinite(scores).all():
754
+ raise SubmissionError("a CV fold produced a non-finite score")
755
+ return float(np.mean(scores))
756
+
757
+
758
+ def _parallel_fold_score(
759
+ metric: str,
760
+ task_type: str,
761
+ y: np.ndarray,
762
+ folds: np.ndarray,
763
+ matrix: np.ndarray,
764
+ classes: np.ndarray,
765
+ groups: np.ndarray,
766
+ logreg_tol: float,
767
+ ) -> float:
768
+ """Score one repeat in an outer worker without nested inner processes."""
769
+ return _fold_score(
770
+ metric,
771
+ task_type,
772
+ y,
773
+ folds,
774
+ matrix,
775
+ classes,
776
+ groups,
777
+ inner_cv_jobs=1,
778
+ logreg_tol=logreg_tol,
779
+ )
780
+
781
+
782
+ def _repeated_cv_scores(
783
+ metric: str,
784
+ task_type: str,
785
+ y: np.ndarray,
786
+ labels: pd.DataFrame,
787
+ matrix: np.ndarray,
788
+ classes: np.ndarray,
789
+ ) -> tuple[float, ...]:
790
+ """Return one mean fold metric for every external CV repeat."""
791
+ groups = _subject_groups(labels)
792
+ columns = _fold_columns(labels)
793
+ workers = min(OUTER_CV_JOBS, len(columns))
794
+ if workers == 1:
795
+ scores = [
796
+ _fold_score(
797
+ metric,
798
+ task_type,
799
+ y,
800
+ labels[column].to_numpy(),
801
+ matrix,
802
+ classes,
803
+ groups,
804
+ )
805
+ for column in columns
806
+ ]
807
+ else:
808
+ with parallel_backend("loky", inner_max_num_threads=1):
809
+ scores = Parallel(n_jobs=workers)(
810
+ delayed(_parallel_fold_score)(
811
+ metric,
812
+ task_type,
813
+ y,
814
+ labels[column].to_numpy(),
815
+ matrix,
816
+ classes,
817
+ groups,
818
+ LOGREG_TOL,
819
+ )
820
+ for column in columns
821
+ )
822
+ return tuple(float(score) for score in scores)
823
+
824
+
825
+ def _repeated_cv_score(
826
+ metric: str,
827
+ task_type: str,
828
+ y: np.ndarray,
829
+ labels: pd.DataFrame,
830
+ matrix: np.ndarray,
831
+ classes: np.ndarray,
832
+ ) -> float:
833
+ """Return the aggregate repeated-CV score for legacy callers."""
834
+ return float(
835
+ np.mean(_repeated_cv_scores(metric, task_type, y, labels, matrix, classes))
836
+ )
837
+
838
+
839
+ def _regression_fold_scores(
840
+ metrics: tuple[str, ...],
841
+ targets: np.ndarray,
842
+ folds: np.ndarray,
843
+ matrix: np.ndarray,
844
+ groups: np.ndarray,
845
+ ) -> tuple[float, ...]:
846
+ """Score compatible regression targets with shared ridge factorizations."""
847
+ fold_scores: list[list[float]] = [[] for _ in metrics]
848
+ for fold in sorted(np.unique(folds)):
849
+ test = folds == fold
850
+ scaler = StandardScaler().fit(matrix[~test])
851
+ x_train = scaler.transform(matrix[~test])
852
+ x_test = scaler.transform(matrix[test])
853
+ predictions = _ridge_predict_by_target(
854
+ x_train, targets[~test], x_test, groups[~test]
855
+ )
856
+ for index, metric in enumerate(metrics):
857
+ fold_scores[index].append(
858
+ METRICS[metric](targets[test, index], predictions[:, index], None)
859
+ )
860
+ if not all(np.isfinite(scores).all() for scores in fold_scores):
861
+ raise SubmissionError("a CV fold produced a non-finite score")
862
+ return tuple(float(np.mean(scores)) for scores in fold_scores)
863
+
864
+
865
+ def _repeated_regression_scores(
866
+ metrics: tuple[str, ...],
867
+ targets: np.ndarray,
868
+ labels: pd.DataFrame,
869
+ matrix: np.ndarray,
870
+ ) -> tuple[tuple[float, ...], ...]:
871
+ """Return repeat scores per target for compatible regression tasks."""
872
+ groups = _subject_groups(labels)
873
+ columns = _fold_columns(labels)
874
+ workers = min(OUTER_CV_JOBS, len(columns))
875
+ if workers == 1:
876
+ by_repeat = [
877
+ _regression_fold_scores(
878
+ metrics, targets, labels[column].to_numpy(), matrix, groups
879
+ )
880
+ for column in columns
881
+ ]
882
+ else:
883
+ with parallel_backend("loky", inner_max_num_threads=1):
884
+ by_repeat = Parallel(n_jobs=workers)(
885
+ delayed(_regression_fold_scores)(
886
+ metrics,
887
+ targets,
888
+ labels[column].to_numpy(),
889
+ matrix,
890
+ groups,
891
+ )
892
+ for column in columns
893
+ )
894
+ return tuple(
895
+ tuple(float(scores[index]) for scores in by_repeat)
896
+ for index in range(len(metrics))
897
+ )
898
+
899
+
900
+ def _score_regression_group(
901
+ tasks: list[dict], labels_by_task: list[pd.DataFrame], emb: pd.DataFrame
902
+ ) -> list[TaskScore]:
903
+ """Score regression tasks that have identical samples and assignments."""
904
+ labels = labels_by_task[0]
905
+ matrix = _align(labels, emb)
906
+ targets = np.column_stack([frame[LABEL].to_numpy() for frame in labels_by_task])
907
+ metrics = tuple(str(task["metric"]) for task in tasks)
908
+ unknown = set(metrics) - set(METRICS)
909
+ if unknown:
910
+ raise EvaluatorError(f"unknown metric(s) for regression tasks: {unknown}")
911
+ if SPLIT in labels:
912
+ split = labels[SPLIT].to_numpy()
913
+ train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
914
+ if not train.any() or not test.any():
915
+ raise EvaluatorError("transfer labels.csv has an empty train or test side")
916
+ scaler = StandardScaler().fit(matrix[train])
917
+ predictions = _ridge_predict_by_target(
918
+ scaler.transform(matrix[train]),
919
+ targets[train],
920
+ scaler.transform(matrix[test]),
921
+ _subject_groups(labels)[train],
922
+ )
923
+ repeat_scores = tuple(
924
+ (METRICS[metric](targets[test, index], predictions[:, index], None),)
925
+ for index, metric in enumerate(metrics)
926
+ )
927
+ n_samples = int(test.sum())
928
+ else:
929
+ repeat_scores = _repeated_regression_scores(metrics, targets, labels, matrix)
930
+ n_samples = len(labels)
931
+ return [
932
+ TaskScore(
933
+ task_id=_norm_id(task[TASK_ID]),
934
+ dataset_id=_norm_id(task[DATASET_ID]),
935
+ category=str(task["category"]),
936
+ metric=metrics[index],
937
+ score=float(np.mean(repeat_scores[index])),
938
+ n_samples=n_samples,
939
+ repeat_scores=repeat_scores[index],
940
+ )
941
+ for index, task in enumerate(tasks)
942
+ ]
943
+
944
+
945
+ def _uses_leave_pair_out(metric: str, y: np.ndarray, groups: np.ndarray) -> bool:
946
+ """Is this a binary AUROC task with fewer than ``MIN_CV_SUBJECTS`` subjects?
947
+
948
+ Paired tasks, where a subject carries both labels, stay on CV.
949
+ """
950
+ return (
951
+ metric == "auroc"
952
+ and len(np.unique(y)) == 2
953
+ and len(np.unique(groups)) < MIN_CV_SUBJECTS
954
+ and pd.Series(y).groupby(groups).nunique().max() == 1
955
+ )
956
+
957
+
958
+ def _subject_labels(y: np.ndarray, subjects: np.ndarray) -> pd.Series:
959
+ """Return one label per subject, indexed by subject id."""
960
+ per_subject = pd.Series(y).groupby(subjects)
961
+ if (per_subject.nunique() > 1).any():
962
+ raise EvaluatorError("a subject carries more than one label")
963
+ return per_subject.first()
964
+
965
+
966
+ def _ranked_pairs(scores: np.ndarray, is_positive: np.ndarray) -> tuple[float, int]:
967
+ """Count correctly ordered positive-negative sample pairs, ties as half."""
968
+ gaps = scores[is_positive][:, None] - scores[~is_positive][None, :]
969
+ if not np.isfinite(gaps).all():
970
+ raise SubmissionError("a leave-pair-out fit produced a non-finite score")
971
+ return float((gaps > 0).sum() + 0.5 * (gaps == 0).sum()), int(gaps.size)
972
+
973
+
974
+ def _pair_ranks(
975
+ y: np.ndarray,
976
+ subjects: np.ndarray,
977
+ matrix: np.ndarray,
978
+ classes: np.ndarray,
979
+ pair: tuple[str, str],
980
+ ) -> tuple[float, int]:
981
+ """Fit without one subject pair, then count its sample pairs ranked right."""
982
+ positive, negative = pair
983
+ held_out = np.isin(subjects, [positive, negative])
984
+ scores = fit_predict(
985
+ "classification",
986
+ matrix[~held_out],
987
+ y[~held_out],
988
+ matrix[held_out],
989
+ classes,
990
+ subjects[~held_out],
991
+ inner_cv_jobs=1,
992
+ )[:, 1]
993
+ return _ranked_pairs(scores, subjects[held_out] == positive)
994
+
995
+
996
+ def _leave_pair_out_score(
997
+ y: np.ndarray, subjects: np.ndarray, matrix: np.ndarray, classes: np.ndarray
998
+ ) -> float:
999
+ """Fraction of sample pairs ranked right, each subject pair held out once."""
1000
+ per_subject = _subject_labels(y, subjects)
1001
+ pairs = list(
1002
+ itertools.product(
1003
+ per_subject.index[per_subject == classes[1]],
1004
+ per_subject.index[per_subject == classes[0]],
1005
+ )
1006
+ )
1007
+ workers = min(OUTER_CV_JOBS, len(pairs))
1008
+ if workers == 1:
1009
+ counts = [_pair_ranks(y, subjects, matrix, classes, pair) for pair in pairs]
1010
+ else:
1011
+ with parallel_backend("loky", inner_max_num_threads=1):
1012
+ counts = Parallel(n_jobs=workers)(
1013
+ delayed(_pair_ranks)(y, subjects, matrix, classes, pair)
1014
+ for pair in pairs
1015
+ )
1016
+ correct = sum(pair_correct for pair_correct, _ in counts)
1017
+ total = sum(pair_total for _, pair_total in counts)
1018
+ return correct / total
1019
+
1020
+
1021
+ def _transfer_predict(
1022
+ task_type: str,
1023
+ y: np.ndarray,
1024
+ split: np.ndarray,
1025
+ matrix: np.ndarray,
1026
+ classes: np.ndarray,
1027
+ groups: np.ndarray,
1028
+ ) -> tuple[np.ndarray, np.ndarray]:
1029
+ """Fit once on the train cohort, predict the test cohort (no pooling)."""
1030
+ train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
1031
+ if not train.any() or not test.any():
1032
+ raise EvaluatorError("transfer labels.csv has an empty train or test side")
1033
+ preds = fit_predict(
1034
+ task_type,
1035
+ matrix[train],
1036
+ y[train],
1037
+ matrix[test],
1038
+ classes,
1039
+ groups[train],
1040
+ )
1041
+ return y[test], preds
1042
+
1043
+
1044
+ def _select_degs(delta_train: np.ndarray, n_top: int) -> np.ndarray:
1045
+ """Select the strongest mean absolute responses using training rows only."""
1046
+ n_keep = min(int(n_top), delta_train.shape[1])
1047
+ if n_keep <= 0:
1048
+ raise EvaluatorError("perturbation task has no response genes to select")
1049
+ effect = np.abs(np.mean(delta_train, axis=0))
1050
+ return np.argsort(effect, kind="stable")[-n_keep:]
1051
+
1052
+
1053
+ def _mean_scores(scores: list[float]) -> float:
1054
+ """Average finite fold scores."""
1055
+ finite = np.asarray(scores)[np.isfinite(scores)]
1056
+ return float(np.mean(finite)) if len(finite) else float("nan")
1057
+
1058
+
1059
+ def _perturbation_fold(
1060
+ matrix: np.ndarray,
1061
+ delta: np.ndarray,
1062
+ groups: np.ndarray,
1063
+ train: np.ndarray,
1064
+ test: np.ndarray,
1065
+ n_top: int,
1066
+ target_centered: bool,
1067
+ ) -> float:
1068
+ """Fit and score one leakage-safe perturbation fold."""
1069
+ if not train.any() or not test.any():
1070
+ raise EvaluatorError("perturbation fold has an empty train or test side")
1071
+ genes = _select_degs(delta[train], n_top)
1072
+ y_train = delta[train][:, genes]
1073
+ y_test = delta[test][:, genes]
1074
+ scaler = StandardScaler().fit(matrix[train])
1075
+ x_train = scaler.transform(matrix[train])
1076
+ x_test = np.clip(
1077
+ scaler.transform(matrix[test]),
1078
+ -PERTURBATION_TEST_Z_CLIP,
1079
+ PERTURBATION_TEST_Z_CLIP,
1080
+ )
1081
+ predictions = _ridge_predict(x_train, y_train, x_test, groups[train])
1082
+ if target_centered:
1083
+ correlation = compute_target_centered_sample_spearman(y_test, predictions)
1084
+ else:
1085
+ training_mean = np.mean(y_train, axis=0)
1086
+ correlation = compute_residual_sample_spearman(
1087
+ y_test, predictions, training_mean
1088
+ )
1089
+ if not np.isfinite(correlation):
1090
+ raise SubmissionError("a perturbation fold has a degenerate Spearman score")
1091
+ return float(np.clip(correlation, -1.0, 1.0))
1092
+
1093
+
1094
+ def _perturbation_repeat(
1095
+ matrix: np.ndarray,
1096
+ delta: np.ndarray,
1097
+ groups: np.ndarray,
1098
+ partitions: list[tuple[np.ndarray, np.ndarray]],
1099
+ n_top: int,
1100
+ ) -> float:
1101
+ """Score every fold in one perturbation repeat."""
1102
+ return _mean_scores(
1103
+ [
1104
+ _perturbation_fold(matrix, delta, groups, train, test, n_top, False)
1105
+ for train, test in partitions
1106
+ ]
1107
+ )
1108
+
1109
+
1110
+ def _parallel_perturbation_repeats(
1111
+ matrix: np.ndarray,
1112
+ delta: np.ndarray,
1113
+ groups: np.ndarray,
1114
+ repeats: list[list[tuple[np.ndarray, np.ndarray]]],
1115
+ n_top: int,
1116
+ ) -> tuple[float, ...]:
1117
+ """Score independent perturbation repeats across available workers."""
1118
+ workers = (
1119
+ min(OUTER_CV_JOBS, len(repeats))
1120
+ if len(matrix) >= OUTER_PARALLEL_MIN_SAMPLES
1121
+ else 1
1122
+ )
1123
+ if workers == 1:
1124
+ scores = [
1125
+ _perturbation_repeat(matrix, delta, groups, folds, n_top)
1126
+ for folds in repeats
1127
+ ]
1128
+ else:
1129
+ with parallel_backend("loky", inner_max_num_threads=1):
1130
+ scores = Parallel(n_jobs=workers)(
1131
+ delayed(_perturbation_repeat)(matrix, delta, groups, folds, n_top)
1132
+ for folds in repeats
1133
+ )
1134
+ return tuple(float(score) for score in scores)
1135
+
1136
+
1137
+ def _paired_perturbation_scores(
1138
+ matrix: np.ndarray,
1139
+ delta: np.ndarray,
1140
+ labels: pd.DataFrame,
1141
+ n_top: int,
1142
+ ) -> tuple[float, ...]:
1143
+ """Score each frozen subject-grouped CV partition the labels carry."""
1144
+ groups = _subject_groups(labels)
1145
+ repeats = []
1146
+ for column in _fold_columns(labels):
1147
+ folds = labels[column].to_numpy()
1148
+ repeats.append(
1149
+ [(folds != fold, folds == fold) for fold in sorted(np.unique(folds))]
1150
+ )
1151
+ return _parallel_perturbation_repeats(matrix, delta, groups, repeats, n_top)
1152
+
1153
+
1154
+ def _transfer_perturbation_scores(
1155
+ matrix: np.ndarray,
1156
+ delta: np.ndarray,
1157
+ labels: pd.DataFrame,
1158
+ n_top: int,
1159
+ target_centered: bool,
1160
+ ) -> tuple[tuple[float, ...], int]:
1161
+ """Train every response-decoding component on the source disease only."""
1162
+ groups = _subject_groups(labels)
1163
+ split = labels[SPLIT].astype(str).to_numpy()
1164
+ train, test = split == SPLIT_TRAIN, split == SPLIT_TEST
1165
+ metrics = _perturbation_fold(
1166
+ matrix, delta, groups, train, test, n_top, target_centered
1167
+ )
1168
+ return (metrics,), int(test.sum())
1169
+
1170
+
1171
+ def _cartesian_partitions(
1172
+ control_ids: np.ndarray, perturbed_ids: np.ndarray
1173
+ ) -> list[list[tuple[np.ndarray, np.ndarray]]]:
1174
+ """Build six 3-fold specimen-isolated partitions for a 3x3 response grid."""
1175
+ controls = np.unique(control_ids)
1176
+ perturbed = np.unique(perturbed_ids)
1177
+ if len(controls) != 3 or len(perturbed) != 3:
1178
+ raise EvaluatorError(
1179
+ "Cartesian perturbation evaluation requires exactly three controls "
1180
+ "and three perturbed specimens."
1181
+ )
1182
+ repeats = []
1183
+ for assignment in itertools.permutations(perturbed):
1184
+ folds = []
1185
+ for control_id, perturbed_id in zip(controls, assignment):
1186
+ test = (control_ids == control_id) & (perturbed_ids == perturbed_id)
1187
+ train = (control_ids != control_id) & (perturbed_ids != perturbed_id)
1188
+ if test.sum() != 1 or train.sum() != 4:
1189
+ raise EvaluatorError("Cartesian labels do not form a complete 3x3 grid")
1190
+ folds.append((train, test))
1191
+ repeats.append(folds)
1192
+ return repeats
1193
+
1194
+
1195
+ def _cartesian_perturbation_scores(
1196
+ matrix: np.ndarray,
1197
+ delta: np.ndarray,
1198
+ labels: pd.DataFrame,
1199
+ n_top: int,
1200
+ ) -> tuple[float, ...]:
1201
+ """Evaluate all control-to-perturbed permutations without specimen leakage."""
1202
+ groups = _subject_groups(labels)
1203
+ repeats = _cartesian_partitions(
1204
+ labels[CONTROL_ID].astype(str).to_numpy(),
1205
+ labels[PERTURBED_ID].astype(str).to_numpy(),
1206
+ )
1207
+ return _parallel_perturbation_repeats(matrix, delta, groups, repeats, n_top)
1208
+
1209
+
1210
+ def _score_perturbation_task(
1211
+ task: dict,
1212
+ labels: pd.DataFrame,
1213
+ emb: pd.DataFrame,
1214
+ targets: PerturbationTargets,
1215
+ ) -> TaskScore:
1216
+ """Decode one hidden response matrix from submitted baseline embeddings."""
1217
+ if len(labels) != len(targets.delta):
1218
+ raise EvaluatorError("labels.csv and targets.npz have different row counts")
1219
+ matrix = _align(labels, emb)
1220
+ n_top = int(task.get("n_top_de_genes", 0))
1221
+ pairing = str(task.get(PAIRING, labels.get(PAIRING, pd.Series([PAIRED])).iloc[0]))
1222
+ if SPLIT in labels:
1223
+ repeat_scores, n_samples = _transfer_perturbation_scores(
1224
+ matrix,
1225
+ targets.delta,
1226
+ labels,
1227
+ n_top,
1228
+ bool(task.get("target_centered", False)),
1229
+ )
1230
+ elif pairing == CARTESIAN:
1231
+ repeat_scores = _cartesian_perturbation_scores(
1232
+ matrix, targets.delta, labels, n_top
1233
+ )
1234
+ n_samples = len(labels)
1235
+ else:
1236
+ repeat_scores = _paired_perturbation_scores(
1237
+ matrix, targets.delta, labels, n_top
1238
+ )
1239
+ n_samples = len(labels)
1240
+ return TaskScore(
1241
+ task_id=_norm_id(task[TASK_ID]),
1242
+ dataset_id=_norm_id(task[DATASET_ID]),
1243
+ category=str(task["category"]),
1244
+ metric=str(task.get("metric", RESIDUAL_SPEARMAN)),
1245
+ score=float(np.mean(repeat_scores)),
1246
+ n_samples=n_samples,
1247
+ repeat_scores=repeat_scores,
1248
+ )
1249
+
1250
+
1251
+ def score_task(
1252
+ task: dict,
1253
+ labels: pd.DataFrame,
1254
+ emb: pd.DataFrame,
1255
+ targets: PerturbationTargets | None = None,
1256
+ ) -> TaskScore:
1257
+ """Score one task: repeated fold-wise CV, leave-pair-out, or a transfer split.
1258
+
1259
+ Private biology (disease, tissue, and target names) never leaks in.
1260
+ """
1261
+ metric = task["metric"]
1262
+ if task.get("task_type") == PERTURBATION:
1263
+ if metric not in {CENTERED_SPEARMAN, RESIDUAL_SPEARMAN}:
1264
+ raise EvaluatorError(
1265
+ f"perturbation task {task.get(TASK_ID)} needs metric "
1266
+ f"'{CENTERED_SPEARMAN}'"
1267
+ )
1268
+ if targets is None:
1269
+ raise EvaluatorError("perturbation task is missing targets.npz")
1270
+ return _score_perturbation_task(task, labels, emb, targets)
1271
+ if metric not in METRICS:
1272
+ raise EvaluatorError(f"unknown metric '{metric}' for task {task.get(TASK_ID)}")
1273
+ task_type = task["task_type"]
1274
+ matrix = _align(labels, emb)
1275
+ y = labels[LABEL].to_numpy()
1276
+ y_eval = y
1277
+ classes = np.unique(y) if task_type == "classification" else np.array([])
1278
+ groups = _subject_groups(labels)
1279
+
1280
+ if SPLIT in labels.columns:
1281
+ y_eval, preds = _transfer_predict(
1282
+ task_type,
1283
+ y,
1284
+ labels[SPLIT].to_numpy(),
1285
+ matrix,
1286
+ classes,
1287
+ groups,
1288
+ )
1289
+ try:
1290
+ score = METRICS[metric](y_eval, preds, classes)
1291
+ except ValueError as error:
1292
+ raise SubmissionError(f"transfer task not scoreable: {error}") from error
1293
+ repeat_scores = (score,)
1294
+ elif _uses_leave_pair_out(metric, y, groups):
1295
+ score = _leave_pair_out_score(y, groups, matrix, classes)
1296
+ repeat_scores = (score,)
1297
+ else:
1298
+ repeat_scores = _repeated_cv_scores(
1299
+ task_type=task_type,
1300
+ metric=metric,
1301
+ y=y,
1302
+ labels=labels,
1303
+ matrix=matrix,
1304
+ classes=classes,
1305
+ )
1306
+ score = float(np.mean(repeat_scores))
1307
+
1308
+ return TaskScore(
1309
+ task_id=_norm_id(task[TASK_ID]),
1310
+ dataset_id=_norm_id(task[DATASET_ID]),
1311
+ category=str(task["category"]),
1312
+ metric=metric,
1313
+ score=score,
1314
+ n_samples=int(len(y_eval)),
1315
+ repeat_scores=repeat_scores,
1316
+ )
1317
+
1318
+
1319
+ def fetch_manifest(
1320
+ token: str | None = None, revision: str = CURRENT.data
1321
+ ) -> list[dict]:
1322
+ """Download the public ``datasets.yaml`` registry of opaque dataset ids."""
1323
+ from huggingface_hub import hf_hub_download
1324
+
1325
+ path = hf_hub_download(
1326
+ PUBLIC_REPO,
1327
+ MANIFEST_FILENAME,
1328
+ repo_type="dataset",
1329
+ token=token,
1330
+ revision=revision,
1331
+ )
1332
+ with open(path) as handle:
1333
+ data = yaml.safe_load(handle)
1334
+ if isinstance(data, dict):
1335
+ return data.get("datasets", [])
1336
+ return data or []
1337
+
1338
+
1339
+ def public_task_ids_from_manifest(path: str | Path) -> set[str]:
1340
+ """Read the task ids of the published public-task manifest."""
1341
+ with open(path) as handle:
1342
+ return {_norm_id(entry[TASK_ID]) for entry in yaml.safe_load(handle)["tasks"]}
1343
+
1344
+
1345
+ def fetch_public_task_ids(
1346
+ token: str | None = None, revision: str = CURRENT.data
1347
+ ) -> set[str]:
1348
+ """Download the public task ids; none until published or if the fetch fails.
1349
+
1350
+ Losing the Public tags beats blanking every page over a Hub hiccup.
1351
+ """
1352
+ from huggingface_hub import hf_hub_download
1353
+ from huggingface_hub.utils import EntryNotFoundError
1354
+
1355
+ try:
1356
+ path = hf_hub_download(
1357
+ PUBLIC_REPO,
1358
+ PUBLIC_TASKS_MANIFEST,
1359
+ repo_type="dataset",
1360
+ token=token,
1361
+ revision=revision,
1362
+ )
1363
+ return public_task_ids_from_manifest(path)
1364
+ except EntryNotFoundError:
1365
+ return set()
1366
+ except Exception: # noqa: BLE001
1367
+ logging.warning("public task manifest unreadable; no task tagged public")
1368
+ return set()
1369
+
1370
+
1371
+ def manifest_ids(manifest: list[dict], modality: str | None = None) -> set[str]:
1372
+ """Return manifest dataset ids, optionally for one modality."""
1373
+ return {
1374
+ _norm_id(entry["id"])
1375
+ for entry in manifest
1376
+ if modality is None or entry.get("modality") == modality
1377
+ }
1378
+
1379
+
1380
+ def scoreable_tasks(tasks: list[dict], valid_ids: set[str]) -> list[dict]:
1381
+ """Registry tasks whose dataset is in the public manifest; orphans are skipped.
1382
+
1383
+ Shared by ``score_all`` and the leaderboard so both agree on what full
1384
+ coverage means. An orphan (a task on a dataset not yet published) is logged
1385
+ and dropped rather than making full coverage unreachable for everyone.
1386
+ """
1387
+ kept, orphans = [], []
1388
+ for task in tasks:
1389
+ if _norm_id(task[DATASET_ID]) in valid_ids:
1390
+ kept.append(task)
1391
+ else:
1392
+ orphans.append(_norm_id(task[TASK_ID]))
1393
+ if orphans:
1394
+ logging.warning("skipping tasks on unpublished datasets: %s", orphans)
1395
+ return kept
1396
+
1397
+
1398
+ def fetch_tasks_registry(
1399
+ token: str | None = None, revision: str = CURRENT.labels
1400
+ ) -> list[dict]:
1401
+ """Download the private ``tasks.yaml`` registry from the labels repo."""
1402
+ from huggingface_hub import hf_hub_download
1403
+
1404
+ path = hf_hub_download(
1405
+ LABELS_REPO, TASKS_FILENAME, repo_type="dataset", token=token, revision=revision
1406
+ )
1407
+ return load_tasks_registry(path)
1408
+
1409
+
1410
+ def fetch_task_labels(
1411
+ task_id: str, token: str | None = None, revision: str = CURRENT.labels
1412
+ ) -> pd.DataFrame:
1413
+ """Download a task's private ``labels.csv`` from the labels repo."""
1414
+ from huggingface_hub import hf_hub_download
1415
+
1416
+ path = hf_hub_download(
1417
+ LABELS_REPO,
1418
+ f"{task_id}/{LABELS_FILENAME}",
1419
+ repo_type="dataset",
1420
+ token=token,
1421
+ revision=revision,
1422
+ )
1423
+ return load_labels(path)
1424
+
1425
+
1426
+ def fetch_task_targets(
1427
+ task_id: str, token: str | None = None, revision: str = CURRENT.labels
1428
+ ) -> PerturbationTargets:
1429
+ """Download a task's private compressed response matrix."""
1430
+ from huggingface_hub import hf_hub_download
1431
+
1432
+ path = hf_hub_download(
1433
+ LABELS_REPO,
1434
+ f"{task_id}/{TARGETS_FILENAME}",
1435
+ repo_type="dataset",
1436
+ token=token,
1437
+ revision=revision,
1438
+ )
1439
+ return load_targets(path)
1440
+
1441
+
1442
+ def _retry(fn):
1443
+ """Call ``fn``, retrying transient failures with linear backoff."""
1444
+ last: Exception | None = None
1445
+ for attempt in range(FETCH_ATTEMPTS):
1446
+ try:
1447
+ return fn()
1448
+ except Exception as error: # noqa: BLE001
1449
+ last = error
1450
+ if attempt < FETCH_ATTEMPTS - 1:
1451
+ time.sleep(FETCH_BACKOFF * (attempt + 1))
1452
+ raise last
1453
+
1454
+
1455
+ def score_all(
1456
+ path: str | Path,
1457
+ token: str | None = None,
1458
+ *,
1459
+ modality: str | None = None,
1460
+ representation: str = EMBEDDING,
1461
+ datasets: list[dict] | None = None,
1462
+ tasks: list[dict] | None = None,
1463
+ fetch_labels=None,
1464
+ fetch_targets=None,
1465
+ checkpoint_dir: str | Path | None = None,
1466
+ reference: str | Path | None = None,
1467
+ ) -> dict:
1468
+ """Score a submission per task and roll it up into a blind result.
1469
+
1470
+ Unknown dataset ids and bad files raise/record a ``SubmissionError``; a
1471
+ failed fetch or internal error raises ``EvaluatorError`` (our side, not the
1472
+ submitter's). A dataset is embedded once and reused across all its tasks.
1473
+
1474
+ ``EMBEDDING`` columns are put on the unit sphere first, ``GENE_SPACE`` ones
1475
+ keep their scale. A ``reference`` file (same layout as a submission, gene
1476
+ space) is then appended to every sample and the concatenated row put back
1477
+ on the unit sphere.
1478
+ """
1479
+ if representation not in REPRESENTATIONS:
1480
+ raise SubmissionError(
1481
+ f"unknown representation '{representation}'; expected one of "
1482
+ f"{list(REPRESENTATIONS)}"
1483
+ )
1484
+ blocks = load_submission(path)
1485
+ if datasets is None:
1486
+ try:
1487
+ datasets = fetch_manifest(token)
1488
+ except Exception as error: # noqa: BLE001
1489
+ raise EvaluatorError(
1490
+ f"could not load the dataset manifest: {error}"
1491
+ ) from error
1492
+ if modality is not None and modality not in MODALITIES:
1493
+ raise SubmissionError(f"unknown or unavailable modality: {modality}")
1494
+ all_ids = manifest_ids(datasets)
1495
+ valid = manifest_ids(datasets, MODALITIES.get(modality))
1496
+ if modality is not None and not valid:
1497
+ raise SubmissionError(f"unknown or unavailable modality: {modality}")
1498
+
1499
+ unknown = sorted(set(blocks) - all_ids)
1500
+ if unknown:
1501
+ raise SubmissionError(f"unknown dataset_id(s) not in the benchmark: {unknown}")
1502
+ wrong_modality = sorted(set(blocks) - valid)
1503
+ if wrong_modality:
1504
+ raise SubmissionError(
1505
+ f"dataset_id(s) outside the selected modality: {wrong_modality}"
1506
+ )
1507
+
1508
+ if tasks is None:
1509
+ try:
1510
+ tasks = fetch_tasks_registry(token)
1511
+ except Exception as error: # noqa: BLE001
1512
+ raise EvaluatorError(
1513
+ f"could not load the task registry: {error}"
1514
+ ) from error
1515
+ fetch_labels = fetch_labels or fetch_task_labels
1516
+ fetch_targets = fetch_targets or fetch_task_targets
1517
+ checkpoints = (
1518
+ _TaskCheckpoints(checkpoint_dir, path, modality, representation, reference)
1519
+ if checkpoint_dir is not None
1520
+ else None
1521
+ )
1522
+ reference_by_ds = _load_reference(reference) if reference is not None else None
1523
+
1524
+ emb_by_ds, block_error = {}, {}
1525
+ for dataset_id, block in blocks.items():
1526
+ try:
1527
+ frame = _to_embedding_frame(block)
1528
+ if representation == EMBEDDING:
1529
+ frame = _l2_normalize_rows(frame)
1530
+ if reference_by_ds is not None:
1531
+ if dataset_id not in reference_by_ds:
1532
+ raise EvaluatorError(
1533
+ f"reference features lack dataset {dataset_id}"
1534
+ )
1535
+ frame = _concat_reference(frame, reference_by_ds[dataset_id])
1536
+ emb_by_ds[dataset_id] = frame
1537
+ except SubmissionError as error:
1538
+ block_error[dataset_id] = str(error)
1539
+
1540
+ selected_tasks = [
1541
+ task
1542
+ for task in scoreable_tasks(tasks, all_ids)
1543
+ if _norm_id(task[DATASET_ID]) in valid
1544
+ ]
1545
+ outcomes: list[TaskOutcome | None] = [None] * len(selected_tasks)
1546
+ pending: list[_PendingTask] = []
1547
+ for position, task in enumerate(selected_tasks):
1548
+ task_id = _norm_id(task[TASK_ID])
1549
+ dataset_id = _norm_id(task[DATASET_ID])
1550
+ emb = emb_by_ds.get(dataset_id)
1551
+ error = block_error.get(dataset_id)
1552
+ if emb is None and error is None:
1553
+ outcomes[position] = TaskOutcome(task_id, dataset_id, "missing")
1554
+ continue
1555
+ if error is not None:
1556
+ outcomes[position] = TaskOutcome(
1557
+ task_id, dataset_id, "invalid", reason=error
1558
+ )
1559
+ continue
1560
+ try:
1561
+ labels = _retry(lambda: fetch_labels(task_id, token))
1562
+ except Exception as fetch_error: # noqa: BLE001
1563
+ raise EvaluatorError(
1564
+ f"could not load evaluation data for a task: {fetch_error}"
1565
+ ) from fetch_error
1566
+ if reference_by_ds is not None:
1567
+ _check_reference_covers(labels, reference_by_ds[dataset_id], task_id)
1568
+ targets = None
1569
+ if task.get("task_type") == PERTURBATION:
1570
+ try:
1571
+ targets = _retry(lambda: fetch_targets(task_id, token))
1572
+ except Exception as fetch_error: # noqa: BLE001
1573
+ raise EvaluatorError(
1574
+ f"could not load perturbation targets for a task: {fetch_error}"
1575
+ ) from fetch_error
1576
+ fingerprint = _task_fingerprint(task, labels, targets)
1577
+ restored = (
1578
+ checkpoints.load(task_id, fingerprint) if checkpoints is not None else None
1579
+ )
1580
+ if restored is not None:
1581
+ outcomes[position] = restored
1582
+ continue
1583
+ pending.append(_PendingTask(position, task, emb, labels, targets, fingerprint))
1584
+
1585
+ groups: dict[tuple[str, str], list[_PendingTask]] = defaultdict(list)
1586
+ for item in pending:
1587
+ if item.task.get("task_type") == "regression":
1588
+ key = (_norm_id(item.task[DATASET_ID]), _regression_geometry(item.labels))
1589
+ else:
1590
+ key = ("individual", _norm_id(item.task[TASK_ID]))
1591
+ groups[key].append(item)
1592
+
1593
+ for items in groups.values():
1594
+ scores = None
1595
+ if len(items) > 1 and all(
1596
+ item.task.get("task_type") == "regression" for item in items
1597
+ ):
1598
+ try:
1599
+ scores = _score_regression_group(
1600
+ [item.task for item in items],
1601
+ [item.labels for item in items],
1602
+ items[0].emb,
1603
+ )
1604
+ except SubmissionError:
1605
+ scores = None
1606
+ for index, item in enumerate(items):
1607
+ try:
1608
+ score = (
1609
+ scores[index]
1610
+ if scores is not None
1611
+ else score_task(item.task, item.labels, item.emb, item.targets)
1612
+ )
1613
+ outcome = _outcome_from_score(item.task, score)
1614
+ except SubmissionError as scoring_error:
1615
+ outcome = TaskOutcome(
1616
+ _norm_id(item.task[TASK_ID]),
1617
+ _norm_id(item.task[DATASET_ID]),
1618
+ "invalid",
1619
+ reason=str(scoring_error),
1620
+ )
1621
+ outcomes[item.position] = outcome
1622
+ if checkpoints is not None:
1623
+ checkpoints.save(outcome, item.fingerprint)
1624
+
1625
+ if any(outcome is None for outcome in outcomes):
1626
+ raise EvaluatorError("internal error: a task produced no outcome")
1627
+ return _summarize([outcome for outcome in outcomes if outcome is not None])
1628
+
1629
+
1630
+ def _summarize(outcomes: list[TaskOutcome]) -> dict:
1631
+ """Combine per-task outcomes into blind per-category numbers + coverage."""
1632
+ scored = [o.score for o in outcomes if o.status == "scored"]
1633
+ dataset_status = _dataset_status(outcomes)
1634
+ n_scored, n_total = len(scored), len(outcomes)
1635
+ n_ds_scored = sum(1 for status in dataset_status.values() if status == "scored")
1636
+ return {
1637
+ "per_task": scored,
1638
+ "categories": category_means(scored),
1639
+ "invalid": [
1640
+ {"task_id": o.task_id, "dataset_id": o.dataset_id, "reason": o.reason}
1641
+ for o in outcomes
1642
+ if o.status == "invalid"
1643
+ ],
1644
+ "missing": sorted({o.dataset_id for o in outcomes if o.status == "missing"}),
1645
+ "incomplete": sorted(
1646
+ ds for ds, status in dataset_status.items() if status == "incomplete"
1647
+ ),
1648
+ "coverage": n_scored / n_total if n_total else 0.0,
1649
+ "n_scored": n_scored,
1650
+ "n_total": n_total,
1651
+ "full_coverage": n_total > 0 and n_scored == n_total,
1652
+ "n_datasets_scored": n_ds_scored,
1653
+ "n_datasets_total": len(dataset_status),
1654
+ }
1655
+
1656
+
1657
+ def _dataset_status(outcomes: list[TaskOutcome]) -> dict[str, str]:
1658
+ """Blind per-dataset STATUS only (no score): scored / missing / incomplete."""
1659
+ statuses: dict[str, set[str]] = defaultdict(set)
1660
+ for outcome in outcomes:
1661
+ statuses[outcome.dataset_id].add(outcome.status)
1662
+ out = {}
1663
+ for dataset_id in sorted(statuses):
1664
+ seen = statuses[dataset_id]
1665
+ if seen == {"missing"}:
1666
+ out[dataset_id] = "missing"
1667
+ elif seen == {"scored"}:
1668
+ out[dataset_id] = "scored"
1669
+ else:
1670
+ out[dataset_id] = "incomplete"
1671
+ return out
1672
+
1673
+
1674
+ def _cli() -> None:
1675
+ parser = argparse.ArgumentParser(description="Score a PRIMO submission locally.")
1676
+ parser.add_argument("--submission", required=True, help="CSV/TSV/Parquet/NPZ file")
1677
+ parser.add_argument("--modality", required=True, choices=MODALITIES)
1678
+ parser.add_argument(
1679
+ "--representation",
1680
+ default=EMBEDDING,
1681
+ choices=REPRESENTATIONS,
1682
+ help="what the columns are: learned embedding dims, or gene-space values",
1683
+ )
1684
+ parser.add_argument("--token", default=None, help="HF token (else env HF_TOKEN)")
1685
+ parser.add_argument(
1686
+ "--checkpoint-dir",
1687
+ default=None,
1688
+ help="resume completed tasks from this directory",
1689
+ )
1690
+ parser.add_argument(
1691
+ "--reference",
1692
+ default=None,
1693
+ help="gene-space features to concatenate to every sample before scoring",
1694
+ )
1695
+ args = parser.parse_args()
1696
+
1697
+ token = args.token or os.environ.get("HF_TOKEN")
1698
+ result = score_all(
1699
+ args.submission,
1700
+ token,
1701
+ modality=args.modality,
1702
+ representation=args.representation,
1703
+ checkpoint_dir=args.checkpoint_dir,
1704
+ reference=args.reference,
1705
+ )
1706
+ print(
1707
+ f"scored : {result['n_datasets_scored']}/{result['n_datasets_total']} "
1708
+ f"datasets, {result['n_scored']}/{result['n_total']} tasks "
1709
+ f"(full_coverage={result['full_coverage']})"
1710
+ )
1711
+ for category, stats in sorted(result["categories"].items()):
1712
+ print(
1713
+ f" {category:20s} {stats['metric']} = {stats['mean']:.4f} "
1714
+ f"(n={stats['n_tasks']})"
1715
+ )
1716
+ for bad in result["invalid"]:
1717
+ print(f" INVALID [{bad['dataset_id']}]: {bad['reason']}")
1718
+ if result["missing"]:
1719
+ print(f" missing : {result['missing']}")
1720
+ if result["incomplete"]:
1721
+ print(f" incomplete : {result['incomplete']}")
1722
+
1723
+
1724
+ if __name__ == "__main__":
1725
+ _cli()
example_submission.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ dataset_id,sample_id,e0,e1,e2
2
+ d002,SAMPLE_A,0.121,-0.443,0.982
3
+ d002,SAMPLE_B,-0.075,0.310,0.044
4
+ d003,SAMPLE_C,0.512,0.028,-0.157
5
+ d003,SAMPLE_D,-0.301,-0.119,0.663
leaderboard.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Table building for the PRIMO front-end: persisted results + registry -> frames.
2
+
3
+ Kept apart from Gradio and from any I/O so it unit-tests without a network
4
+ (``app.py`` fetches and renders, ``boards.py`` decides what a board *is*).
5
+
6
+ ``ranked_table`` lists only models that covered EVERY task the Elo anchor
7
+ scored, so a partial run cannot win by skipping the hard cohorts.
8
+ ``per_task_table`` keeps partial submissions and is never a ranking.
9
+
10
+ Our baselines rank where their score puts them: pinning one to the bottom would
11
+ hide a foundation model losing to an information-rich expression baseline. Task
12
+ metadata is read defensively, so an old registry yields blank cells rather than
13
+ a broken page.
14
+ """
15
+
16
+ import json
17
+ import math
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ from boards import (
22
+ PUBLIC,
23
+ Board,
24
+ label,
25
+ metric_label,
26
+ modality_label,
27
+ )
28
+ from evaluator import _norm_id
29
+ from results import (
30
+ INSTITUTION,
31
+ IS_AUTHOR_SUBMISSION,
32
+ IS_BASELINE,
33
+ MODEL_NAME,
34
+ PAPER_LINK,
35
+ SUBMISSION_ID,
36
+ display_name,
37
+ split_hvg_name,
38
+ with_author_submission_flag,
39
+ with_baseline_flag,
40
+ with_institution,
41
+ with_paper_link,
42
+ with_submission_id,
43
+ )
44
+ from scipy.optimize import minimize
45
+ from scipy.special import expit
46
+ from scoring import TaskScore, category_means, sort_key
47
+
48
+ EMPTY_RANKED_COLUMNS = ["Model"]
49
+ EMPTY_PER_TASK_COLUMNS = ["Task"]
50
+ SCORE_DECIMALS = 3
51
+ ELO_START = 1000.0
52
+ ELO_SCALE = 400 / math.log(10)
53
+ ELO_REGULARIZATION = 0.1
54
+ ELO_ANCHOR_MODEL = "HVG-1200-genes"
55
+
56
+
57
+ class ModelCell(str):
58
+ """String-compatible model label with paper-link and chip metadata.
59
+
60
+ ``is_hvg`` marks a ``<model> + HVG`` entry, whose label is the parent name.
61
+ ``is_baseline`` marks one of our baselines, whose label keeps its
62
+ ``(baseline)`` tag so it never merges with a model of the same name. The page
63
+ shows both flags as chips.
64
+ """
65
+
66
+ paper_link: str
67
+ is_author_submission: bool
68
+ is_hvg: bool
69
+ is_baseline: bool
70
+
71
+ def __new__(
72
+ cls,
73
+ label: str,
74
+ paper_link: str,
75
+ is_author_submission: bool,
76
+ is_hvg: bool = False,
77
+ is_baseline: bool = False,
78
+ ) -> "ModelCell":
79
+ cell = super().__new__(cls, label)
80
+ cell.paper_link = paper_link
81
+ cell.is_author_submission = is_author_submission
82
+ cell.is_hvg = is_hvg
83
+ cell.is_baseline = is_baseline
84
+ return cell
85
+
86
+
87
+ class TaskCell(str):
88
+ """String-compatible task title flagged when its labels are public."""
89
+
90
+ is_public: bool
91
+
92
+ def __new__(cls, title: str, is_public: bool) -> "TaskCell":
93
+ cell = super().__new__(cls, title)
94
+ cell.is_public = is_public
95
+ return cell
96
+
97
+
98
+ def _task_cell(task: dict) -> TaskCell:
99
+ """The task's title, or its id when it has none."""
100
+ return TaskCell(
101
+ str(task.get("title") or task.get("task_id", "")), bool(task.get(PUBLIC))
102
+ )
103
+
104
+
105
+ def _round(value: float | None) -> float | None:
106
+ """Round for display; anything non-finite becomes a blank cell, not ``-inf``."""
107
+ if value is None or not math.isfinite(value):
108
+ return None
109
+ return round(value, SCORE_DECIMALS)
110
+
111
+
112
+ def _ordered(df: pd.DataFrame) -> pd.DataFrame:
113
+ """Normalise the credit columns and add the tiebreak key rows are ordered by.
114
+
115
+ ``_submission_key`` falls back to the timestamp for rows written before
116
+ ``submission_id`` existed, so legacy rows still order deterministically.
117
+ """
118
+ flagged = with_submission_id(
119
+ with_paper_link(
120
+ with_author_submission_flag(with_institution(with_baseline_flag(df)))
121
+ )
122
+ )
123
+ if flagged.empty:
124
+ return flagged
125
+ key = flagged[SUBMISSION_ID].mask(
126
+ flagged[SUBMISSION_ID] == "", "legacy:" + flagged["submitted_at"].astype(str)
127
+ )
128
+ return flagged.assign(
129
+ _submission_key=key, _task=flagged["task_id"].map(_norm_id)
130
+ ).sort_values(["submitted_at", "_submission_key"])
131
+
132
+
133
+ def latest_only(df: pd.DataFrame) -> pd.DataFrame:
134
+ """Keeps the most recent result for each ``(name, is_baseline, task)`` slot.
135
+
136
+ An entry is the accumulated set of its slots, not one submission: a later
137
+ submission fills the tasks it covers and leaves the rest standing.
138
+
139
+ Parameters
140
+ ----------
141
+ df : pd.DataFrame
142
+ Persisted result rows.
143
+
144
+ Returns
145
+ -------
146
+ pd.DataFrame
147
+ One row per slot, credit columns normalised.
148
+ """
149
+ ordered = _ordered(df)
150
+ if ordered.empty:
151
+ return ordered
152
+ return (
153
+ ordered.groupby([MODEL_NAME, IS_BASELINE, "_task"], as_index=False)
154
+ .tail(1)
155
+ .drop(columns=["_submission_key", "_task"])
156
+ )
157
+
158
+
159
+ def submission_counts(df: pd.DataFrame) -> dict[tuple[str, bool], int]:
160
+ """Counts how many times each entry's most-rewritten task was submitted.
161
+
162
+ Parameters
163
+ ----------
164
+ df : pd.DataFrame
165
+ Persisted result rows.
166
+
167
+ Returns
168
+ -------
169
+ dict[tuple[str, bool], int]
170
+ Count keyed by ``(model_name, is_baseline)``.
171
+ """
172
+ flagged = with_baseline_flag(df)
173
+ if flagged.empty:
174
+ return {}
175
+ per_task = (
176
+ flagged.assign(_task=flagged["task_id"].map(_norm_id))
177
+ .groupby([MODEL_NAME, IS_BASELINE, "_task"])
178
+ .size()
179
+ )
180
+ return {
181
+ (str(model), bool(is_baseline)): int(count)
182
+ for (model, is_baseline), count in per_task.groupby(
183
+ level=[MODEL_NAME, IS_BASELINE]
184
+ )
185
+ .max()
186
+ .items()
187
+ }
188
+
189
+
190
+ def scores_from_rows(rows: pd.DataFrame, by_id: dict[str, dict]) -> list[TaskScore]:
191
+ """Rebuilds one submission's task scores from persisted rows and the registry.
192
+
193
+ Parameters
194
+ ----------
195
+ rows : pd.DataFrame
196
+ Result rows of a single entry.
197
+ by_id : dict[str, dict]
198
+ Task registry keyed by task id.
199
+
200
+ Returns
201
+ -------
202
+ list[TaskScore]
203
+ One score per row, joined by ``task_id``. Retired tasks are dropped.
204
+ """
205
+ out = []
206
+ for _, r in rows.iterrows():
207
+ task_id = _norm_id(r["task_id"])
208
+ meta = by_id.get(task_id)
209
+ if meta is None:
210
+ continue
211
+ out.append(
212
+ TaskScore(
213
+ task_id=task_id,
214
+ dataset_id=_norm_id(meta["dataset_id"]),
215
+ category=str(meta["category"]),
216
+ metric=str(meta["metric"]),
217
+ score=float(r["score"]),
218
+ n_samples=0,
219
+ )
220
+ )
221
+ return out
222
+
223
+
224
+ def _board_registry(by_id: dict[str, dict], board: Board) -> dict[str, dict]:
225
+ return {tid: meta for tid, meta in by_id.items() if tid in board.task_ids}
226
+
227
+
228
+ def _stored_repeat_scores(row: pd.Series) -> tuple[float, ...]:
229
+ """Read finite persisted repeat scores, or return an empty tuple."""
230
+ raw = row.get("repeat_scores")
231
+ if isinstance(raw, str) and raw:
232
+ try:
233
+ parsed = json.loads(raw)
234
+ if not isinstance(parsed, list) or any(
235
+ isinstance(value, bool) or not isinstance(value, (int, float))
236
+ for value in parsed
237
+ ):
238
+ return ()
239
+ values = tuple(float(value) for value in parsed)
240
+ if values and all(math.isfinite(value) for value in values):
241
+ return values
242
+ except (
243
+ TypeError,
244
+ ValueError,
245
+ OverflowError,
246
+ RecursionError,
247
+ json.JSONDecodeError,
248
+ ):
249
+ pass
250
+ return ()
251
+
252
+
253
+ def _repeat_scores(row: pd.Series) -> tuple[float, ...]:
254
+ """Read persisted repeat scores, falling back to one legacy task score."""
255
+ values = _stored_repeat_scores(row)
256
+ if values:
257
+ return values
258
+ return (float(row["score"]),)
259
+
260
+
261
+ def _paired_splits(
262
+ left: tuple[float, ...], right: tuple[float, ...]
263
+ ) -> list[tuple[float, ...]]:
264
+ """Lines two entries up split by split; the shorter record plays its mean.
265
+
266
+ Pairwise, never board-wide: aligning on the longest record on the board
267
+ would flatten two three-split entries the day a six-split one is published.
268
+ """
269
+ splits = max(len(left), len(right))
270
+ return [
271
+ column if len(column) == splits else (float(np.mean(column)),) * splits
272
+ for column in (left, right)
273
+ ]
274
+
275
+
276
+ def _is_anchor(model: str, is_baseline: bool) -> bool:
277
+ """Is this the Elo anchor? Matched on ``(name, is_baseline)``, not the name."""
278
+ return is_baseline and model == ELO_ANCHOR_MODEL
279
+
280
+
281
+ def _credit_row(rows: pd.DataFrame) -> pd.Series:
282
+ """The most recent slot carrying an institution or a paper link."""
283
+ credited = rows[(rows[INSTITUTION] != "") | (rows[PAPER_LINK] != "")]
284
+ return (credited if not credited.empty else rows).iloc[-1]
285
+
286
+
287
+ def _entries(df: pd.DataFrame, by_id: dict[str, dict], board: Board) -> list[dict]:
288
+ """Rank-ready entries for every model covering the board's scored tasks.
289
+
290
+ The anchor decides which tasks count. Without it every finitely-scored task
291
+ counts and the Elo scale falls back to mean-centred, decided per board.
292
+ """
293
+ scoped = _board_registry(by_id, board)
294
+ latest = latest_only(df)
295
+ if latest.empty:
296
+ return []
297
+ on_board = latest[latest["task_id"].map(_norm_id).isin(list(board.task_ids))]
298
+ anchor_rows = on_board[
299
+ [
300
+ _is_anchor(str(model), bool(flag))
301
+ for model, flag in zip(on_board[MODEL_NAME], on_board[IS_BASELINE])
302
+ ]
303
+ ]
304
+ ranked_rows = on_board if anchor_rows.empty else anchor_rows
305
+ scored_tasks = {
306
+ _norm_id(row["task_id"])
307
+ for _, row in ranked_rows.iterrows()
308
+ if math.isfinite(float(row["score"]))
309
+ }
310
+ if not scored_tasks:
311
+ return []
312
+
313
+ counts = submission_counts(df)
314
+ entries = []
315
+ for (model, is_baseline), rows in latest.groupby([MODEL_NAME, IS_BASELINE]):
316
+ submitted = rows["submitted_at"].max()
317
+ credits = _credit_row(rows)
318
+ task_rows = rows[
319
+ rows["task_id"].map(_norm_id).isin(list(scored_tasks))
320
+ & rows["score"].map(lambda score: math.isfinite(float(score)))
321
+ ]
322
+ if len(task_rows["task_id"].map(_norm_id).unique()) != len(scored_tasks):
323
+ continue
324
+ categories = category_means(scores_from_rows(task_rows, scoped))
325
+ task_scores = {
326
+ _norm_id(row["task_id"]): float(row["score"])
327
+ for _, row in task_rows.iterrows()
328
+ }
329
+ repeat_scores = {
330
+ _norm_id(row["task_id"]): _repeat_scores(row)
331
+ for _, row in task_rows.iterrows()
332
+ }
333
+ entries.append(
334
+ {
335
+ "model_name": model,
336
+ "institution": credits[INSTITUTION],
337
+ "paper_link": credits[PAPER_LINK],
338
+ "is_author_submission": bool(credits[IS_AUTHOR_SUBMISSION]),
339
+ "submission_id": credits[SUBMISSION_ID],
340
+ "submitted_at": submitted,
341
+ "n_submissions": counts.get((str(model), bool(is_baseline)), 1),
342
+ "categories": categories,
343
+ "rank": sort_key(categories),
344
+ "is_baseline": bool(is_baseline),
345
+ "task_scores": task_scores,
346
+ "repeat_scores": repeat_scores,
347
+ }
348
+ )
349
+ if not entries:
350
+ return entries
351
+ shared = set.intersection(*(set(entry["task_scores"]) for entry in entries))
352
+ _comparison_stats(entries, shared)
353
+ return sorted(entries, key=lambda entry: (-entry["elo"], -entry["rank"]))
354
+
355
+
356
+ def _comparison_stats(entries: list[dict], task_ids: set[str]) -> None:
357
+ """Add Bradley-Terry Elo and mean rank to ``entries`` in place.
358
+
359
+ Battles are played split by split, a pair's splits sharing weight one per
360
+ task. ``ELO_ANCHOR_MODEL`` is pinned during the fit, not shifted after,
361
+ because the ridge penalty shrinks toward whichever model is pinned. Entries
362
+ are keyed by position: two of them can share a name.
363
+ """
364
+ if not entries:
365
+ return
366
+ indices = list(range(len(entries)))
367
+ ranks: dict[int, list[float]] = {index: [] for index in indices}
368
+ battles: list[tuple[int, int, float, float]] = []
369
+ for task_id in sorted(task_ids):
370
+ scores = [entry["task_scores"][task_id] for entry in entries]
371
+ ordered = sorted(
372
+ indices, key=lambda index: (-scores[index], entries[index]["model_name"])
373
+ )
374
+ for index in indices:
375
+ tied_positions = [
376
+ rank
377
+ for rank, other in enumerate(ordered, start=1)
378
+ if scores[other] == scores[index]
379
+ ]
380
+ ranks[index].append(sum(tied_positions) / len(tied_positions))
381
+ for index in indices:
382
+ for opponent in indices[index + 1 :]:
383
+ left, right = _paired_splits(
384
+ entries[index]["repeat_scores"][task_id],
385
+ entries[opponent]["repeat_scores"][task_id],
386
+ )
387
+ for ours, theirs in zip(left, right):
388
+ outcome = 1.0 if ours > theirs else (0.5 if ours == theirs else 0.0)
389
+ battles.append((index, opponent, outcome, 1 / len(left)))
390
+
391
+ anchor = next(
392
+ (
393
+ index
394
+ for index, entry in enumerate(entries)
395
+ if _is_anchor(entry["model_name"], entry["is_baseline"])
396
+ ),
397
+ None,
398
+ )
399
+ pinned = len(entries) - 1 if anchor is None else anchor
400
+ battle_table = np.array(battles, dtype=float).reshape(-1, 4)
401
+ lefts, rights = battle_table[:, 0].astype(int), battle_table[:, 1].astype(int)
402
+ weights = battle_table[:, 3]
403
+ weighted_outcomes = weights * battle_table[:, 2]
404
+
405
+ def objective(theta_free: np.ndarray) -> tuple[float, np.ndarray]:
406
+ theta = np.insert(theta_free, pinned, 0.0)
407
+ delta = theta[lefts] - theta[rights]
408
+ loss = np.sum(weights * np.logaddexp(0.0, delta) - weighted_outcomes * delta)
409
+ error = weights * expit(delta) - weighted_outcomes
410
+ gradient = np.bincount(lefts, error, len(entries)) - np.bincount(
411
+ rights, error, len(entries)
412
+ )
413
+ loss += ELO_REGULARIZATION * float(np.dot(theta, theta)) / 2
414
+ gradient += ELO_REGULARIZATION * theta
415
+ return float(loss), np.delete(gradient, pinned)
416
+
417
+ if battles and len(entries) > 1:
418
+ fit = minimize(
419
+ objective,
420
+ np.zeros(len(entries) - 1),
421
+ jac=True,
422
+ method="L-BFGS-B",
423
+ )
424
+ theta = np.insert(fit.x, pinned, 0.0) if fit.success else np.zeros(len(entries))
425
+ if anchor is None:
426
+ theta -= theta.mean()
427
+ ratings = ELO_START + ELO_SCALE * theta
428
+ else:
429
+ ratings = np.full(len(entries), ELO_START)
430
+ for index, entry in enumerate(entries):
431
+ entry["elo"] = float(ratings[index])
432
+ entry["mean_rank"] = (
433
+ float(np.mean(ranks[index])) if ranks[index] else float("nan")
434
+ )
435
+
436
+
437
+ def ranked_table(
438
+ df: pd.DataFrame, by_id: dict[str, dict], board: Board
439
+ ) -> pd.DataFrame:
440
+ """Ranks the models that covered every task of ``board`` the anchor scored.
441
+
442
+ Parameters
443
+ ----------
444
+ df : pd.DataFrame
445
+ Persisted result rows.
446
+ by_id : dict[str, dict]
447
+ Task registry keyed by task id.
448
+ board : Board
449
+ Board to rank.
450
+
451
+ Returns
452
+ -------
453
+ pd.DataFrame
454
+ One row per ranked model, one column per task category in its native
455
+ metric. Empty when no model has full coverage.
456
+ """
457
+ entries = _entries(df, by_id, board)
458
+ if not entries:
459
+ return pd.DataFrame(columns=EMPTY_RANKED_COLUMNS)
460
+
461
+ columns = sorted({cat for e in entries for cat in e["categories"]})
462
+ metric_of: dict[str, str] = {}
463
+ for entry in entries:
464
+ for cat, stats in entry["categories"].items():
465
+ metric_of.setdefault(cat, stats["metric"])
466
+
467
+ rows = []
468
+ for position, entry in enumerate(entries, start=1):
469
+ categories = entry["categories"]
470
+ name, is_hvg = split_hvg_name(str(entry["model_name"]))
471
+ model = ModelCell(
472
+ display_name(name, entry["is_baseline"]),
473
+ entry["paper_link"],
474
+ entry["is_author_submission"],
475
+ is_hvg,
476
+ entry["is_baseline"],
477
+ )
478
+ row = {
479
+ "Rank": position,
480
+ "Model": model,
481
+ "Institution": entry["institution"] or None,
482
+ "Elo": round(entry["elo"]),
483
+ "Mean rank": _round(entry["mean_rank"]),
484
+ # Blank for baselines and ``+ HVG`` entries: we republish and re-sync
485
+ # those, so their count measures our release cadence, not anyone tuning.
486
+ "Submissions count": (
487
+ None if entry["is_baseline"] or is_hvg else entry["n_submissions"]
488
+ ),
489
+ }
490
+ for cat in columns:
491
+ header = f"{label(cat)} ({metric_label(metric_of[cat])})"
492
+ row[header] = _round(categories[cat]["mean"]) if cat in categories else None
493
+ rows.append(row)
494
+ table = pd.DataFrame(rows)
495
+ table["Submissions count"] = table["Submissions count"].astype("Int64")
496
+ return table
497
+
498
+
499
+ RESERVED_COLUMNS = frozenset({"Task", "Family", "Area", "Modality", "Metric", "Best"})
500
+
501
+
502
+ def per_task_table(
503
+ df: pd.DataFrame, by_id: dict[str, dict], board: Board
504
+ ) -> pd.DataFrame:
505
+ """Builds the per-task table: one row per task, one column per model.
506
+
507
+ Partial submissions are kept, unlike in ``ranked_table``. Model names become
508
+ column names, hence ``RESERVED_COLUMNS``: a model called ``Task`` would
509
+ overwrite every task title.
510
+
511
+ Parameters
512
+ ----------
513
+ df : pd.DataFrame
514
+ Persisted result rows.
515
+ by_id : dict[str, dict]
516
+ Task registry keyed by task id.
517
+ board : Board
518
+ Board whose tasks the table is restricted to.
519
+
520
+ Returns
521
+ -------
522
+ pd.DataFrame
523
+ Task rows and model columns ordered by mean over the board, plus a
524
+ ``Best`` column naming the leading model.
525
+ """
526
+ scoped = _board_registry(by_id, board)
527
+ if df.empty or not scoped:
528
+ return pd.DataFrame(columns=EMPTY_PER_TASK_COLUMNS)
529
+
530
+ scores: dict[tuple[str, str], float] = {}
531
+ repeat_sd: dict[tuple[str, str], tuple[float, int]] = {}
532
+ for _, r in latest_only(df).iterrows():
533
+ task_id = _norm_id(r["task_id"])
534
+ if task_id in scoped:
535
+ model = display_name(str(r[MODEL_NAME]), bool(r[IS_BASELINE]))
536
+ scores[(task_id, model)] = float(r["score"])
537
+ repeats = _stored_repeat_scores(r)
538
+ if len(repeats) > 1:
539
+ repeat_sd[(task_id, model)] = (float(np.std(repeats)), len(repeats))
540
+ if not scores:
541
+ return pd.DataFrame(columns=EMPTY_PER_TASK_COLUMNS)
542
+
543
+ by_model: dict[str, list[float]] = {}
544
+ for (_, model), score in scores.items():
545
+ by_model.setdefault(model, []).append(score)
546
+ models = sorted(by_model, key=lambda m: -sum(by_model[m]) / len(by_model[m]))
547
+
548
+ rows = []
549
+ displayed_sd: dict[tuple[int, str], tuple[float, int]] = {}
550
+ families = {str(t.get("category", "")) for t in scoped.values()}
551
+ areas = {str(t.get("therapeutic_area", "")) for t in scoped.values()}
552
+ for task_id, task in sorted(scoped.items()):
553
+ row = {"Task": _task_cell(task)}
554
+ if len(families) > 1:
555
+ row["Family"] = label(str(task.get("category", "")))
556
+ if len(areas) > 1:
557
+ row["Area"] = str(task.get("therapeutic_area", ""))
558
+ row["Metric"] = metric_label(str(task.get("metric", "")))
559
+ present = {m: scores[(task_id, m)] for m in models if (task_id, m) in scores}
560
+ for model in models:
561
+ row[model] = _round(present.get(model))
562
+ if (task_id, model) in repeat_sd:
563
+ displayed_sd[(len(rows), model)] = repeat_sd[(task_id, model)]
564
+ row["Best"] = max(present, key=present.get) if present else None
565
+ rows.append(row)
566
+ table = pd.DataFrame(rows)
567
+ table.attrs["repeat_standard_deviations"] = displayed_sd
568
+ return table
569
+
570
+
571
+ TASK_COLUMNS = [
572
+ "Task",
573
+ "Predicts",
574
+ "Family",
575
+ "Area",
576
+ "Modality",
577
+ "Disease",
578
+ "Tissue",
579
+ "Patients",
580
+ "Metric",
581
+ ]
582
+
583
+
584
+ def tasks_table(tasks: list[dict]) -> pd.DataFrame:
585
+ """Builds the Tasks page table. Provenance is never a column.
586
+
587
+ Parameters
588
+ ----------
589
+ tasks : list[dict]
590
+ Registry tasks.
591
+
592
+ Returns
593
+ -------
594
+ pd.DataFrame
595
+ One row per task, sorted by task id, with ``TASK_COLUMNS`` as columns.
596
+ """
597
+ if not tasks:
598
+ return pd.DataFrame(columns=TASK_COLUMNS)
599
+ rows = []
600
+ for task in sorted(tasks, key=lambda t: str(t.get("task_id", ""))):
601
+ rows.append(
602
+ {
603
+ "Task": _task_cell(task),
604
+ "Predicts": str(task.get("description") or ""),
605
+ "Family": label(str(task.get("category", ""))),
606
+ "Area": str(task.get("therapeutic_area", "")),
607
+ "Modality": modality_label(str(task.get("modality", ""))),
608
+ "Disease": ", ".join(str(d) for d in task.get("diseases") or []),
609
+ "Tissue": str(task.get("tissue", "")),
610
+ "Patients": task.get("n_subjects") or task.get("n_samples"),
611
+ "Metric": metric_label(str(task.get("metric", ""))),
612
+ }
613
+ )
614
+ return pd.DataFrame(rows, columns=TASK_COLUMNS)
615
+
616
+
617
+ REPOSITORY_OF_PREFIX = {"GSE": "NCBI GEO", "E-MTAB": "EMBL-EBI ArrayExpress"}
618
+
619
+
620
+ def source_repositories(tasks: list[dict]) -> list[str]:
621
+ """Lists the public archives the cohorts come from, never the accessions.
622
+
623
+ An accession like ``GSE000000`` would let anyone download the study and
624
+ recover the labels. The registry's ``license`` field stays out too: one
625
+ unvalidated line per dataset, and some cohorts pool three studies under it.
626
+
627
+ Parameters
628
+ ----------
629
+ tasks : list[dict]
630
+ Registry tasks, each optionally carrying a ``sources`` list.
631
+
632
+ Returns
633
+ -------
634
+ list[str]
635
+ Archive names, sorted.
636
+ """
637
+ repositories: set[str] = set()
638
+ for task in tasks:
639
+ for source in task.get("sources") or []:
640
+ for prefix, repository in REPOSITORY_OF_PREFIX.items():
641
+ if str(source).upper().startswith(prefix):
642
+ repositories.add(repository)
643
+ return sorted(repositories)
pages/about.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## What PRIMO measures
2
+
3
+ **Can a foundation model turn a patient's omics data into an embedding that
4
+ predicts meaningful outcomes?**
5
+
6
+ You embed each patient. We fit a fixed probe on top and check whether that
7
+ embedding predicts a real clinical outcome. You never see the labels, so you
8
+ cannot tune to them, and when a score comes out high the credit belongs to the
9
+ *embedding*.
10
+
11
+ Plenty of benchmarks already score reconstruction. Far fewer ask whether the
12
+ representation is any use in the clinic, and that is the gap PRIMO tries to fill.
13
+
14
+ ## How a submission is scored
15
+
16
+ 1. **You send embeddings, never predictions**: one vector per baseline sample.
17
+ 2. **We fit the probe**: ridge for scalar regression, logistic for
18
+ classification, or multi-output ridge regression for perturbation
19
+ responses. Learned embedding rows are scaled to unit length first, since an
20
+ embedding dimension has no unit; gene-space baselines keep their log-normalized row
21
+ scale. Feature standardisation is fit on each outer training fold, and probe
22
+ hyperparameters are selected with subject-grouped inner cross-validation.
23
+ 3. **Scores always hold out whole subjects.** Most tasks use subject-grouped
24
+ cross-validation: we average the fold-level metric over frozen partitions,
25
+ three folds ten times over.
26
+ Binary tasks with fewer than 30 subjects use leave-pair-out: each
27
+ positive-negative pair of subjects is held out in turn, the probe is fit on
28
+ everyone else, and the AUROC is the share of held-out sample pairs ranked
29
+ correctly. These tasks have one score, so no ±.
30
+ Transfer tasks use a fixed split instead: train on one source cohort, test
31
+ on the target cohort. Perturbation tasks decode hidden post-minus-pre
32
+ expression responses.
33
+ 4. **Each task family keeps its own metric.** AUROC, Pearson r and centered
34
+ Spearman never share a category column.
35
+
36
+ The probe is identical for everyone, which is what makes the numbers comparable:
37
+ what changes between two rows is the representation behind them. The rows marked
38
+ `(baseline)` are our own submissions. In particular, `HVG-1200-genes` is the
39
+ **HVG-1200 log-normalized linear baseline**: bulk samples use log2(CPM+1), while
40
+ single-cell profiles use per-cell log2(CP10K+1) followed by sample-level mean
41
+ pooling. We retain the 1,200 genes with highest dataset-wide variance, apply
42
+ train-fold standardisation, and fit the same tuned linear probe. Gene selection
43
+ never reads a label, but it is fit over every sample of a dataset. This is an
44
+ information-rich transductive reference, not a naïve raw-count baseline or an
45
+ expected lower bound for a foundation model.
46
+
47
+ Every submission also gets a second row, **`<model> + HVG`**: the same
48
+ embedding concatenated with the `HVG-1200-genes` features of each sample. The
49
+ embedding is scaled to unit length, the 1,200 log-normalized genes are appended,
50
+ and the concatenated row is scaled to unit length again before the same probe.
51
+ It shows whether a model adds information that expression alone does not carry.
52
+ These rows are shown by default; set **+ HVG models** to **Hide** to hide them,
53
+ which re-ranks the board and refits Elo without them.
54
+
55
+ Elo orders the rows. Each task family keeps its own column, in its own metric,
56
+ and no column averages across metrics.
57
+
58
+ **Elo** is pinned to our HVG-1200 log-normalized linear baseline, which sits at
59
+ exactly 1000. It turns every paired repeat into a win, draw, or loss and
60
+ discards the score margin, so a tiny win counts like a large one. A difference
61
+ of only a few Elo points is effectively a tie, not evidence that one
62
+ representation is meaningfully superior. Use mean rank, the family columns, and
63
+ per-task scores to interpret close rows. +400 Elo is a 10-to-1 expected win
64
+ rate. The scale does not move when a new model is submitted, so ratings stay
65
+ comparable over time, but only within one board, since each board is fitted on
66
+ its own tasks.
67
+
68
+ **Subm.** (submissions count) is the most times any *single* task result of
69
+ that model was resubmitted, not how many files were uploaded. A model that
70
+ covered twenty tasks across twenty submissions reads 1, because each task was
71
+ submitted once; a model that sent the same task five times reads 5. It shows
72
+ n/a for our baselines and the `+ HVG` rows, which we republish ourselves. A
73
+ published result can be corrected, so a 2 or 3 is ordinary; a high number on
74
+ one task is worth a second look.
75
+
76
+ ## How the results are organised
77
+
78
+ A **board** is a self-contained leaderboard over a slice of the benchmark: the
79
+ whole modality, one therapeutic area, one task family, or an area and a family
80
+ together. Pick it with the pills of the **Leaderboard** page.
81
+
82
+ A board ranks only the models that covered **all of its tasks**. Skipping
83
+ Infectious diseases therefore costs you nothing on the Oncology board.
84
+
85
+ **Boards never cross modalities.** An AUROC on bulk RNA and an AUROC on
86
+ single-cell are not measuring the same thing, so we keep them apart.
87
+
88
+ ## What stays hidden
89
+
90
+ The evaluation only stays honest while the labels stay ours, which prevents tuning
91
+ the model directly on the labels. We still publish the omics data, to compute
92
+ embeddings.
93
+
94
+ The exception is the tasks tagged **Public**: their labels are downloadable from
95
+ the [PRIMO dataset](https://huggingface.co/datasets/PRIMOmics/primo), so a model
96
+ may have been trained on them.
97
+
98
+ | Disclosure | What it covers |
99
+ |---|---|
100
+ | **Per task** | disease, tissue, therapeutic area, what is predicted, class names, patient count, metric |
101
+ | **In aggregate** | the public archives the cohorts sit in |
102
+ | **Public tasks** | labels, evaluation folds, post-treatment responses, and the `dataset_id` each is scored on, which can make its source study findable |
103
+ | **Never** | study accessions and, for every other task, which cohort is behind which `dataset_id`, labels, pairing identifiers and post-treatment responses |
104
+
105
+ Every cohort comes from a published study deposited in {repositories}. We name
106
+ the archive but never the accession, as publishing the study id behind a
107
+ `dataset_id` would make the dataset easy to reconstitute.
108
+
109
+ ## Cite PRIMO
110
+
111
+ Cite this website and the board version that you used. The **Cite** button, at
112
+ the top of every page, gives the BibTeX entry.
113
+
114
+ Want to add a cohort, propose a task on the cohorts already here, open a new
115
+ modality or therapeutic area, or work with us on the methodology? The
116
+ **Contribute** tab explains how.
117
+
118
+ ## Links
119
+
120
+ - 🌐 **Website**: [primomics.org](http://primomics.org/)
121
+ - 📦 **Data**: [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/primo)
122
+ - 📄 **Paper**: [OpenReview](https://openreview.net/forum?id=v2SA8gHwqo)
123
+ - 💬 **Questions**: [karim.elkanbi@scientalab.com](mailto:karim.elkanbi@scientalab.com)
pages/contribute.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PRIMO is an open benchmark, and it is **deliberately incomplete**. The
2
+ **Leaderboard** page shows how far it reaches today, and its last panel names
3
+ a few of the gaps.
4
+
5
+ PRIMO was started by **Scienta** and **Hugging Face**. The intent is community governance; how
6
+ that actually works is part of what the first partners will define.
7
+
8
+ ## Add a cohort
9
+
10
+ PRIMO has no shortage of models. What is hard to find is **an omics cohort with
11
+ patient-level clinical labels**, whether that is drug response, a severity score
12
+ or a molecular subtype. If you hold one, or know a group that does, tell us:
13
+
14
+ - **modality** and roughly how many patients
15
+ - **what is labelled**, and how many patients carry that label
16
+ - **disease and tissue**
17
+ - **licence**, and whether the data can be redistributed
18
+
19
+ [Propose a cohort](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20cohort%20proposal&body=Modality%20and%20rough%20patient%20count%3A%0A%0AWhat%20is%20labelled%2C%20and%20how%20many%20patients%20carry%20that%20label%3A%0A%0ADisease%20and%20tissue%3A%0A%0ALicence%2C%20and%20whether%20the%20data%20can%20be%20redistributed%3A%0A)
20
+
21
+ There is no formal acceptance process yet. We are writing it with the first
22
+ partners, and we would rather admit that than invent rules nobody has agreed to.
23
+ What you will get today is a real answer and a technical conversation.
24
+
25
+ ## Add a task, without bringing any data
26
+
27
+ This one costs you nothing but an idea. Ask **a better question of the cohorts
28
+ already here**: a label we are not predicting, a transfer split nobody has tried,
29
+ or a metric that would say more than the one we use. Methodology counts here as
30
+ much as data does.
31
+
32
+ [Propose a task](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20task%20proposal&body=Task%20I%20would%20add%3A%0A%0AWhich%20cohorts%20already%20in%20PRIMO%20it%20uses%3A%0A%0AWhat%20is%20predicted%2C%20and%20with%20which%20metric%3A%0A%0AWhy%20it%20says%20something%20the%20current%20tasks%20do%20not%3A%0A)
33
+
34
+ ## Add a modality or a therapeutic area
35
+
36
+ The last panel of the **Leaderboard** page names a few omics layers and clinical
37
+ areas PRIMO does not reach yet. Any other one is welcome too. Read them as gaps
38
+ waiting for someone rather than as a roadmap we are already working through. Opening one takes a single cohort with clinical
39
+ labels, and not a whole atlas.
40
+
41
+ [Propose a modality or an area](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20modality%20or%20area%20proposal&body=Modality%20or%20area%20I%20would%20add%3A%0A%0AWhat%20I%20can%20bring%20%28cohort%2C%20labels%2C%20licence%29%3A%0A)
42
+
43
+ ## Become a founding partner
44
+
45
+ Founding partners shape PRIMO rather than just appear on it: a voice on the
46
+ methodology, co-signature on what we publish, and early access to the results.
47
+
48
+ [Talk to us about partnership](mailto:karim.elkanbi@scientalab.com?subject=PRIMO%20founding%20partner&body=Organisation%3A%0A%0AWhat%20we%20would%20bring%20to%20PRIMO%3A%0A%0AWhat%20we%20would%20want%20from%20it%3A%0A)
49
+ · [primomics.org](http://primomics.org/)
50
+
51
+ ## Smaller ways in
52
+
53
+ - **Question the method.** Open a thread in the Space's **Community** tab.
54
+ - **Submit a model.** Baselines are on the leaderboard already; beat them.
55
+ - **Read the paper**: [OpenReview](https://openreview.net/forum?id=v2SA8gHwqo)
56
+ - **Look at the data**: [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/primo)
pages/submit.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ### The fastest way in
2
+
3
+ Two files, both in this [Space's repo](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/quickstart.py):
4
+
5
+ - 📥 [`quickstart.py`](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/quickstart.py):
6
+ downloads the selected modality, embeds it, and writes a valid submission.
7
+ Swap its `embed` function for your model and you are done.
8
+ - 📄 [`example_submission.csv`](https://huggingface.co/spaces/PRIMOmics/primo-eval/blob/main/example_submission.csv):
9
+ four lines, fake numbers, the exact shape we expect.
10
+
11
+ ```bash
12
+ pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
13
+ python quickstart.py --modality bulk-rna --out submission.parquet
14
+ # or: --modality single-cell-rna
15
+ ```
16
+
17
+ ### Or by hand, in three steps
18
+
19
+ 1. **Choose one modality** → use the selector in the form. The quickstart
20
+ downloads only its datasets from [PRIMOmics/primo](https://huggingface.co/datasets/PRIMOmics/primo).
21
+ 2. **Embed that modality** → build **one** file: `dataset_id`, `sample_id`, then one column per embedding dim (`e0`, `e1`, …). CSV / TSV / Parquet, or NPZ.
22
+ 3. **Sign in, fill the form, and hit Evaluate.** Add an institution for group submissions, check **Submitted by the model's authors** when applicable, and provide a paper link to make the model name clickable. A fixed task probe scores each hidden task (AUROC, Pearson or centered Spearman), reported per task category in its native metric.
23
+
24
+ ### Example file
25
+
26
+ ```
27
+ dataset_id,sample_id,e0,e1,e2
28
+ d002,S1,0.12,-0.44,0.98
29
+ d003,S1,0.31,0.02,-0.15
30
+ ```
31
+
32
+ For bulk datasets, each H5AD row is one submission sample. For single-cell
33
+ datasets, H5AD rows are cells and `obs["sample_id"]` maps them to opaque
34
+ collection samples. Aggregate the cells however your model requires and submit
35
+ exactly one embedding per unique `sample_id`; the submission schema is unchanged.
36
+
37
+ ### Good to know
38
+
39
+ Files containing dataset IDs from another modality are rejected. **Partial
40
+ submissions are welcome, and they add up.** Cover fewer datasets and you are still
41
+ scored: you get ranked on every **board** whose scored tasks you covered in
42
+ full, and your numbers still show up in each board's **per-task** table, so
43
+ nothing you send is thrown away.
44
+
45
+ Results are keyed by **model name and task**. Submit under a name you already
46
+ used and it fills in the tasks it covers, leaving the rest of that model's
47
+ results standing — so you can build coverage up one submission at a time, and the
48
+ same name can hold results for both modalities at once. Submitting a task you
49
+ have already covered replaces that one result, which is how a bad run gets fixed.
50
+ A name belongs to whichever HF user submitted it first; nobody else can write under it.
51
+
52
+ **Each submission yields two entries.** We also score your embedding
53
+ concatenated with our `HVG-1200-genes` features (your embedding is put on the
54
+ unit sphere, then the concatenated row) and rank it as `<your model> + HVG`,
55
+ under your name and credits. Scoring therefore takes about twice as long. Names containing `+ HVG` are reserved.
56
+
57
+ **Your first target is the baselines.** We run our own reference submissions on
58
+ log-normalized expression, whole or cut down to its most variable genes, and they
59
+ sit on the boards labelled `(baseline)`. `HVG-1200-genes` is the HVG-1200
60
+ log-normalized linear baseline, not a raw-count baseline.
pages/tasks.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Every scoreable task. Each row is a hidden clinical target or perturbation
2
+ response, scored by the same fixed task probe. The cohorts stay anonymous, but the
3
+ biology is on the table.
primo.css ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* PRIMO benchmark platform - Gradio stylesheet.
2
+ Load with: gr.Blocks(css_paths=["primo.css"])
3
+ Layout, tokens and type adapted from the STRABLE leaderboard,
4
+ https://github.com/gblayer/strable-leaderboard
5
+ Copyright (c) 2026 The STRABLE developers. BSD 3-Clause License:
6
+ its full text, conditions and disclaimer are in LICENSE-STRABLE. */
7
+
8
+ /* ---------- tokens: PRIMO marine and coral ---------- */
9
+ :root {
10
+ --pm-f-display: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
11
+ --pm-f-body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
12
+ --pm-f-mono: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
13
+ --pm-r-card: 16px;
14
+ --pm-bg: #EEF0F7; --pm-surface: #F8F9FC; --pm-surface-2: #E2E6F3;
15
+ --pm-ink: #0D1B62; --pm-ink-2: #2E3A73; --pm-ink-3: #565F8C; --pm-line: #CDD3EA;
16
+ --pm-head: #0D1B62; --pm-accent: #162DA3; --pm-accent-ink: #FFFFFF; --pm-btn-edge: #162DA3;
17
+ --pm-accent-2: #3F53B5; --pm-accent-text: #B5532F;
18
+ --pm-mark: #FD7E56; --pm-mark-muted: #A5ACCB;
19
+ --pm-panel: #0D1B62; --pm-panel-line: #3F53B5; --pm-panel-ink: #FFFFFF; --pm-panel-ink-2: #BCC3EA;
20
+ color-scheme: light;
21
+ }
22
+ .dark {
23
+ --pm-bg: #0A1030; --pm-surface: #111A45; --pm-surface-2: #1A2559;
24
+ --pm-ink: #E6E9F7; --pm-ink-2: #C2C8E4; --pm-ink-3: #959DC4; --pm-line: #28336B;
25
+ --pm-head: #C9D0FF; --pm-accent: #3F53B5; --pm-accent-ink: #FFFFFF; --pm-btn-edge: #3F53B5;
26
+ --pm-accent-2: #9AA8FF; --pm-accent-text: #FD9878;
27
+ --pm-mark: #FD7E56; --pm-mark-muted: #5A6394;
28
+ --pm-panel: #162DA3; --pm-panel-line: #3F53B5; --pm-panel-ink: #FFFFFF; --pm-panel-ink-2: #C9D0FF;
29
+ color-scheme: dark;
30
+ }
31
+
32
+ /* ---------- Gradio reset: its chrome takes the page tokens ---------- */
33
+ html, body, gradio-app { background: var(--pm-bg) !important; }
34
+ .gradio-container {
35
+ max-width: 100% !important; padding: 0 !important; background: var(--pm-bg) !important;
36
+ font-family: var(--pm-f-body) !important; color: var(--pm-ink);
37
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
38
+ --body-background-fill: var(--pm-bg);
39
+ --background-fill-primary: var(--pm-surface);
40
+ --background-fill-secondary: var(--pm-bg);
41
+ --block-background-fill: var(--pm-surface);
42
+ --block-border-color: var(--pm-line);
43
+ --border-color-primary: var(--pm-line);
44
+ --input-background-fill: var(--pm-bg);
45
+ --input-border-color: var(--pm-line);
46
+ --body-text-color: var(--pm-ink);
47
+ --body-text-color-subdued: var(--pm-ink-3);
48
+ --block-label-text-color: var(--pm-ink-2);
49
+ --block-title-text-color: var(--pm-ink-2);
50
+ --block-info-text-color: var(--pm-ink-3);
51
+ --link-text-color: var(--pm-ink);
52
+ --link-text-color-hover: var(--pm-accent-text);
53
+ --color-accent: var(--pm-accent);
54
+ --color-accent-soft: var(--pm-surface-2);
55
+ --checkbox-background-color-selected: var(--pm-accent);
56
+ --button-primary-background-fill: var(--pm-accent);
57
+ --button-primary-text-color: var(--pm-accent-ink);
58
+ }
59
+ footer { display: none !important; }
60
+ #pm-pages > .tab-nav, #pm-pages > .tab-wrapper { display: none !important; }
61
+ #pm-pages, #pm-pages > .tabitem { border: 0 !important; padding: 0 !important; background: transparent !important; }
62
+ #pm-pages > .tabitem > .column { gap: 0 !important; }
63
+ #pm-site { max-width: 1440px; margin: 0 auto !important; padding: 0 clamp(16px, 4vw, 40px) 56px !important; gap: 0 !important; }
64
+ #pm-site .html-container, #pm-site .html-container > .prose { padding: 0 !important; }
65
+ #pm-site .prose { font-family: var(--pm-f-body); font-size: 17px; line-height: 1.55; color: var(--pm-ink); }
66
+ #pm-site a { color: inherit; }
67
+ #pm-site :focus-visible { outline: 2px solid var(--pm-accent-2); outline-offset: 2px; }
68
+ #pm-site code { font-family: var(--pm-f-mono); font-size: .92em; background: none; padding: 0; color: var(--pm-ink-2); }
69
+
70
+ .pm-label { font: 600 12px/1.6 var(--pm-f-body); letter-spacing: .16em; text-transform: uppercase; }
71
+ #pm-site .pm-title { font-family: var(--pm-f-display); font-weight: 700; font-size: 28px; letter-spacing: -.02em; line-height: 1.15; margin: 0; color: var(--pm-ink); text-wrap: balance; }
72
+
73
+ /* ---------- top bar ---------- */
74
+ .pm-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-block: 20px; }
75
+ #pm-site .pm-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--pm-head); border-radius: 6px; }
76
+ #pm-site .pm-logo img { width: 34px; height: 34px; display: block; margin: 0; }
77
+ .pm-logo b { font: 800 22px/1 var(--pm-f-display); letter-spacing: -.03em; }
78
+ .pm-nav-right { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px 24px; }
79
+ .pm-nav-links { display: flex; gap: 24px; font-size: 16.5px; }
80
+ #pm-site .pm-nav-links a { color: var(--pm-ink-2); text-decoration: none; }
81
+ #pm-site .pm-nav-links a:hover, #pm-site .pm-nav-links a.pm-active { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 4px; }
82
+ @media (max-width: 760px) { .pm-nav-links { display: none; } }
83
+
84
+ #pm-site .pm-btn {
85
+ appearance: none; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 10px;
86
+ background: var(--pm-accent); color: var(--pm-accent-ink); border: 2px solid var(--pm-btn-edge); border-radius: 999px;
87
+ font: 600 14.5px/1.3 var(--pm-f-body); letter-spacing: .01em; padding: 10px 18px; min-height: 44px; white-space: nowrap;
88
+ transition: transform 120ms ease, filter 120ms ease;
89
+ }
90
+ #pm-site .pm-btn:hover { transform: translateY(-1px); filter: brightness(1.12); }
91
+ #pm-site .pm-btn:active { transform: translateY(1px); }
92
+ #pm-site .pm-btn--outline { background: transparent; color: var(--pm-head); }
93
+ @media (prefers-reduced-motion: reduce) { #pm-site .pm-btn { transition: none; } #pm-site .pm-btn:hover, #pm-site .pm-btn:active { transform: none; } }
94
+
95
+ /* ---------- hero ---------- */
96
+ .pm-hero { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: 28px 44px; align-items: center; padding-block: 14px 8px; }
97
+ #pm-site .pm-eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0; color: var(--pm-accent-2); }
98
+ .pm-updated { font: 500 13px/1 var(--pm-f-mono); color: var(--pm-ink-3); }
99
+ .pm-ver { font: 600 12.5px/1 var(--pm-f-mono); color: var(--pm-accent-ink); background: var(--pm-accent); padding: 6px 9px; border-radius: 999px; }
100
+ .pm-ver--closed { color: var(--pm-accent-text); background: transparent; box-shadow: inset 0 0 0 1.5px var(--pm-mark); }
101
+ #pm-site .pm-notice { margin: 0 0 8px; padding: 12px 16px; border: 1px solid var(--pm-line); border-left: 3px solid var(--pm-mark); border-radius: 12px; background: var(--pm-surface); font-size: 15px; color: var(--pm-ink-2); }
102
+ #pm-site .pm-notice b { color: var(--pm-ink); }
103
+ #pm-site .pm-notice a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
104
+ #pm-site .pm-ver-link { font-size: 14px; color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
105
+ #pm-site .pm-ver-link:hover { color: var(--pm-ink); }
106
+ #pm-site .pm-hero h1 {
107
+ font-family: var(--pm-f-display); font-weight: 800; font-size: clamp(58px, 8.6vw, 112px); line-height: .92;
108
+ letter-spacing: -.03em; margin: 14px 0 10px; color: var(--pm-head);
109
+ }
110
+ #pm-site .pm-expand { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; margin: 0 0 22px; font-size: 20px; color: var(--pm-ink-3); }
111
+ .pm-expand .pm-xp { font-weight: 500; letter-spacing: .01em; color: var(--pm-ink-2); }
112
+ .pm-expand b { color: var(--pm-head); font-weight: 800; }
113
+ #pm-site .pm-lede { font-size: 19.5px; line-height: 1.55; color: var(--pm-ink-2); max-width: 34em; margin: 0 0 22px; }
114
+ #pm-site .pm-lede a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
115
+ #pm-site .pm-stats { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }
116
+ #pm-site .pm-stats li { margin: 0; font: 500 14px/1 var(--pm-f-mono); color: var(--pm-ink-2); border: 1px solid var(--pm-line); background: var(--pm-surface); padding: 8px 12px; border-radius: 999px; }
117
+ #pm-site .pm-stats li::marker { content: none; }
118
+ .pm-stats b { color: var(--pm-ink); font-weight: 600; }
119
+ .pm-art-wrap { justify-self: end; width: 100%; max-width: 520px; }
120
+ .pm-art { padding: 14px; border: 1px solid var(--pm-line); border-radius: 14px; background: var(--pm-surface); }
121
+ .pm-hero-art { display: block; width: 100%; height: auto; }
122
+ .pm-hero-art text { font-family: var(--pm-f-body); }
123
+ .pm-art-h { fill: var(--pm-ink); font-size: 11.5px; font-weight: 600; }
124
+ .pm-art-c { fill: var(--pm-ink-3); font-size: 9.5px; font-weight: 500; }
125
+ .pm-art-t { fill: var(--pm-ink); font-size: 10.5px; font-weight: 500; }
126
+ .pm-art-patient { fill: var(--pm-mark); }
127
+ .pm-art-a { fill: var(--pm-accent-2); }
128
+ .pm-art-b { fill: var(--pm-accent); }
129
+ .pm-art-box { fill: var(--pm-surface-2); stroke: var(--pm-accent-2); stroke-width: 1.2; }
130
+ .pm-art-edge { stroke: var(--pm-accent-2); stroke-width: .6; opacity: .45; }
131
+ .pm-art-node { fill: var(--pm-accent-2); }
132
+ .pm-art-line { fill: none; stroke: var(--pm-ink-3); stroke-width: 1.2; }
133
+ .pm-art-head { fill: var(--pm-ink-3); }
134
+ .pm-art-pill { fill: var(--pm-surface-2); stroke: var(--pm-line); stroke-width: 1; }
135
+ @media (max-width: 1000px) { .pm-hero { grid-template-columns: 1fr; } .pm-art-wrap { justify-self: start; max-width: 520px; } }
136
+
137
+ /* ---------- view pills: Gradio radios drawn as segmented pills ---------- */
138
+ #pm-site .pm-board-sub { margin: 40px 0 14px; padding-top: 22px; border-top: 1px solid var(--pm-line); font-size: 14.5px; color: var(--pm-ink-3); }
139
+ #pm-controls { display: block !important; margin: 0 0 20px; }
140
+ #pm-controls > .form {
141
+ display: flex !important; flex-wrap: wrap !important; align-items: center; gap: 12px 22px !important;
142
+ width: 100% !important; background: transparent !important; border: 0 !important; box-shadow: none !important; overflow: visible !important;
143
+ }
144
+ #pm-controls fieldset.pm-seg { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; }
145
+ #pm-controls fieldset.pm-seg--main { order: 1; flex: 1 1 100% !important; }
146
+ #pm-controls fieldset.pm-seg--line { order: 2; flex: 1 1 100% !important; }
147
+ #pm-controls fieldset.pm-seg--minor { order: 3; }
148
+ #pm-controls .pm-seg:not(.pm-seg--minor) [data-testid="block-info"] { flex: none; width: 168px; }
149
+ #pm-site fieldset.pm-seg {
150
+ display: flex !important; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 !important;
151
+ padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; overflow: visible !important;
152
+ }
153
+ #pm-site .pm-seg [data-testid="block-info"] {
154
+ margin: 0 !important; padding: 0 !important; font: 600 12px/1.2 var(--pm-f-body) !important;
155
+ letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-3) !important; background: none !important; border: 0 !important;
156
+ }
157
+ #pm-site .pm-seg .wrap:has(> label) {
158
+ display: inline-flex !important; flex-wrap: wrap; gap: 3px !important; padding: 3px !important;
159
+ border: 1px solid var(--pm-line); border-radius: 999px; background: var(--pm-bg);
160
+ }
161
+ #pm-site .pm-seg label {
162
+ position: relative; display: inline-flex !important; align-items: center; gap: 8px; margin: 0 !important;
163
+ padding: 8px 12px !important; min-height: 34px; border: 0 !important; border-radius: 999px !important;
164
+ background: transparent !important; box-shadow: none !important; cursor: pointer; white-space: nowrap;
165
+ font: 500 13.5px/1 var(--pm-f-mono) !important; color: var(--pm-ink-2) !important;
166
+ }
167
+ #pm-site .pm-seg label:hover { color: var(--pm-ink) !important; background: var(--pm-surface-2) !important; }
168
+ #pm-site .pm-seg label.selected, #pm-site .pm-seg label:has(input:checked) { background: var(--pm-ink) !important; color: var(--pm-bg) !important; }
169
+ #pm-site .pm-seg input[type="radio"] { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
170
+ #pm-site .pm-seg label > span { margin: 0 !important; color: inherit !important; font: inherit !important; }
171
+ #pm-site .pm-seg--main label { font-size: 16px !important; font-weight: 600 !important; padding: 12px 20px !important; min-height: 46px; }
172
+ #pm-site .pm-seg--main [data-testid="block-info"] { color: var(--pm-ink) !important; }
173
+ #pm-site .pm-seg--minor label { font-size: 12.5px !important; padding: 6px 10px !important; min-height: 28px; }
174
+ #pm-site .pm-seg--minor [data-testid="block-info"] { font-style: italic !important; }
175
+
176
+ /* ---------- tables ---------- */
177
+ .pm-card { background: var(--pm-surface); border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); min-width: 0; }
178
+ .pm-tbl-wrap { overflow: auto; border-radius: 0 0 var(--pm-r-card) var(--pm-r-card); }
179
+ .pm-card-head {
180
+ display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 18px;
181
+ padding: 14px 14px 12px; border-bottom: 1px solid var(--pm-line); border-radius: var(--pm-r-card) var(--pm-r-card) 0 0; background: var(--pm-bg);
182
+ }
183
+ .pm-view { font: 700 12.5px/1.3 var(--pm-f-body); letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-2); }
184
+ .pm-view-n { font: 500 13.5px/1.3 var(--pm-f-mono); color: var(--pm-ink-3); }
185
+ .pm-view-n b { font-weight: 700; color: var(--pm-ink); }
186
+ .pm-view-n b:first-child { color: var(--pm-accent-text); font-size: 15px; }
187
+ .pm-table-wrap { width: 100%; overflow-x: auto; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); }
188
+ #pm-site table.pm-table {
189
+ width: 100%; margin: 0; border: 0; border-collapse: collapse; table-layout: auto;
190
+ font-family: var(--pm-f-body); font-size: 14.5px; font-variant-numeric: tabular-nums; background: transparent;
191
+ }
192
+ #pm-site .pm-table td.pm-num, #pm-site .pm-table td.pm-metric, #pm-site .pm-ranked td.pm-name { font-family: var(--pm-f-mono); font-size: 14px; }
193
+ #pm-site .pm-table th {
194
+ padding: 16px 8px 11px; border: 0; border-bottom: 1px solid var(--pm-line); background: var(--pm-surface);
195
+ font: 700 11.5px/1.2 var(--pm-f-body); letter-spacing: .1em; text-transform: uppercase; color: var(--pm-ink-2);
196
+ text-align: left; vertical-align: bottom; white-space: nowrap;
197
+ }
198
+ #pm-site .pm-table th small { display: block; margin-top: 3px; font: 500 12px/1.2 var(--pm-f-body); letter-spacing: .02em; text-transform: none; color: var(--pm-ink-3); }
199
+ #pm-site .pm-table td { padding: 11px 8px; border: 0; border-bottom: 1px solid var(--pm-line); vertical-align: middle; color: var(--pm-ink-2); background: transparent; }
200
+ #pm-site .pm-table tr { border: 0; background: transparent; }
201
+ #pm-site .pm-table tbody tr:last-child td { border-bottom: 0; }
202
+ #pm-site .pm-table tbody tr:hover td { background: var(--pm-surface-2); }
203
+ #pm-site .pm-table th.pm-sort { cursor: pointer; user-select: none; }
204
+ #pm-site .pm-table th.pm-sort:hover { color: var(--pm-ink); }
205
+ #pm-site .pm-table th.pm-sort[data-sort-direction="asc"]::after { content: " ↑"; color: var(--pm-mark); }
206
+ #pm-site .pm-table th.pm-sort[data-sort-direction="desc"]::after { content: " ↓"; color: var(--pm-mark); }
207
+ #pm-site .pm-table .pm-r, #pm-site .pm-table td.pm-num { text-align: right; }
208
+ #pm-site .pm-table td.pm-num { color: var(--pm-ink-2); }
209
+ #pm-site .pm-table td.pm-key { color: var(--pm-ink); font-weight: 600; }
210
+ #pm-site .pm-table td.pm-best, #pm-site .pm-table td[style*="font-weight:700"] { color: var(--pm-ink); font-weight: 700; }
211
+ #pm-site .pm-table .pm-dim, #pm-site .pm-table .pm-num--dim { color: var(--pm-mark-muted); }
212
+ #pm-site .pm-table td.pm-strong { font-family: var(--pm-f-body); color: var(--pm-ink); }
213
+ #pm-site .pm-table td.pm-name { font-weight: 600; color: var(--pm-ink); }
214
+ #pm-site .pm-table td.pm-metric { font-size: 12px; color: var(--pm-ink-3); }
215
+
216
+ /* the ranking: one line per model, the top three framed as on STRABLE */
217
+ #pm-site .pm-ranked td { white-space: nowrap; }
218
+ #pm-site .pm-ranked th { white-space: normal; }
219
+ #pm-site .pm-ranked th small { white-space: nowrap; }
220
+ #pm-site .pm-ranked th:first-child, #pm-site .pm-ranked td:first-child { padding-left: 14px; }
221
+ #pm-site .pm-ranked th:last-child, #pm-site .pm-ranked td:last-child { padding-right: 14px; }
222
+ #pm-site .pm-ranked tr.pm-top td:first-child { border-left: 2px solid var(--pm-mark); }
223
+ #pm-site .pm-ranked tr.pm-top td:last-child { border-right: 2px solid var(--pm-mark); }
224
+ #pm-site .pm-ranked tr.pm-top-first td { border-top: 2px solid var(--pm-mark); }
225
+ #pm-site .pm-ranked tr.pm-top-last td { border-bottom: 2px solid var(--pm-mark); }
226
+ #pm-site .pm-ranked[data-sorted="true"] tr.pm-top td { border-left: 0; border-right: 0; border-top: 0; border-bottom: 1px solid var(--pm-line); }
227
+ #pm-site .pm-ranked .pm-rank { width: 1%; font: 700 14px/1 var(--pm-f-body); letter-spacing: .03em; color: var(--pm-ink-3); }
228
+ #pm-site .pm-ranked .pm-inst { font-family: var(--pm-f-body); font-size: 14.5px; max-width: 9.5em; overflow: hidden; text-overflow: ellipsis; }
229
+ .pm-pipe { display: flex; align-items: center; gap: 6px; min-width: 0; }
230
+ .pm-lrn { color: var(--pm-ink); font-weight: 600; white-space: nowrap; }
231
+ #pm-site .pm-model-link { color: var(--pm-ink); text-decoration: underline; text-decoration-color: var(--pm-line); text-underline-offset: 4px; }
232
+ #pm-site .pm-model-link:hover { text-decoration-color: var(--pm-ink); }
233
+ .pm-leader { flex: 1 1 12px; min-width: 12px; align-self: center; border-bottom: 2px dotted var(--pm-line); transform: translateY(3px); }
234
+ .pm-tag { flex: none; font: 600 9.5px/1 var(--pm-f-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--pm-ink-3); border: 1px solid var(--pm-line); padding: 3px 4px; border-radius: 999px; white-space: nowrap; }
235
+ .pm-tag--hvg { color: var(--pm-accent-2); border-color: var(--pm-accent-2); }
236
+ .pm-tag--author { color: var(--pm-accent-text); border-color: var(--pm-accent-text); }
237
+ .pm-tag--base { color: var(--pm-ink-2); border-style: dashed; border-color: var(--pm-ink-3); }
238
+ #pm-site .pm-note { margin: 12px 0 0; font-size: 13px; color: var(--pm-ink-3); max-width: 64em; }
239
+ #pm-site .pm-empty { margin: 0; padding: 22px 18px; font-size: 15px; color: var(--pm-ink-2); }
240
+
241
+ /* the window of the Cite button, in the top bar */
242
+ #pm-cite-dialog { width: min(640px, calc(100vw - 32px)); }
243
+ #pm-cite-dialog b { color: var(--pm-ink); }
244
+ #pm-cite-dialog pre { margin: 14px 0 0; overflow-x: auto; font: 12.5px/1.55 var(--pm-f-mono); white-space: pre; }
245
+
246
+ /* the per-task scores, folded under the ranking */
247
+ #pm-site .pm-more { margin-top: 36px; }
248
+ #pm-site .pm-more > summary { display: flex; align-items: baseline; gap: 14px; cursor: pointer; list-style: none; }
249
+ #pm-site .pm-more > summary::-webkit-details-marker { display: none; }
250
+ #pm-site .pm-more > summary::before { content: "+"; width: 1em; font: 600 24px/1 var(--pm-f-mono); color: var(--pm-mark); }
251
+ #pm-site .pm-more[open] > summary::before { content: "−"; }
252
+ .pm-more-n { font: 500 13.5px/1 var(--pm-f-mono); color: var(--pm-ink-3); }
253
+ #pm-site .pm-more > .pm-note { margin: 8px 0 14px; }
254
+ #pm-site .pm-more .pm-table td { white-space: nowrap; padding: 9px 10px; font-size: 13.5px; }
255
+ #pm-site .pm-more .pm-table th { padding: 14px 10px 10px; font-size: 11.5px; }
256
+ #pm-site .pm-more .pm-table th:first-child, #pm-site .pm-more .pm-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--pm-surface); }
257
+ #pm-site .pm-more .pm-table tbody tr:hover td:first-child { background: var(--pm-surface-2); }
258
+ #pm-site .pm-more .pm-task-text { display: inline-block; max-width: 30em; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
259
+ #pm-site .pm-table .pm-sd { display: inline; margin-left: 4px; font-size: 11px; color: var(--pm-ink-3); }
260
+
261
+ /* tooltips */
262
+ .pm-score-guide, .pm-task-guide { position: relative; display: inline-block; cursor: help; }
263
+ .pm-score-guide::after, .pm-task-guide::after {
264
+ content: attr(data-tooltip); position: absolute; z-index: 10; bottom: calc(100% + 7px); width: max-content;
265
+ max-width: min(300px, 70vw); padding: 8px 12px; border-radius: 10px; background: var(--pm-ink); color: var(--pm-bg);
266
+ font: 400 13px/1.45 var(--pm-f-body); text-align: left; white-space: normal; letter-spacing: 0; text-transform: none;
267
+ opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease;
268
+ }
269
+ .pm-score-guide::after { right: 0; }
270
+ .pm-task-guide::after { left: 0; width: min(360px, 72vw); }
271
+ .pm-score-guide:hover::after, .pm-score-guide:focus::after, .pm-task-guide:hover::after, .pm-task-guide:focus::after { opacity: 1; transform: translateY(0); }
272
+ .pm-table tbody tr:first-child .pm-task-guide::after, .pm-table tbody tr:first-child .pm-score-guide::after { top: calc(100% + 7px); bottom: auto; }
273
+ .pm-public-chip { display: inline-flex; margin-left: 6px; font: 600 10px/1 var(--pm-f-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--pm-accent-2); border: 1px solid var(--pm-accent-2); padding: 3px 5px; border-radius: 999px; white-space: nowrap; vertical-align: 1px; }
274
+
275
+ /* ---------- version history ---------- */
276
+ .pm-history { margin-top: 44px; display: grid; gap: 14px; }
277
+ #pm-site .pm-history-sub { margin: 6px 0 0; color: var(--pm-ink-3); font-size: 14.5px; }
278
+ .pm-history-sub span { font-family: var(--pm-f-mono); color: var(--pm-ink-2); }
279
+ #pm-site .pm-history-sub a { color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
280
+ #pm-site .pm-history ol { list-style: none; margin: 0; padding: 0; display: grid; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); }
281
+ #pm-site .pm-history li { display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; align-items: baseline; gap: 12px; margin: 0; padding: 13px 16px; border-bottom: 1px solid var(--pm-line); font-size: 16px; color: var(--pm-ink-2); }
282
+ #pm-site .pm-history li.pm-on { box-shadow: inset 3px 0 0 var(--pm-mark); }
283
+ #pm-site .pm-history li p { margin: 0; }
284
+ #pm-site .pm-history .pm-ver-meta { margin: 0 0 4px; font: 500 13px/1.5 var(--pm-f-mono); color: var(--pm-ink-3); }
285
+ #pm-site .pm-history li > a { font-size: 14.5px; color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
286
+ .pm-chip { display: inline-flex; margin-right: 4px; padding: 2px 7px; border-radius: 999px; font: 600 10.5px/1.4 var(--pm-f-mono); letter-spacing: .08em; text-transform: uppercase; }
287
+ .pm-chip--current { color: var(--pm-accent-ink); background: var(--pm-accent); }
288
+ .pm-chip--closed { color: var(--pm-ink-2); box-shadow: inset 0 0 0 1px var(--pm-ink-3); }
289
+ #pm-site .pm-history li::marker { content: none; }
290
+ #pm-site .pm-history li:last-child { border-bottom: 0; }
291
+ .pm-history li b { font: 600 14px/1.7 var(--pm-f-mono); color: var(--pm-ink); }
292
+
293
+ /* ---------- closing panel and credit ---------- */
294
+ .pm-ask { margin-top: 44px; display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 16px 44px; padding: 22px 28px; border-radius: var(--pm-r-card); background: var(--pm-panel); color: var(--pm-panel-ink); border: 1px solid var(--pm-panel-line); }
295
+ @media (max-width: 860px) { .pm-ask { grid-template-columns: 1fr; padding: 24px 18px; } }
296
+ #pm-site .pm-ask .pm-label { color: var(--pm-panel-ink-2); margin: 0; }
297
+ #pm-site .pm-ask h2 { font-family: var(--pm-f-display); font-weight: 700; font-size: clamp(24px, 2.6vw, 32px); letter-spacing: -.02em; line-height: 1.1; margin: 10px 0 0; color: var(--pm-panel-ink); text-wrap: balance; }
298
+ .pm-ask-go { display: grid; gap: 14px; align-content: center; justify-items: start; min-width: 0; }
299
+ #pm-site .pm-ask-go p { margin: 0; color: var(--pm-panel-ink-2); max-width: 34em; }
300
+ #pm-site .pm-ask .pm-btn { background: var(--pm-panel-ink); color: var(--pm-panel); border-color: var(--pm-panel-ink); }
301
+
302
+ #pm-site .pm-credit { margin: 32px 0 0; text-align: center; font-size: 13.5px; color: var(--pm-ink-3); }
303
+ #pm-site .pm-credit a { color: var(--pm-ink-2); text-decoration: underline; text-underline-offset: 3px; }
304
+
305
+ /* ---------- other pages: Tasks, Submit, Contribute, Method ---------- */
306
+ .pm-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 18px 0 22px; border-bottom: 1px solid var(--pm-line); }
307
+ #pm-site .pm-head h1 { margin: 0; font-family: var(--pm-f-display); font-weight: 800; font-size: clamp(40px, 5vw, 64px); line-height: .95; letter-spacing: -.03em; color: var(--pm-head); }
308
+ #pm-site .pm-head p { margin: 12px 0 0; font-size: 17px; color: var(--pm-ink-2); max-width: 46em; }
309
+ #pm-site .pm-body { padding: 24px 0 0 !important; }
310
+ .pm-prose { max-width: 860px; }
311
+ #pm-site .pm-prose :where(h1, h2, h3) { font-family: var(--pm-f-display); font-weight: 700; letter-spacing: -.02em; color: var(--pm-ink); }
312
+ #pm-site .pm-prose a { color: var(--pm-ink); text-decoration: underline; text-underline-offset: 3px; }
313
+ #pm-site .pm-caption { margin: 14px 0 0; font-size: 14px; color: var(--pm-ink-3); }
314
+ .pm-over { margin: 0; font: 600 12px/1.4 var(--pm-f-body); letter-spacing: .14em; text-transform: uppercase; color: var(--pm-ink-3); }
315
+ .pm-badge { display: inline-flex; align-items: center; padding: 3px 6px; border: 1px solid var(--pm-line); border-radius: 999px; font: 500 12px/1 var(--pm-f-mono); white-space: nowrap; }
316
+
317
+ /* submit page: the steps on the left, the form on the right */
318
+ #pm-submit { display: grid !important; grid-template-columns: minmax(0, 1fr) minmax(340px, 460px); gap: 28px 56px !important; align-items: start; }
319
+ #pm-submit > .column { width: auto !important; min-width: 0 !important; }
320
+ @media (max-width: 1000px) { #pm-submit { grid-template-columns: minmax(0, 1fr); } }
321
+ #pm-site .pm-submit-text { max-width: 68ch; }
322
+ #pm-site .pm-prose .prose { font-size: 16.5px; line-height: 1.6; color: var(--pm-ink-2); }
323
+ #pm-site .pm-prose .prose strong { color: var(--pm-ink); }
324
+ #pm-site .pm-prose .prose h3 { margin: 34px 0 10px; font: 700 22px/1.2 var(--pm-f-display); letter-spacing: -.01em; color: var(--pm-ink); }
325
+ #pm-site .pm-prose .prose > h3:first-child { margin-top: 4px; }
326
+ #pm-site .pm-prose .prose p { margin: 0 0 14px; }
327
+ #pm-site .pm-submit-text ul { display: grid; gap: 8px; margin: 10px 0 16px; padding: 0; list-style: none; }
328
+ #pm-site .pm-submit-text ul > li { margin: 0; padding: 0; }
329
+ #pm-site .pm-submit-text ul > li::marker { content: none; }
330
+ #pm-site .pm-prose ol { margin: 10px 0 16px; padding-left: 1.4em; }
331
+ #pm-site .pm-prose ol > li { margin: 0 0 10px; padding-left: 4px; }
332
+ #pm-site .prose :not(pre) > code { padding: 1px 6px; border: 0; border-radius: 6px; background: var(--pm-surface-2); box-shadow: none; color: var(--pm-ink); font-size: .86em; }
333
+ #pm-site .prose .code_wrap, #pm-site .prose pre { background: var(--pm-surface) !important; }
334
+ #pm-site .prose pre { margin: 12px 0 18px; padding: 14px 16px !important; border: 1px solid var(--pm-line); border-radius: 12px; }
335
+ #pm-site .prose pre code { padding: 0; background: transparent; color: var(--pm-ink-2); font-size: 13px; line-height: 1.6; }
336
+ #pm-site .prose hr { margin: 28px 0; border: 0; border-top: 1px solid var(--pm-line); }
337
+
338
+ #pm-form { display: flex; flex-direction: column; gap: 2px !important; padding: 22px !important; background: var(--pm-surface); border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); }
339
+ #pm-form .form { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 4px !important; }
340
+ #pm-form .block { padding: 10px 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
341
+ #pm-form [data-testid="block-info"], #pm-site #pm-form .pm-seg--form [data-testid="block-info"] {
342
+ display: block; margin: 0 0 2px !important; font: 600 15px/1.35 var(--pm-f-body) !important;
343
+ letter-spacing: 0 !important; text-transform: none !important; color: var(--pm-ink) !important;
344
+ }
345
+ #pm-form [data-testid="block-info"] + div .prose, #pm-form [data-testid="block-info"] + div .prose p {
346
+ margin: 0 0 8px !important; font-size: 13.5px !important; line-height: 1.4 !important; color: var(--pm-ink-3) !important;
347
+ }
348
+ #pm-site #pm-form fieldset.pm-seg--form { display: block !important; }
349
+ #pm-site #pm-form .pm-hint .prose p { margin: 0; font-size: 13.5px; color: var(--pm-ink-3); }
350
+ #pm-site #pm-form .pm-hint .prose pre { margin: 0; }
351
+ #pm-form input[type="text"], #pm-form textarea {
352
+ border: 1px solid var(--pm-line) !important; border-radius: 10px !important; background: var(--pm-bg) !important;
353
+ box-shadow: none !important; font-family: var(--pm-f-body) !important; font-size: 15px !important; color: var(--pm-ink) !important;
354
+ scrollbar-width: none;
355
+ }
356
+ #pm-form textarea::-webkit-scrollbar { display: none; }
357
+ #pm-form input[type="text"]:focus, #pm-form textarea:focus { outline: 2px solid var(--pm-accent-2); outline-offset: 0; }
358
+ #pm-form input[type="checkbox"] { accent-color: var(--pm-accent); }
359
+ #pm-site #pm-form button.huggingface {
360
+ width: 100%; justify-content: center; min-height: 46px; margin-bottom: 6px; border: 1px solid var(--pm-line) !important;
361
+ border-radius: 999px !important; background: var(--pm-bg) !important; box-shadow: none !important;
362
+ font: 600 15px/1.3 var(--pm-f-body) !important; color: var(--pm-ink) !important;
363
+ }
364
+ #pm-site #pm-form button.huggingface:hover { background: var(--pm-surface-2) !important; }
365
+ #pm-form .block:has(input[type="file"]) { margin-top: 6px; padding: 14px !important; border: 2px dashed var(--pm-line) !important; border-radius: 14px !important; background: var(--pm-bg) !important; }
366
+ #pm-form [data-testid="block-label"] {
367
+ position: static !important; display: inline-flex; align-items: center; gap: 6px; margin: 0 0 6px !important; padding: 0 !important;
368
+ border: 0 !important; background: none !important; box-shadow: none !important;
369
+ font: 600 15px/1.35 var(--pm-f-body) !important; color: var(--pm-ink) !important;
370
+ }
371
+ #pm-form .block:has(input[type="file"]) button:has(input[type="file"]) { height: 120px !important; min-height: 0 !important; color: var(--pm-ink-3); }
372
+ #pm-form label:has(input[type="checkbox"]) > span { font: 600 15px/1.35 var(--pm-f-body) !important; color: var(--pm-ink) !important; }
373
+ #pm-form label:has(input[type="checkbox"]) + div .prose, #pm-form label:has(input[type="checkbox"]) + div .prose p {
374
+ margin: 2px 0 0 !important; font-size: 13.5px !important; line-height: 1.4 !important; color: var(--pm-ink-3) !important;
375
+ }
376
+ #pm-site #pm-evaluate { width: 100%; justify-content: center; min-height: 46px; margin-top: 10px; border-radius: 999px !important; border: 2px solid var(--pm-btn-edge) !important; background: var(--pm-accent) !important; color: var(--pm-accent-ink) !important; font: 600 15px/1.3 var(--pm-f-body) !important; }
377
+ #pm-site #pm-evaluate:hover { filter: brightness(1.12); }
378
+
379
+ /* the windows: evaluation confirmation and Cite */
380
+ .pm-dialog { width: min(440px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--pm-line); border-radius: var(--pm-r-card); background: var(--pm-surface); color: var(--pm-ink); box-shadow: 0 24px 64px rgb(19 39 63 / .24); }
381
+ #pm-site .pm-dialog { margin: auto !important; }
382
+ .pm-dialog::backdrop { background: rgb(19 39 63 / .45); }
383
+ .pm-dialog-surface { padding: 24px; }
384
+ .pm-dialog h2 { margin: 8px 0 0; font: 700 1.35rem/1.2 var(--pm-f-display); letter-spacing: -.02em; color: var(--pm-ink); }
385
+ .pm-dialog-surface > p:not(.pm-over) { margin: 12px 0 0; font-size: 15px; line-height: 1.6; color: var(--pm-ink-2); }
386
+ .pm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
387
+ .pm-dialog .pm-btn { appearance: none; cursor: pointer; border-radius: 999px; padding: 9px 16px; font: 600 14px/1.2 var(--pm-f-body); border: 2px solid var(--pm-btn-edge); }
388
+ .pm-dialog .pm-btn--primary { background: var(--pm-accent); color: var(--pm-accent-ink); }
389
+ .pm-dialog .pm-btn--outline { background: transparent; color: var(--pm-head); }
390
+ .pm-evaluation-status { display: flex; align-items: center; gap: 10px; font-size: 14px; line-height: 1.45; color: var(--pm-ink-2); }
391
+ .pm-spinner { width: 18px; height: 18px; flex: none; animation: pm-spin .8s linear infinite; }
392
+ .pm-spinner circle, .pm-spinner path { fill: none; stroke-width: 3; }
393
+ .pm-spinner circle { stroke: var(--pm-line); }
394
+ .pm-spinner path { stroke: var(--pm-mark); stroke-linecap: round; }
395
+ @keyframes pm-spin { to { transform: rotate(360deg); } }
396
+ @media (prefers-reduced-motion: reduce) { .pm-spinner { animation-duration: 1.6s; } }
quickstart.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Produce a valid PRIMO submission in one command, then swap in your own model.
2
+
3
+ Downloads one modality's public datasets, at the data commit that the
4
+ leaderboard scores, and writes the file the Submit tab expects. The embedding
5
+ here is deliberately dumb: bulk data uses log2(CPM+1); single-cell data uses
6
+ per-cell log2(CP10K+1), mean pooling by collection sample, then PCA. Replace
7
+ ``embed`` with your encoder and nothing else changes.
8
+
9
+ pip install anndata scikit-learn pandas pyarrow pyyaml huggingface_hub
10
+ python quickstart.py --modality bulk-rna --out submission.parquet
11
+
12
+ Standalone on purpose: no import from this Space, so it keeps working if you
13
+ copy the file into your own project. For the same reason, ``DATA_REVISION``
14
+ repeats the data commit of ``versions.yaml``.
15
+ """
16
+
17
+ import argparse
18
+ from pathlib import Path
19
+
20
+ import anndata as ad
21
+ import numpy as np
22
+ import pandas as pd
23
+ import yaml
24
+ from huggingface_hub import hf_hub_download, snapshot_download
25
+ from sklearn.decomposition import PCA
26
+
27
+ PUBLIC_REPO = "PRIMOmics/primo"
28
+ DATA_REVISION = "0f452b2fc84e544fb1a1fc5bd42017482e07b43c"
29
+ MANIFEST_FILENAME = "datasets.yaml"
30
+ MODALITIES = {"bulk-rna": "bulk RNA", "single-cell-rna": "Single Cell RNA"}
31
+
32
+ DATASET_ID = "dataset_id"
33
+ SAMPLE_ID = "sample_id"
34
+
35
+ TARGET_SUM = 1_000_000
36
+ SINGLE_CELL_TARGET_SUM = 10_000
37
+ N_COMPONENTS = 50
38
+ RANDOM_STATE = 0
39
+
40
+
41
+ def prediction_ids(adata: ad.AnnData, cells: bool) -> np.ndarray:
42
+ """Opaque collection-sample ids required in the submission."""
43
+ if cells:
44
+ return pd.unique(adata.obs[SAMPLE_ID].astype(str)).astype(str)
45
+ return adata.obs_names.astype(str).to_numpy()
46
+
47
+
48
+ def _log_normalize(x, target_sum: int) -> np.ndarray:
49
+ """Return dense log2 counts-per-target expression."""
50
+ x = x.toarray() if hasattr(x, "toarray") else np.asarray(x)
51
+ totals = x.sum(axis=1, keepdims=True)
52
+ return np.log2(x / np.where(totals == 0, 1, totals) * target_sum + 1)
53
+
54
+
55
+ def _sample_expression(adata: ad.AnnData, cells: bool) -> tuple[np.ndarray, np.ndarray]:
56
+ """Normalize expression and mean-pool cells into collection samples."""
57
+ ids = prediction_ids(adata, cells)
58
+ if not cells:
59
+ return ids, _log_normalize(adata.X, TARGET_SUM)
60
+ samples = adata.obs[SAMPLE_ID].astype(str).to_numpy()
61
+ pooled = [
62
+ _log_normalize(adata.X[samples == sample_id], SINGLE_CELL_TARGET_SUM).mean(0)
63
+ for sample_id in ids
64
+ ]
65
+ return ids, np.asarray(pooled)
66
+
67
+
68
+ def embed(adata: ad.AnnData, cells: bool) -> np.ndarray:
69
+ """One dataset's expression -> one vector per collection sample. Replace me.
70
+
71
+ ``cells`` is true when the rows of ``adata`` are cells, which
72
+ ``obs["sample_id"]`` maps to collection samples.
73
+
74
+ ``X`` holds raw counts unless ``adata.uns["input_normalization"]`` names another
75
+ scale (FPKM, microarray intensities or author-normalised counts). The
76
+ log2(CPM + 1) below accepts any non-negative scale.
77
+
78
+ Whatever you return, the contract is one finite row per id returned by
79
+ ``prediction_ids``. The embedding width may differ between datasets.
80
+ """
81
+ _, x = _sample_expression(adata, cells)
82
+ k = min(N_COMPONENTS, x.shape[0] - 1, x.shape[1])
83
+ return PCA(n_components=k, random_state=RANDOM_STATE).fit_transform(x)
84
+
85
+
86
+ def download(modality: str, token: str | None) -> Path:
87
+ """Download the manifest and expression files for one modality."""
88
+ manifest = Path(
89
+ hf_hub_download(
90
+ PUBLIC_REPO,
91
+ MANIFEST_FILENAME,
92
+ repo_type="dataset",
93
+ token=token,
94
+ revision=DATA_REVISION,
95
+ )
96
+ )
97
+ paths = [
98
+ MANIFEST_FILENAME,
99
+ *[str(entry["path"]) for entry in datasets(manifest, modality)],
100
+ ]
101
+ return Path(
102
+ snapshot_download(
103
+ PUBLIC_REPO,
104
+ repo_type="dataset",
105
+ token=token,
106
+ revision=DATA_REVISION,
107
+ allow_patterns=paths,
108
+ )
109
+ )
110
+
111
+
112
+ def datasets(manifest_path: Path, modality: str) -> list[dict]:
113
+ """Manifest entries for one modality."""
114
+ manifest = yaml.safe_load(manifest_path.read_text())
115
+ entries = manifest.get("datasets", []) if isinstance(manifest, dict) else manifest
116
+ return [entry for entry in entries if entry["modality"] == modality]
117
+
118
+
119
+ def build(root: Path, modality: str) -> pd.DataFrame:
120
+ """Embed one modality into the frame the Submit tab expects.
121
+
122
+ Datasets of different widths stack into one table; the extra columns of a
123
+ narrower dataset stay empty and the evaluator drops them per dataset, so each
124
+ dataset keeps its own embedding size.
125
+ """
126
+ blocks = []
127
+ for entry in datasets(root / MANIFEST_FILENAME, modality):
128
+ dataset_id = str(entry["id"])
129
+ adata = ad.read_h5ad(root / entry["path"])
130
+ cells = entry.get("observation_level") == "cell"
131
+ sample_ids = prediction_ids(adata, cells)
132
+ vectors = embed(adata, cells)
133
+ print(
134
+ f"{dataset_id}: {adata.n_obs} observations, {len(sample_ids)} samples, "
135
+ f"{entry.get('input_normalization', 'raw counts')} "
136
+ f"-> {vectors.shape[1]} dims"
137
+ )
138
+ block = pd.DataFrame(
139
+ vectors, columns=[f"e{i}" for i in range(vectors.shape[1])]
140
+ )
141
+ block.insert(0, SAMPLE_ID, sample_ids)
142
+ block.insert(0, DATASET_ID, dataset_id)
143
+ blocks.append(block)
144
+ return pd.concat(blocks, ignore_index=True)
145
+
146
+
147
+ def main() -> None:
148
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
149
+ parser.add_argument("--modality", required=True, choices=MODALITIES)
150
+ parser.add_argument("--out", type=Path, default=Path("submission.parquet"))
151
+ parser.add_argument("--token", default=None, help="HF token, if you need one.")
152
+ args = parser.parse_args()
153
+
154
+ modality = MODALITIES[args.modality]
155
+ submission = build(download(modality, args.token), modality)
156
+ if args.out.suffix == ".csv":
157
+ submission.to_csv(args.out, index=False)
158
+ else:
159
+ submission.to_parquet(args.out, index=False)
160
+ print(f"\nWrote {args.out}: {len(submission)} rows. Upload it on the Submit tab.")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
render.py ADDED
@@ -0,0 +1,873 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The PRIMO pages, rendered as ``pm-*`` markup for ``gr.HTML`` blocks.
2
+
3
+ Gradio has no card or rich-table component, so every page here is a string of
4
+ HTML styled entirely by ``primo.css`` -- the top bar, the hero, the leaderboard
5
+ tables, the release notes and the tasks table. The functions are pure: they take
6
+ the data the app already fetched (``Board`` objects, the results ``DataFrame``,
7
+ the registry) and return a string, so they unit-test without Gradio or a network.
8
+
9
+ Two rules hold everywhere:
10
+
11
+ * Every string that comes from the registry or from a submission goes through
12
+ ``html.escape`` -- board names, blurbs, disease names and, above all, the
13
+ user-chosen model names that become table cells and column headers.
14
+ * New submissions can carry a public institution, author-submission check and paper
15
+ link. Private legacy ``hf_username`` values stay hidden. Public strings and
16
+ links are validated and escaped before rendering.
17
+
18
+ Pages are reload-based ``<a href="?tab=name">`` links in the top bar; the
19
+ leaderboard view is picked with Gradio pills in ``app.py``. On a closed board
20
+ version, every link adds ``&version=<number>`` so the visitor stays on it, except
21
+ Submit: a submission always goes to the current version.
22
+ """
23
+
24
+ import math
25
+ import numbers
26
+ import re
27
+ from html import escape
28
+ from itertools import pairwise
29
+ from urllib.parse import urlparse
30
+
31
+ import pandas as pd
32
+ from boards import (
33
+ AREA_GROUP,
34
+ CATEGORY_GROUP,
35
+ MODALITY_GROUP,
36
+ Board,
37
+ in_group,
38
+ open_in_group,
39
+ )
40
+ from leaderboard import (
41
+ ELO_ANCHOR_MODEL,
42
+ ModelCell,
43
+ TaskCell,
44
+ per_task_table,
45
+ ranked_table,
46
+ tasks_table,
47
+ )
48
+ from results import (
49
+ BASELINE_TAG,
50
+ IS_BASELINE,
51
+ MODEL_NAME,
52
+ split_hvg_name,
53
+ with_baseline_flag,
54
+ without_hvg,
55
+ )
56
+ from versions import CURRENT, VERSIONS, Version
57
+
58
+ SECTIONS = (MODALITY_GROUP, AREA_GROUP, CATEGORY_GROUP)
59
+ VERSION_PARAM = "version"
60
+ NAME_COLUMNS = frozenset({"Model", "Best"})
61
+ STRONG_COLUMNS = frozenset({"Task"})
62
+ METRIC_COLUMNS = frozenset({"Metric"})
63
+ RIGHT_COLUMNS = frozenset({"Rank", "Patients", "Submissions count"})
64
+ METRIC_GUIDES = {
65
+ "AUROC": "Range: 0 to 1 · Random predictor: 0.5",
66
+ "Pearson": "Range: -1 to 1 · Random predictor: 0 (expected)",
67
+ "Residual Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
68
+ "Centered Spearman score": "Range: -1 to 1 · Random predictor: 0 (expected)",
69
+ }
70
+ PUBLIC_CHIP = (
71
+ ' <span class="pm-public-chip" title="Labels downloadable from the PRIMO '
72
+ 'dataset" aria-label="Public labels">Public</span>'
73
+ )
74
+ BASELINE_CHIP = (
75
+ '<span class="pm-tag pm-tag--base" title="Our reference model">Baseline</span>'
76
+ )
77
+
78
+
79
+ def evaluation_confirmation() -> str:
80
+ """Render the confirmation shown before a model evaluation starts."""
81
+ return (
82
+ '<dialog id="pm-evaluation-confirmation" class="pm-dialog" '
83
+ 'aria-labelledby="pm-evaluation-confirmation-title">'
84
+ '<form method="dialog" class="pm-dialog-surface">'
85
+ '<p class="pm-over">Confirm submission</p>'
86
+ '<h2 id="pm-evaluation-confirmation-title">'
87
+ "Evaluate and submit this model?</h2>"
88
+ "<p>Evaluation can take several minutes. If it succeeds, this submission "
89
+ "will update the public leaderboards.</p>"
90
+ '<div class="pm-dialog-actions">'
91
+ '<button class="pm-btn pm-btn--outline" value="cancel">Cancel</button>'
92
+ '<button class="pm-btn pm-btn--primary" value="confirm">'
93
+ "Evaluate model</button>"
94
+ "</div></form></dialog>"
95
+ )
96
+
97
+
98
+ def evaluation_status() -> str:
99
+ """Render the progress message shown while an evaluation runs."""
100
+ return (
101
+ '<div class="pm-evaluation-status" role="status" aria-live="polite">'
102
+ '<svg class="pm-spinner" aria-hidden="true" viewBox="0 0 24 24">'
103
+ '<circle cx="12" cy="12" r="9"></circle>'
104
+ '<path d="M12 3a9 9 0 0 1 9 9"></path></svg>'
105
+ "<span>Your embeddings are being evaluated... This can take a few "
106
+ "minutes.</span></div>"
107
+ )
108
+
109
+
110
+ def _href(query: str, version: Version = CURRENT) -> str:
111
+ """A reload link that stays on ``version``; the current one needs no tag."""
112
+ if version != CURRENT:
113
+ query = f"{query}&{VERSION_PARAM}={version.number}"
114
+ return escape(f"?{query}")
115
+
116
+
117
+ def _number(version: Version) -> tuple[int, ...]:
118
+ """The version number as integers, so ``0.10`` comes after ``0.9``."""
119
+ return tuple(int(part) for part in version.number.split("."))
120
+
121
+
122
+ def _closed_on(version: Version) -> str:
123
+ """The day the version after ``version`` opened."""
124
+ later = [listed for listed in VERSIONS if _number(listed) > _number(version)]
125
+ return min(later, key=_number).opened
126
+
127
+
128
+ # ------------------------------------------------------------- html tables
129
+ def _fmt_value(value, is_score: bool) -> str | None:
130
+ """Display text for one cell; ``None`` marks a blank (``n/a``) cell."""
131
+ try:
132
+ if pd.isna(value):
133
+ return None
134
+ except (TypeError, ValueError):
135
+ pass
136
+ if isinstance(value, str):
137
+ return value
138
+ if isinstance(value, numbers.Integral):
139
+ return str(int(value))
140
+ if isinstance(value, numbers.Real):
141
+ if not math.isfinite(float(value)):
142
+ return None
143
+ return f"{float(value):.3f}" if is_score else str(value)
144
+ return str(value)
145
+
146
+
147
+ def _model_cell_html(cell: ModelCell) -> str:
148
+ """One model on one line: its name, its chips, then a dotted leader.
149
+
150
+ Only escaped HTTP(S) paper links become links.
151
+ """
152
+ label = escape(str(cell).removesuffix(f" {BASELINE_TAG}"))
153
+ parsed = urlparse(cell.paper_link)
154
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
155
+ url = escape(cell.paper_link, quote=True)
156
+ label = (
157
+ f'<a class="pm-model-link" href="{url}" target="_blank" '
158
+ f'rel="noopener noreferrer">{label}</a>'
159
+ )
160
+ chips = []
161
+ if cell.is_hvg:
162
+ chips.append(
163
+ '<span class="pm-tag pm-tag--hvg" title="Concatenated with the HVG-1200 '
164
+ 'baseline features" aria-label="Concatenated with the HVG-1200 baseline '
165
+ 'features">+ HVG</span>'
166
+ )
167
+ if cell.is_baseline:
168
+ chips.append(BASELINE_CHIP)
169
+ if cell.is_author_submission:
170
+ chips.append(
171
+ '<span class="pm-tag pm-tag--author" title="Submitted by the model '
172
+ 'authors" aria-label="Submitted by the model authors">✓ Authors</span>'
173
+ )
174
+ return (
175
+ f'<div class="pm-pipe"><span class="pm-lrn">{label}</span>'
176
+ f'{"".join(chips)}<span class="pm-leader"></span></div>'
177
+ )
178
+
179
+
180
+ def _with_baseline_chip(name: str) -> str:
181
+ """The escaped name, its ``(baseline)`` tag shown as the Baseline chip."""
182
+ if not name.endswith(f" {BASELINE_TAG}"):
183
+ return escape(name)
184
+ return f"{escape(name.removesuffix(f' {BASELINE_TAG}'))} {BASELINE_CHIP}"
185
+
186
+
187
+ def _score_columns(df: pd.DataFrame) -> list[str]:
188
+ """Numeric columns to format and bold -- ``Rank`` is an index, not a score."""
189
+ return [c for c in df.select_dtypes("number").columns if c not in RIGHT_COLUMNS]
190
+
191
+
192
+ def _score_guide(col: str, row: pd.Series) -> str | None:
193
+ """Return metric context for a native score cell, when identifiable."""
194
+ metric = next((name for name in METRIC_GUIDES if f"({name})" in col), None)
195
+ if metric is None and col not in {"Elo", "Mean rank"}:
196
+ metric = str(row.get("Metric", ""))
197
+ return METRIC_GUIDES.get(metric) if metric is not None else None
198
+
199
+
200
+ def _bold_cells(df: pd.DataFrame, score_cols: list[str], axis: int) -> set:
201
+ """Which ``(row, col)`` cells hold the best value.
202
+
203
+ ``axis=0`` bolds the best model per column (the ranked table, read down);
204
+ ``axis=1`` bolds the best model per row (the per-task table, read across).
205
+ """
206
+ bold = set()
207
+ if not score_cols:
208
+ return bold
209
+ if axis == 0:
210
+ for col in score_cols:
211
+ best = (
212
+ df[col].min(skipna=True)
213
+ if col == "Mean rank"
214
+ else df[col].max(skipna=True)
215
+ )
216
+ if pd.notna(best):
217
+ for idx, value in df[col].items():
218
+ if pd.notna(value) and value == best:
219
+ bold.add((idx, col))
220
+ else:
221
+ for idx, row in df.iterrows():
222
+ present = {c: row[c] for c in score_cols if pd.notna(row[c])}
223
+ if present:
224
+ best = max(present.values())
225
+ bold.update((idx, c) for c, v in present.items() if v == best)
226
+ return bold
227
+
228
+
229
+ def _cell_class(col: str, is_score: bool, blank: bool) -> str:
230
+ if col in NAME_COLUMNS:
231
+ return "pm-name"
232
+ if col in STRONG_COLUMNS:
233
+ return "pm-strong"
234
+ if col in METRIC_COLUMNS:
235
+ return "pm-metric"
236
+ if is_score or col in RIGHT_COLUMNS:
237
+ return "pm-num pm-num--dim" if blank else "pm-num"
238
+ return ""
239
+
240
+
241
+ def _task_tooltip(task: dict) -> str:
242
+ """Build the expanded clinical context shown for a task name."""
243
+ description = str(task.get("description") or "").strip().rstrip(".")
244
+ diseases = (
245
+ ", ".join(str(d) for d in task.get("diseases") or []) or "the listed cohort"
246
+ )
247
+ patients = task.get("n_subjects") or task.get("n_samples")
248
+ patient_text = (
249
+ f"{patients:,} patients"
250
+ if isinstance(patients, int)
251
+ else "an unspecified number of patients"
252
+ )
253
+ n_samples = task.get("n_samples")
254
+ if (
255
+ isinstance(n_samples, int)
256
+ and isinstance(patients, int)
257
+ and n_samples != patients
258
+ ):
259
+ patient_text += f" ({n_samples:,} collection samples)"
260
+ modality = str(task.get("modality") or "omics")
261
+ tissue = str(task.get("tissue") or "unspecified tissue")
262
+ target = str(task.get("target") or task.get("title") or "the task target")
263
+ return (
264
+ f"{description}. Patients: {patient_text} with {diseases}; "
265
+ f"input data: {modality} profiles from {tissue.lower()}; "
266
+ f"outcome: {target}."
267
+ )
268
+
269
+
270
+ def _df_to_table(
271
+ df: pd.DataFrame,
272
+ bold_axis: int | None,
273
+ empty: str,
274
+ task_guides: dict[str, dict] | None = None,
275
+ ) -> str:
276
+ """Render a DataFrame as a ``pm-table``, escaping every header and cell."""
277
+ if df.empty:
278
+ return f'<p class="pm-caption">{escape(empty)}</p>'
279
+ repeat_sd = df.attrs.get("repeat_standard_deviations", {})
280
+ df = df.copy(deep=False)
281
+ df.attrs = {}
282
+ score_cols = _score_columns(df)
283
+ bold = _bold_cells(df, score_cols, bold_axis) if bold_axis is not None else set()
284
+
285
+ head = []
286
+ for index, col in enumerate(df.columns):
287
+ name = _with_baseline_chip(str(col))
288
+ align = (
289
+ ' style="text-align:right"'
290
+ if col in score_cols or col in RIGHT_COLUMNS
291
+ else ""
292
+ )
293
+ head.append(
294
+ f'<th class="pm-sort" data-sort-index="{index}" tabindex="0" '
295
+ f'role="button" aria-sort="none" title="Sort by {escape(str(col))}"'
296
+ f"{align}>{name}</th>"
297
+ )
298
+ body = []
299
+ for idx, row in df.iterrows():
300
+ cells = []
301
+ for col in df.columns:
302
+ is_score = col in score_cols
303
+ text = _fmt_value(row[col], is_score)
304
+ cls = _cell_class(col, is_score, text is None)
305
+ weight = ' style="font-weight:700"' if (idx, col) in bold else ""
306
+ guide = (
307
+ _score_guide(str(col), row) if is_score and text is not None else None
308
+ )
309
+ shown = (
310
+ _model_cell_html(row[col])
311
+ if isinstance(row[col], ModelCell)
312
+ else "n/a"
313
+ if text is None
314
+ else _with_baseline_chip(text)
315
+ if col in NAME_COLUMNS
316
+ else escape(text)
317
+ )
318
+ standard_deviation = repeat_sd.get((idx, col))
319
+ uncertainty = ""
320
+ if standard_deviation and text is not None:
321
+ deviation, repeats = standard_deviation
322
+ sd_text = f"{deviation:.3f}"
323
+ uncertainty = (
324
+ f" Standard deviation across {repeats} repeats: {sd_text}."
325
+ )
326
+ shown += f'<span class="pm-sd" aria-hidden="true"> ± {sd_text}</span>'
327
+ if guide:
328
+ escaped_guide = escape(guide, quote=True)
329
+ score_label = escape(f"Score {text}.{uncertainty} {guide}", quote=True)
330
+ shown = (
331
+ '<span class="pm-score-guide" tabindex="0" '
332
+ f'aria-label="{score_label}" '
333
+ f'data-tooltip="{escaped_guide}">{shown}</span>'
334
+ )
335
+ if task_guides and col == "Task" and text is not None:
336
+ task = next(
337
+ (
338
+ candidate
339
+ for candidate in task_guides.values()
340
+ if candidate.get("title") == text
341
+ ),
342
+ None,
343
+ )
344
+ if task:
345
+ tooltip = _task_tooltip(task)
346
+ escaped_tooltip = escape(tooltip, quote=True)
347
+ task_label = escape(f"{text}. {tooltip}", quote=True)
348
+ shown = (
349
+ '<span class="pm-task-guide" tabindex="0" '
350
+ f'aria-label="{task_label}" data-tooltip="{escaped_tooltip}">'
351
+ f'<span class="pm-task-text">{shown}</span></span>'
352
+ )
353
+ if isinstance(row[col], TaskCell) and row[col].is_public:
354
+ shown += PUBLIC_CHIP
355
+ sort_value = (
356
+ f' data-sort-value="{escape(text, quote=True)}"'
357
+ if is_score and text is not None
358
+ else ""
359
+ )
360
+ cells.append(f'<td class="{cls}"{sort_value}{weight}>{shown}</td>')
361
+ body.append(f"<tr>{''.join(cells)}</tr>")
362
+ return (
363
+ '<div class="pm-table-wrap"><table class="pm-table" style="table-layout:auto">'
364
+ f"<thead><tr>{''.join(head)}</tr></thead>"
365
+ f"<tbody>{''.join(body)}</tbody></table></div>"
366
+ )
367
+
368
+
369
+ # ------------------------------------------------------------------- tasks
370
+ def render_tasks(by_id: dict[str, dict]) -> str:
371
+ """The Tasks page: one scannable row per target. Provenance is never shown."""
372
+ df = tasks_table(list(by_id.values()))
373
+ table = _df_to_table(
374
+ df,
375
+ bold_axis=None,
376
+ empty="The task registry is unavailable right now.",
377
+ task_guides=by_id,
378
+ )
379
+ return (
380
+ '<div class="pm-head"><div><h1>Tasks</h1>'
381
+ f"<p>{len(df)} targets. One fixed task probe reads each "
382
+ "one out of your embedding; the cohorts stay anonymous, the biology does "
383
+ "not.</p></div></div>"
384
+ f'<div class="pm-body">{table}</div>'
385
+ )
386
+
387
+
388
+ # ------------------------------------------------------------- leaderboard
389
+ SPACE_URL = "https://huggingface.co/spaces/PRIMOmics/primo-eval"
390
+ DATA_URL = "https://huggingface.co/datasets/PRIMOmics/primo"
391
+ PAPER_URL = "https://openreview.net/forum?id=v2SA8gHwqo"
392
+
393
+ NAV_TABS = (
394
+ ("leaderboard", "Leaderboard"),
395
+ ("tasks", "Tasks"),
396
+ ("method", "Method"),
397
+ ("contribute", "Contribute"),
398
+ )
399
+ LEDE = (
400
+ "PRIMO ranks omics foundation models on real "
401
+ '<a target="_self" href="{tasks}">clinical tasks</a>. Your model turns each '
402
+ "patient's omics profile into an embedding. One fixed probe, the same for every "
403
+ "model, then predicts hidden outcomes such as treatment response or prognosis, "
404
+ "so the score measures the embedding."
405
+ )
406
+ RANKED_NOTE = (
407
+ '<p class="pm-note">We score zero-shot representations, so a score is not the '
408
+ "best a model can reach. Only models that covered every task scored by the "
409
+ f"<code>{ELO_ANCHOR_MODEL}</code> baseline are ranked. <code>+ HVG</code> rows "
410
+ "concatenate a model with the baseline's features.</p>"
411
+ )
412
+ N_TOP = 3
413
+ ORDINAL_SUFFIXES = {1: "st", 2: "nd", 3: "rd"}
414
+ NOT_SCORES = frozenset({"Rank", "Submissions count"})
415
+ RANKED_LABELS = {"Submissions count": "Subm."}
416
+ RANKED_NOTES = {"Model": "paper linked when given"}
417
+ RANKED_HINTS = {
418
+ "Elo": f"The {ELO_ANCHOR_MODEL} baseline sits at 1000.",
419
+ "Mean rank": "Lower is better.",
420
+ "Submissions count": (
421
+ "Submissions: the most times one task of this model was submitted."
422
+ ),
423
+ }
424
+ SHORT_METRICS = {
425
+ "Centered Spearman score": "centered Spearman",
426
+ "Residual Spearman score": "residual Spearman",
427
+ }
428
+ CATEGORY_HEADER = re.compile(r"^(?P<name>.+) \((?P<metric>[^()]+)\)$")
429
+ STRABLE_URL = "https://gblayer.github.io/strable-leaderboard/"
430
+ BOARD_SUB = (
431
+ '<p class="pm-board-sub">Models are ranked by Elo over the tasks of the view, '
432
+ f"anchored on the {ELO_ANCHOR_MODEL} baseline at 1000. Task columns show the "
433
+ "mean score, each in its own metric.</p>"
434
+ )
435
+ ART_GENES = (0.55, 0.9, 0.4, 0.75, 0.35, 0.95, 0.5, 0.8, 0.45, 0.7, 0.3, 0.85, 0.6, 0.4)
436
+ ART_EMBEDDING = (0.7, 1.0, 0.6, 0.9, 0.75, 0.95)
437
+ ART_LAYERS = ((4, 132), (5, 150), (3, 168))
438
+ ART_TASKS = ("Treatment response", "Prognosis", "Disease severity", "Molecular subtype")
439
+ CREDIT = (
440
+ '<p class="pm-credit">Design inspired by the '
441
+ f'<a href="{STRABLE_URL}" target="_blank" rel="noopener noreferrer">STRABLE '
442
+ "leaderboard</a>.</p>"
443
+ )
444
+
445
+
446
+ def _art_text(x: float, y: float, text: str, cls: str, anchor: str = "middle") -> str:
447
+ return f'<text class="{cls}" x="{x}" y="{y}" text-anchor="{anchor}">{text}</text>'
448
+
449
+
450
+ def _art_arrow(x1: float, x2: float, y: float) -> str:
451
+ return (
452
+ f'<line class="pm-art-line" x1="{x1}" y1="{y}" x2="{x2 - 6}" y2="{y}"></line>'
453
+ f'<path class="pm-art-head" d="M{x2 - 7} {y - 4} L{x2} {y} L{x2 - 7} {y + 4} Z">'
454
+ "</path>"
455
+ )
456
+
457
+
458
+ def _hero_art() -> str:
459
+ """The hero figure: a patient's omics, a model, one embedding, many tasks."""
460
+ parts = [
461
+ _art_text(58, 22, "Patient omics", "pm-art-h"),
462
+ _art_text(162, 22, "Your model", "pm-art-h"),
463
+ _art_text(254, 22, "Embedding", "pm-art-h"),
464
+ _art_text(351, 22, "Clinical tasks", "pm-art-h"),
465
+ '<circle class="pm-art-patient" cx="58" cy="44" r="8"></circle>',
466
+ '<path class="pm-art-patient" d="M45 66 Q58 50 71 66 Z"></path>',
467
+ ]
468
+ parts += [
469
+ f'<rect class="pm-art-{"b" if index % 3 == 0 else "a"}" x="47" '
470
+ f'y="{74 + index * 11}" width="22" height="9" rx="1.5" opacity="{level}"></rect>'
471
+ for index, level in enumerate(ART_GENES)
472
+ ]
473
+ parts += [
474
+ _art_text(58, 246, "gene expression", "pm-art-c"),
475
+ _art_arrow(78, 112, 150),
476
+ '<rect class="pm-art-box" x="114" y="112" width="96" height="76" rx="14"></rect>',
477
+ ]
478
+ nodes = [
479
+ [(162 + (k - (count - 1) / 2) * 16, y) for k in range(count)]
480
+ for count, y in ART_LAYERS
481
+ ]
482
+ parts += [
483
+ f'<line class="pm-art-edge" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"></line>'
484
+ for upper, lower in pairwise(nodes)
485
+ for x1, y1 in upper
486
+ for x2, y2 in lower
487
+ ]
488
+ parts += [
489
+ f'<circle class="pm-art-node" cx="{x}" cy="{y}" r="3.2"></circle>'
490
+ for layer in nodes
491
+ for x, y in layer
492
+ ]
493
+ parts += [
494
+ _art_text(162, 204, "omics foundation model", "pm-art-c"),
495
+ _art_arrow(212, 244, 150),
496
+ ]
497
+ parts += [
498
+ f'<rect class="pm-art-{"a" if index % 2 else "b"}" x="246" y="{100 + index * 17}" '
499
+ f'width="16" height="14" rx="2" opacity="{level}"></rect>'
500
+ for index, level in enumerate(ART_EMBEDDING)
501
+ ]
502
+ parts += [
503
+ _art_text(254, 218, "sample", "pm-art-c"),
504
+ _art_text(254, 230, "representation", "pm-art-c"),
505
+ ]
506
+ for index, task in enumerate(ART_TASKS):
507
+ y = 74 + index * 44
508
+ parts += [
509
+ f'<path class="pm-art-line" d="M268 150 C 278 150, 278 {y}, 288 {y}"></path>',
510
+ f'<rect class="pm-art-pill" x="290" y="{y - 14}" width="122" height="28" '
511
+ 'rx="14"></rect>',
512
+ _art_text(302, y + 4, task, "pm-art-t", anchor="start"),
513
+ ]
514
+ return (
515
+ '<div class="pm-art"><svg class="pm-hero-art" viewBox="0 0 420 268" '
516
+ 'role="img" aria-labelledby="pm-art-title"><title id="pm-art-title">A patient\'s '
517
+ "omics profile goes through your model, which returns one embedding per "
518
+ "sample. The same fixed probe then scores that embedding on clinical tasks."
519
+ f"</title>{''.join(parts)}</svg></div>"
520
+ )
521
+
522
+
523
+ def updated_on(df: pd.DataFrame) -> str:
524
+ """The day of the latest stored result, or ``""`` when there is none."""
525
+ if df.empty or "submitted_at" not in df:
526
+ return ""
527
+ return str(df["submitted_at"].astype(str).max())[:10]
528
+
529
+
530
+ def _model_count(df: pd.DataFrame) -> int:
531
+ """Submitted models, a ``+ HVG`` entry counted with its parent, baselines out."""
532
+ flagged = with_baseline_flag(df)
533
+ if flagged.empty:
534
+ return 0
535
+ return len(
536
+ {
537
+ split_hvg_name(str(model))[0]
538
+ for model, is_baseline in zip(flagged[MODEL_NAME], flagged[IS_BASELINE])
539
+ if not is_baseline
540
+ }
541
+ )
542
+
543
+
544
+ def version_notice(version: Version) -> str:
545
+ """On a closed version, a banner that says so and links to the current one."""
546
+ if version == CURRENT:
547
+ return ""
548
+ return (
549
+ '<div class="pm-notice" role="status">'
550
+ f"<b>Version v{escape(version.number)} is closed.</b> "
551
+ f"Its scores, from {escape(version.opened)} to "
552
+ f"{escape(_closed_on(version))}, are kept read-only. "
553
+ f'<a target="_self" href="{_href("tab=leaderboard")}">'
554
+ f"Go to the current version, v{escape(CURRENT.number)}</a></div>"
555
+ )
556
+
557
+
558
+ def _cite_dialog(version: Version) -> str:
559
+ """The window of the Cite button: how to cite PRIMO and the version on screen."""
560
+ bibtex = (
561
+ "@misc{primo2026,\n"
562
+ " title = {{PRIMO}: Patient Representations in Multi-Omics},\n"
563
+ " author = {{PRIMO contributors}},\n"
564
+ " year = {2026},\n"
565
+ f" note = {{Leaderboard version {version.number}}},\n"
566
+ f" howpublished = {{\\url{{{SPACE_URL}}}}}\n"
567
+ "}"
568
+ )
569
+ return (
570
+ '<dialog id="pm-cite-dialog" class="pm-dialog" aria-labelledby="pm-cite-title">'
571
+ '<form method="dialog" class="pm-dialog-surface">'
572
+ '<h2 id="pm-cite-title">Cite PRIMO</h2>'
573
+ "<p><b>Evaluating a model on PRIMO?</b> Please cite PRIMO and give the board "
574
+ "version you used, for example "
575
+ f"<code>PRIMO leaderboard v{escape(version.number)}</code>.</p>"
576
+ f"<pre>{escape(bibtex)}</pre>"
577
+ '<div class="pm-dialog-actions">'
578
+ '<button class="pm-btn pm-btn--outline" value="close">Close</button>'
579
+ "</div></form></dialog>"
580
+ )
581
+
582
+
583
+ def nav_html(active_tab: str, version: Version = CURRENT) -> str:
584
+ """The top bar: the PRIMO mark, the pages, the data, the paper, Submit and Cite.
585
+
586
+ On a closed version, every page link keeps it, except Submit, Cite names it,
587
+ and a banner under the bar says the version is closed.
588
+ """
589
+ links = [
590
+ f'<a class="{"pm-active" if tab == active_tab else ""}" target="_self" '
591
+ f'href="{_href(f"tab={tab}", version)}">{name}</a>'
592
+ for tab, name in NAV_TABS
593
+ ]
594
+ links += [
595
+ f'<a href="{url}" target="_blank" rel="noopener noreferrer">{name}</a>'
596
+ for url, name in ((DATA_URL, "Data"), (PAPER_URL, "Paper"))
597
+ ]
598
+ return (
599
+ '<header class="pm-nav"><a class="pm-logo" target="_self" '
600
+ f'href="{_href("tab=leaderboard", version)}" aria-label="PRIMO leaderboard">'
601
+ '<img src="/gradio_api/file=assets/primo-mark.webp" alt="">'
602
+ "<b>PRIMO</b></a>"
603
+ '<div class="pm-nav-right">'
604
+ f'<nav class="pm-nav-links" aria-label="Main">{"".join(links)}</nav>'
605
+ '<a class="pm-btn" target="_self" href="?tab=submit">Submit a model</a>'
606
+ '<button type="button" class="pm-btn pm-btn--outline" id="pm-cite-btn" '
607
+ 'aria-haspopup="dialog" aria-controls="pm-cite-dialog">Cite</button>'
608
+ f"</div></header>{_cite_dialog(version)}{version_notice(version)}"
609
+ )
610
+
611
+
612
+ def hero_html(boards: list[Board], df: pd.DataFrame, version: Version = CURRENT) -> str:
613
+ """The title block: version, PRIMO, what it is, its size, and the visual."""
614
+ layers = in_group(boards, MODALITY_GROUP)
615
+ stats = (
616
+ (sum(b.n_tasks for b in layers), "tasks"),
617
+ (sum(b.n_cohorts for b in layers), "cohorts"),
618
+ (sum(b.n_patients for b in layers), "patients"),
619
+ (len(layers), "modalities"),
620
+ (_model_count(df), "models"),
621
+ )
622
+ chips = "".join(f"<li><b>{n:,}</b> {name}</li>" for n, name in stats)
623
+ updated = updated_on(df)
624
+ stamp = (
625
+ f'<span class="pm-updated">Updated {escape(updated)}</span>' if updated else ""
626
+ )
627
+ badge = f'<span class="pm-ver">v{escape(version.number)}</span>'
628
+ if version != CURRENT:
629
+ badge = (
630
+ f'<span class="pm-ver pm-ver--closed">v{escape(version.number)} '
631
+ "· closed</span>"
632
+ )
633
+ return (
634
+ '<section class="pm-hero" aria-labelledby="pm-hero-title"><div>'
635
+ '<p class="pm-eyebrow"><span class="pm-label">Leaderboard</span>'
636
+ f'{badge}<a class="pm-ver-link" href="#pm-history">Release notes</a>'
637
+ f"{stamp}</p>"
638
+ '<h1 id="pm-hero-title">PRIMO</h1>'
639
+ '<p class="pm-expand"><span class="pm-xp"><b>P</b>atient <b>R</b>epresentations '
640
+ "<b>i</b>n <b>M</b>ulti-<b>O</b>mics</span></p>"
641
+ f'<p class="pm-lede">{LEDE.format(tasks=_href("tab=tasks", version))}</p>'
642
+ f'<ul class="pm-stats">{chips}</ul></div>'
643
+ f'<div class="pm-art-wrap">{_hero_art()}</div>'
644
+ "</section>"
645
+ )
646
+
647
+
648
+ def _ordinal(rank: int) -> str:
649
+ """``1`` -> ``1st``, ``12`` -> ``12th``."""
650
+ suffix = "th" if 10 <= rank % 100 <= 20 else ORDINAL_SUFFIXES.get(rank % 10, "th")
651
+ return f"{rank}{suffix}"
652
+
653
+
654
+ def _ranked_header(index: int, col: str, numeric: bool) -> str:
655
+ match = CATEGORY_HEADER.match(col)
656
+ if match:
657
+ name = match["name"]
658
+ note = SHORT_METRICS.get(match["metric"], match["metric"])
659
+ else:
660
+ name, note = RANKED_LABELS.get(col, col), RANKED_NOTES.get(col, "")
661
+ align = " pm-r" if numeric else ""
662
+ small = f"<small>{escape(note)}</small>" if note else ""
663
+ hint = escape(
664
+ f"Sort by {name.rstrip('.')}. {RANKED_HINTS.get(col, '')}".strip(), quote=True
665
+ )
666
+ return (
667
+ f'<th class="pm-sort{align}" data-sort-index="{index}" tabindex="0" '
668
+ f'role="button" aria-sort="none" title="{hint}">{escape(name)}{small}</th>'
669
+ )
670
+
671
+
672
+ def _ranked_cell(
673
+ table: pd.DataFrame, idx, col: str, bold: set, scores: list, numeric: set
674
+ ) -> str:
675
+ """One cell of the ranked table, on one line."""
676
+ value = table.at[idx, col]
677
+ if col == "Rank":
678
+ return (
679
+ f'<td class="pm-rank" data-sort-value="{int(value)}">'
680
+ f"{_ordinal(int(value))}</td>"
681
+ )
682
+ if isinstance(value, ModelCell):
683
+ return (
684
+ f'<td class="pm-name" data-sort-value="{escape(str(value), quote=True)}">'
685
+ f"{_model_cell_html(value)}</td>"
686
+ )
687
+ is_score = col in scores
688
+ text = _fmt_value(value, is_score)
689
+ if col not in numeric:
690
+ shown = "n/a" if text is None else escape(text)
691
+ dim = " pm-dim" if text is None else ""
692
+ return f'<td class="pm-inst{dim}">{shown}</td>'
693
+ if text is None:
694
+ return '<td class="pm-num pm-dim">n/a</td>'
695
+ shown = escape(text)
696
+ guide = _score_guide(col, table.loc[idx]) if is_score else None
697
+ if guide:
698
+ label = escape(f"Score {text}. {guide}", quote=True)
699
+ shown = (
700
+ f'<span class="pm-score-guide" tabindex="0" aria-label="{label}" '
701
+ f'data-tooltip="{escape(guide, quote=True)}">{shown}</span>'
702
+ )
703
+ cls = "pm-num"
704
+ if col == "Elo":
705
+ cls += " pm-key"
706
+ if (idx, col) in bold:
707
+ cls += " pm-best"
708
+ return (
709
+ f'<td class="{cls}" data-sort-value="{escape(text, quote=True)}">{shown}</td>'
710
+ )
711
+
712
+
713
+ def _count(n: int, noun: str) -> str:
714
+ """``<b>1</b> task``, ``<b>6</b> tasks``."""
715
+ return f"<b>{n:,}</b> {noun}{'' if n == 1 else 's'}"
716
+
717
+
718
+ def _view_head(board: Board, view_name: str) -> str:
719
+ """The head of the ranking card: the view, then how many tasks it holds."""
720
+ counts = " · ".join(
721
+ (
722
+ _count(board.n_tasks, "task"),
723
+ _count(board.n_cohorts, "cohort"),
724
+ _count(board.n_patients, "patient"),
725
+ )
726
+ )
727
+ return (
728
+ f'<div class="pm-card-head"><span class="pm-view">{escape(view_name)}</span>'
729
+ f'<span class="pm-view-n">{counts}</span></div>'
730
+ )
731
+
732
+
733
+ def _ranked_html(table: pd.DataFrame) -> str:
734
+ """The ranked table: one line per model, the top three framed."""
735
+ if table.empty:
736
+ return (
737
+ '<p class="pm-empty">No model has covered every scored task of this view '
738
+ "yet. Be the first to submit.</p>"
739
+ )
740
+ numeric = set(table.select_dtypes("number").columns)
741
+ scores = [c for c in table.columns if c in numeric and c not in NOT_SCORES]
742
+ bold = _bold_cells(table, scores, axis=0)
743
+ head = "".join(
744
+ _ranked_header(index, str(col), col in numeric)
745
+ for index, col in enumerate(table.columns)
746
+ )
747
+ n_top = min(N_TOP, len(table))
748
+ body = []
749
+ for position, idx in enumerate(table.index):
750
+ classes = []
751
+ if position < n_top:
752
+ classes.append("pm-top")
753
+ if position == 0:
754
+ classes.append("pm-top-first")
755
+ if position == n_top - 1:
756
+ classes.append("pm-top-last")
757
+ cells = "".join(
758
+ _ranked_cell(table, idx, str(col), bold, scores, numeric)
759
+ for col in table.columns
760
+ )
761
+ body.append(f'<tr class="{" ".join(classes)}">{cells}</tr>')
762
+ return (
763
+ '<div class="pm-tbl-wrap"><table class="pm-table pm-ranked">'
764
+ f"<thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody>"
765
+ "</table></div>"
766
+ )
767
+
768
+
769
+ def _per_task_details(per_task: pd.DataFrame, by_id: dict, board: Board) -> str:
770
+ """The per-task scores, folded under the ranking."""
771
+ table = _df_to_table(
772
+ per_task,
773
+ bold_axis=1,
774
+ empty="No submission has scored on this view yet.",
775
+ task_guides=by_id,
776
+ )
777
+ return (
778
+ '<details class="pm-more"><summary><span class="pm-title">Scores per task'
779
+ f'</span><span class="pm-more-n">{board.n_tasks} tasks</span></summary>'
780
+ '<p class="pm-note">Every submission, partial ones included. Read across a '
781
+ "row. Scores show mean ± SD when repeat-level results are available.</p>"
782
+ f"{table}</details>"
783
+ )
784
+
785
+
786
+ def render_view(
787
+ board: Board,
788
+ df: pd.DataFrame,
789
+ by_id: dict[str, dict],
790
+ view_name: str,
791
+ show_hvg: bool,
792
+ ) -> str:
793
+ """One view: its size, its ranking, then its per-task scores."""
794
+ rows = df if show_hvg else without_hvg(df)
795
+ head = _view_head(board, view_name)
796
+ if not by_id:
797
+ return (
798
+ f'<div class="pm-card">{head}<p class="pm-empty">The task registry could '
799
+ "not be loaded. Please retry in a moment.</p></div>"
800
+ )
801
+ if not board.task_ids:
802
+ return (
803
+ f'<div class="pm-card">{head}<p class="pm-empty">No task in this view '
804
+ "yet. Pick another therapeutic area or task.</p></div>"
805
+ )
806
+ ranked = _ranked_html(ranked_table(rows, by_id, board))
807
+ return (
808
+ f'<div class="pm-card">{head}{ranked}</div>'
809
+ + RANKED_NOTE
810
+ + _per_task_details(per_task_table(rows, by_id, board), by_id, board)
811
+ )
812
+
813
+
814
+ def history_html(df: pd.DataFrame, version: Version = CURRENT) -> str:
815
+ """The release notes: every board version, what changed, and its board.
816
+
817
+ A version other than the one on screen links to its board, so a closed
818
+ version stays one click away now that no menu lists them.
819
+ """
820
+ cite = ", ".join(
821
+ part
822
+ for part in (f"PRIMO leaderboard v{version.number}", updated_on(df), SPACE_URL)
823
+ if part
824
+ )
825
+ items = []
826
+ for listed in VERSIONS:
827
+ state = "current" if listed == CURRENT else "closed"
828
+ span = (
829
+ f"since {listed.opened}"
830
+ if listed == CURRENT
831
+ else f"{listed.opened} to {_closed_on(listed)}"
832
+ )
833
+ link = (
834
+ ""
835
+ if listed == version
836
+ else f'<a target="_self" href="{_href("tab=leaderboard", listed)}">'
837
+ "View this board</a>"
838
+ )
839
+ items.append(
840
+ f'<li class="{"pm-on" if listed == version else ""}">'
841
+ f"<b>v{escape(listed.number)}</b><div>"
842
+ f'<p class="pm-ver-meta"><span class="pm-chip pm-chip--{state}">{state}'
843
+ f"</span> {escape(span)} · release {escape(listed.release)}</p>"
844
+ f"<p>{escape(listed.notes)}</p></div>{link}</li>"
845
+ )
846
+ return (
847
+ '<section class="pm-history" id="pm-history" '
848
+ 'aria-labelledby="pm-history-title"><div>'
849
+ '<h2 class="pm-title" id="pm-history-title">Release notes</h2>'
850
+ '<p class="pm-history-sub">What changed in each board version. A closed '
851
+ "version keeps its board, read-only. To cite a rank: "
852
+ f"<span>{escape(cite)}</span>.</p></div><ol>{''.join(items)}</ol></section>"
853
+ )
854
+
855
+
856
+ def cta_html(boards: list[Board], version: Version = CURRENT) -> str:
857
+ """The closing panel: an open call for cohorts, with examples of what is missing."""
858
+ gaps = [b.name for group in SECTIONS for b in open_in_group(boards, group)]
859
+ missing = ""
860
+ if gaps:
861
+ listed = gaps[0] if len(gaps) == 1 else f"{', '.join(gaps[:-1])} and {gaps[-1]}"
862
+ missing = f"Still missing, for example: {listed}. "
863
+ return (
864
+ '<section class="pm-ask" aria-labelledby="pm-ask-title"><div>'
865
+ '<p class="pm-label">Help us grow PRIMO</p>'
866
+ '<h2 id="pm-ask-title">Which cohort should PRIMO cover next?</h2></div>'
867
+ '<div class="pm-ask-go"><p>PRIMO is open to any omics layer and any disease '
868
+ f"area. {escape(missing)}Bring a cohort, or propose a task on the cohorts "
869
+ "already here.</p>"
870
+ f'<a class="pm-btn" target="_self" href="{_href("tab=contribute", version)}">'
871
+ "Contribute</a>"
872
+ "</div></section>"
873
+ )
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ numpy==2.5.4
2
+ pandas==2.3.3
3
+ pyarrow
4
+ joblib==1.6.0
5
+ scikit-learn==1.9.1
6
+ scipy==1.18.1
7
+ pyyaml
8
+ huggingface_hub<1.0
9
+ audioop-lts; python_version >= "3.13"
rescore.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rescore the live entries of a board version into the current version.
2
+
3
+ A new board version opens with an empty board, as its harness has scored nothing
4
+ yet. This maintainer tool fills it from the archived embedding files, so nobody
5
+ has to submit again. An entry is live when it holds the latest row of a slot of
6
+ the old version, on a task that still exists.
7
+
8
+ - Each live submission is scored again, plain and ``+ HVG``, at the current pins.
9
+ Its rows keep the model name, submission id, credits and the archive's
10
+ ``submitted_at``, and carry the current version.
11
+ - The ``HVG-1200-genes`` baseline is scored again from the reference features,
12
+ which are its submission.
13
+ - An entry keeps its task set: the tasks its old rows covered that still exist,
14
+ plus the tasks the new version adds. ``--all-tasks`` scores every task the file
15
+ covers instead.
16
+ - The datasets the current manifest no longer lists are dropped from the file,
17
+ because ``score_all`` would refuse the whole file.
18
+ - A submission is scored again only on the tasks where the current version holds
19
+ no row of its entry from the same time or later. A second run after the deploy
20
+ then only picks up the submissions made in between.
21
+ - An archive whose manifest records no representation is read as an embedding,
22
+ as the Space scored it.
23
+ - An entry that fails, or that gets no score on a task it held, stops the run
24
+ before anything is published, with or without ``--all-tasks``. To publish the
25
+ others, rerun with ``--models`` and the entries to keep.
26
+
27
+ ``--from legacy`` reads the rows written before versions existed.
28
+
29
+ HF_TOKEN=... python rescore.py --from 0.1 --dry-run
30
+ HF_TOKEN=... python rescore.py --from 0.1 --checkpoint-dir ckpt --out rows.csv
31
+ HF_TOKEN=... python rescore.py --from 0.1 --checkpoint-dir ckpt --publish
32
+ """
33
+
34
+ import argparse
35
+ import json
36
+ import logging
37
+ import os
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from tempfile import TemporaryDirectory
41
+
42
+ import pandas as pd
43
+ from evaluator import (
44
+ DATASET_ID,
45
+ EMBEDDING,
46
+ GENE_SPACE,
47
+ RESULTS_REPO,
48
+ TASK_ID,
49
+ EvaluatorError,
50
+ SubmissionError,
51
+ _norm_id,
52
+ fetch_manifest,
53
+ fetch_tasks_registry,
54
+ load_submission,
55
+ manifest_ids,
56
+ score_all,
57
+ scoreable_tasks,
58
+ )
59
+ from leaderboard import latest_only
60
+ from results import (
61
+ ARCHIVE_DIR,
62
+ ARCHIVE_MANIFEST,
63
+ HVG_REFERENCE_MODEL,
64
+ HVG_TAG,
65
+ INSTITUTION,
66
+ IS_AUTHOR_SUBMISSION,
67
+ IS_BASELINE,
68
+ MODEL_NAME,
69
+ OWNER,
70
+ PAPER_LINK,
71
+ SUBMISSION_ID,
72
+ append_results,
73
+ archive_paths,
74
+ fetch_hvg_reference,
75
+ hvg_name,
76
+ read_results,
77
+ task_rows,
78
+ version_rows,
79
+ with_baseline_flag,
80
+ with_owner,
81
+ )
82
+ from versions import CURRENT, VERSIONS
83
+
84
+ LEGACY = "legacy"
85
+ CREDITS = (OWNER, INSTITUTION, IS_AUTHOR_SUBMISSION, PAPER_LINK)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Carry:
90
+ """One live entry to score again: an archived file, or the HVG baseline.
91
+
92
+ ``archive`` is the file's path in the results repo, or None for the baseline,
93
+ whose file is the reference. ``task_ids`` None means every task of the file.
94
+ ``held`` are the tasks its old rows covered: each one must score again.
95
+ """
96
+
97
+ model_name: str
98
+ is_baseline: bool
99
+ submission_id: str
100
+ submitted_at: str
101
+ modality: str | None
102
+ representation: str
103
+ archive: str | None
104
+ task_ids: frozenset[str] | None
105
+ held: frozenset[str]
106
+ credits: dict = field(default_factory=dict, compare=False)
107
+
108
+
109
+ def representation_of(manifest: dict) -> str:
110
+ """How to read an archive: as its manifest says, else as an embedding."""
111
+ return manifest.get("representation", EMBEDDING)
112
+
113
+
114
+ def _slots(rows: pd.DataFrame) -> list[tuple[str, bool, str]]:
115
+ """The ``(model, is_baseline, task)`` slot of each row."""
116
+ return [
117
+ (str(model), bool(is_baseline), _norm_id(task_id))
118
+ for model, is_baseline, task_id in zip(
119
+ rows[MODEL_NAME], rows[IS_BASELINE], rows[TASK_ID]
120
+ )
121
+ ]
122
+
123
+
124
+ def _newest(rows: pd.DataFrame) -> dict[tuple[str, bool, str], str]:
125
+ """The newest ``submitted_at`` of each slot of ``rows``."""
126
+ flagged = with_baseline_flag(rows)
127
+ newest = {}
128
+ for slot, date in zip(_slots(flagged), flagged["submitted_at"].astype(str)):
129
+ newest[slot] = max(newest.get(slot, ""), date)
130
+ return newest
131
+
132
+
133
+ def _open_rows(
134
+ rows: pd.DataFrame, newest: dict, since: str | None = None
135
+ ) -> pd.DataFrame:
136
+ """The ``rows`` whose slot holds no row from ``since`` or later in ``newest``.
137
+
138
+ Without ``since``, each row is compared with its own date.
139
+ """
140
+ dates = rows["submitted_at"].astype(str) if since is None else [since] * len(rows)
141
+ keep = [newest.get(slot, "") < date for slot, date in zip(_slots(rows), dates)]
142
+ return rows[pd.Series(keep, index=rows.index, dtype=bool)]
143
+
144
+
145
+ def _task_set(
146
+ held: frozenset[str], added: set[str], all_tasks: bool
147
+ ) -> frozenset[str] | None:
148
+ """The held tasks plus the added ones, or None for every task."""
149
+ if all_tasks:
150
+ return None
151
+ return held | frozenset(added)
152
+
153
+
154
+ def carried(
155
+ old: pd.DataFrame,
156
+ new: pd.DataFrame,
157
+ manifests: list[dict],
158
+ task_ids: set[str],
159
+ added: set[str],
160
+ all_tasks: bool = False,
161
+ ) -> tuple[list[Carry], list[str]]:
162
+ """The live entries of ``old`` to score into ``new``, and those with no archive."""
163
+ latest = with_owner(latest_only(old))
164
+ if latest.empty:
165
+ return [], []
166
+ live = latest[latest[TASK_ID].map(_norm_id).isin(task_ids)]
167
+ plain = live[
168
+ ~live[IS_BASELINE] & ~live[MODEL_NAME].astype(str).str.endswith(HVG_TAG)
169
+ ]
170
+ newest = _newest(new)
171
+ carries = []
172
+ for manifest in manifests:
173
+ model, submission_id = manifest["model_name"], manifest["submission_id"]
174
+ rows = plain[
175
+ (plain[SUBMISSION_ID] == submission_id) & (plain[MODEL_NAME] == model)
176
+ ]
177
+ rows = _open_rows(rows, newest, manifest["submitted_at"])
178
+ if rows.empty:
179
+ continue
180
+ parent = rows.iloc[-1]
181
+ held = frozenset(rows[TASK_ID].map(_norm_id))
182
+ carries.append(
183
+ Carry(
184
+ model_name=model,
185
+ is_baseline=False,
186
+ submission_id=submission_id,
187
+ submitted_at=manifest["submitted_at"],
188
+ modality=manifest["modality"],
189
+ representation=representation_of(manifest),
190
+ archive=archive_paths(submission_id, manifest["source_format"])[0],
191
+ task_ids=_task_set(held, added, all_tasks),
192
+ held=held,
193
+ credits={key: parent[key] for key in CREDITS},
194
+ )
195
+ )
196
+ archived = {(m["model_name"], m["submission_id"]) for m in manifests}
197
+ orphans = sorted(
198
+ {
199
+ row[MODEL_NAME]
200
+ for slot, (_, row) in zip(_slots(plain), plain.iterrows())
201
+ if (row[MODEL_NAME], row[SUBMISSION_ID]) not in archived
202
+ and newest.get(slot, "") < str(row["submitted_at"])
203
+ }
204
+ )
205
+ baseline = live[live[IS_BASELINE] & (live[MODEL_NAME] == HVG_REFERENCE_MODEL)]
206
+ if not baseline.empty:
207
+ since = str(baseline["submitted_at"].astype(str).max())
208
+ baseline = _open_rows(baseline, newest)
209
+ if not baseline.empty:
210
+ held = frozenset(baseline[TASK_ID].map(_norm_id))
211
+ carries.append(
212
+ Carry(
213
+ model_name=HVG_REFERENCE_MODEL,
214
+ is_baseline=True,
215
+ submission_id="",
216
+ submitted_at=since,
217
+ modality=None,
218
+ representation=GENE_SPACE,
219
+ archive=None,
220
+ task_ids=_task_set(held, added, all_tasks),
221
+ held=held,
222
+ )
223
+ )
224
+ return carries, orphans
225
+
226
+
227
+ def without_retired(path: Path, valid: set[str], out: Path) -> tuple[Path, list[str]]:
228
+ """The file without the datasets ``valid`` no longer lists, and their ids.
229
+
230
+ The file is rewritten to ``out`` only when a dataset has to go.
231
+ """
232
+ blocks = load_submission(path)
233
+ retired = sorted(set(blocks) - valid)
234
+ if not retired:
235
+ return path, []
236
+ kept = [
237
+ block.assign(**{DATASET_ID: dataset_id})
238
+ for dataset_id, block in blocks.items()
239
+ if dataset_id in valid
240
+ ]
241
+ if not kept:
242
+ raise SubmissionError(f"no dataset of the file is still listed: {retired}")
243
+ frame = pd.concat(kept, ignore_index=True)
244
+ frame.columns = frame.columns.map(str)
245
+ frame.to_parquet(out, index=False)
246
+ return out, retired
247
+
248
+
249
+ def rescore(
250
+ carry: Carry,
251
+ source: Path,
252
+ reference: Path,
253
+ datasets: list[dict],
254
+ registry: list[dict],
255
+ workdir: Path,
256
+ token: str | None,
257
+ checkpoint_dir: Path | None = None,
258
+ ) -> list[dict]:
259
+ """The rows of ``carry`` in the current version, plain and ``+ HVG``."""
260
+ name = carry.submission_id or carry.model_name
261
+ path, retired = without_retired(
262
+ source, manifest_ids(datasets), workdir / f"{name}.parquet"
263
+ )
264
+ if retired:
265
+ logging.warning(
266
+ "%s: dropped the retired datasets %s", carry.model_name, retired
267
+ )
268
+ tasks = [
269
+ task
270
+ for task in registry
271
+ if carry.task_ids is None or _norm_id(task[TASK_ID]) in carry.task_ids
272
+ ]
273
+
274
+ def _rows(model: str, features: Path | None) -> list[dict]:
275
+ result = score_all(
276
+ path,
277
+ token,
278
+ modality=carry.modality,
279
+ representation=carry.representation,
280
+ datasets=datasets,
281
+ tasks=tasks,
282
+ checkpoint_dir=checkpoint_dir,
283
+ reference=features,
284
+ )
285
+ for invalid in result["invalid"]:
286
+ logging.warning(
287
+ "%s: no score on %s: %s", model, invalid[TASK_ID], invalid["reason"]
288
+ )
289
+ return task_rows(
290
+ model, result, carry.submitted_at, carry.submission_id, carry.credits
291
+ )
292
+
293
+ if carry.is_baseline:
294
+ return [{**row, IS_BASELINE: True} for row in _rows(carry.model_name, None)]
295
+ return _rows(carry.model_name, None) + _rows(hvg_name(carry.model_name), reference)
296
+
297
+
298
+ def lost_tasks(carry: Carry, rows: list[dict]) -> list[str]:
299
+ """The tasks ``carry`` held that one of its entries got no row for."""
300
+ names = [carry.model_name]
301
+ if not carry.is_baseline:
302
+ names.append(hvg_name(carry.model_name))
303
+ lost = set()
304
+ for name in names:
305
+ lost |= carry.held - {row[TASK_ID] for row in rows if row[MODEL_NAME] == name}
306
+ return sorted(lost)
307
+
308
+
309
+ def _score_carries(
310
+ carries: list[Carry],
311
+ reference: Path,
312
+ datasets: list[dict],
313
+ registry: list[dict],
314
+ token: str | None,
315
+ checkpoint_dir: Path | None,
316
+ ) -> tuple[list[dict], list[str]]:
317
+ """The rows of every carry, and one line per carry that failed."""
318
+ rows, failures = [], []
319
+ with TemporaryDirectory() as workdir:
320
+ for carry in carries:
321
+ source = (
322
+ reference if carry.archive is None else _download(carry.archive, token)
323
+ )
324
+ try:
325
+ scored = rescore(
326
+ carry,
327
+ source,
328
+ reference,
329
+ datasets,
330
+ registry,
331
+ Path(workdir),
332
+ token,
333
+ checkpoint_dir,
334
+ )
335
+ except (SubmissionError, EvaluatorError) as error:
336
+ failures.append(f"{carry.model_name}: {error}")
337
+ continue
338
+ lost = lost_tasks(carry, scored)
339
+ if lost:
340
+ failures.append(f"{carry.model_name}: no score on held tasks {lost}")
341
+ logging.info("%s: %d rows", carry.model_name, len(scored))
342
+ rows += scored
343
+ return rows, failures
344
+
345
+
346
+ def _manifests(token: str | None) -> list[dict]:
347
+ """Every archived submission manifest in the private results dataset."""
348
+ from huggingface_hub import HfApi
349
+
350
+ files = HfApi(token=token).list_repo_files(RESULTS_REPO, repo_type="dataset")
351
+ return [
352
+ json.loads(_download(path, token).read_text())
353
+ for path in files
354
+ if path.startswith(f"{ARCHIVE_DIR}/") and path.endswith(f"/{ARCHIVE_MANIFEST}")
355
+ ]
356
+
357
+
358
+ def _download(path_in_repo: str, token: str | None) -> Path:
359
+ """One file of the private results dataset, at its latest commit."""
360
+ from huggingface_hub import hf_hub_download
361
+
362
+ return Path(
363
+ hf_hub_download(RESULTS_REPO, path_in_repo, repo_type="dataset", token=token)
364
+ )
365
+
366
+
367
+ def _task_ids(registry: list[dict], datasets: list[dict]) -> set[str]:
368
+ """The ids of the tasks of ``registry`` on the datasets the manifest lists."""
369
+ return {
370
+ _norm_id(task[TASK_ID])
371
+ for task in scoreable_tasks(registry, manifest_ids(datasets))
372
+ }
373
+
374
+
375
+ def _describe(carry: Carry) -> str:
376
+ tasks = "every task" if carry.task_ids is None else f"{len(carry.task_ids)} tasks"
377
+ kind = "baseline" if carry.is_baseline else carry.modality
378
+ return f"{carry.model_name} ({kind}, {carry.representation}): {tasks}"
379
+
380
+
381
+ def _parse_args() -> argparse.Namespace:
382
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
383
+ parser.add_argument(
384
+ "--from",
385
+ dest="source",
386
+ required=True,
387
+ help=f"the version to rescore, or '{LEGACY}' for rows without one",
388
+ )
389
+ parser.add_argument("--models", nargs="+", default=None, help="only these models")
390
+ parser.add_argument(
391
+ "--all-tasks", action="store_true", help="score every task each file covers"
392
+ )
393
+ parser.add_argument("--checkpoint-dir", type=Path, default=None)
394
+ parser.add_argument("--out", type=Path, default=None, help="write the rows here")
395
+ parser.add_argument("--dry-run", action="store_true", help="list, score nothing")
396
+ parser.add_argument(
397
+ "--publish", action="store_true", help="append the rows to the leaderboard"
398
+ )
399
+ return parser.parse_args()
400
+
401
+
402
+ def main() -> None:
403
+ """CLI entry point."""
404
+ args = _parse_args()
405
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
406
+ token = os.environ.get("HF_TOKEN")
407
+ if args.publish and not token:
408
+ raise SystemExit("--publish needs write access: set HF_TOKEN.")
409
+ if args.source == CURRENT.number:
410
+ raise SystemExit(f"{CURRENT.number} is the current version: pick an older one.")
411
+ old_version = next((v for v in VERSIONS if v.number == args.source), None)
412
+ if args.source != LEGACY and old_version is None:
413
+ raise SystemExit(f"unknown version {args.source}")
414
+
415
+ datasets = fetch_manifest(token)
416
+ registry = fetch_tasks_registry(token)
417
+ task_ids = _task_ids(registry, datasets)
418
+ old_ids = (
419
+ task_ids
420
+ if old_version is None
421
+ else _task_ids(
422
+ fetch_tasks_registry(token, old_version.labels),
423
+ fetch_manifest(token, old_version.data),
424
+ )
425
+ )
426
+ results = read_results(token)
427
+ carries, orphans = carried(
428
+ version_rows(results, "" if old_version is None else old_version.number),
429
+ version_rows(results, CURRENT.number),
430
+ _manifests(token),
431
+ task_ids,
432
+ task_ids - old_ids,
433
+ args.all_tasks,
434
+ )
435
+ carries = [c for c in carries if args.models is None or c.model_name in args.models]
436
+ logging.info("%d entries to rescore into %s:", len(carries), CURRENT.number)
437
+ for carry in carries:
438
+ logging.info(" %s", _describe(carry))
439
+ if orphans:
440
+ logging.warning("no archive, so not carried over: %s", orphans)
441
+ if args.dry_run:
442
+ return
443
+
444
+ rows, failures = _score_carries(
445
+ carries,
446
+ fetch_hvg_reference(token),
447
+ datasets,
448
+ registry,
449
+ token,
450
+ args.checkpoint_dir,
451
+ )
452
+ if args.out is not None:
453
+ pd.DataFrame(rows).to_csv(args.out, index=False)
454
+ if failures:
455
+ raise SystemExit(
456
+ f"Failed entries: {len(failures)}, so nothing is published. Fix them, or "
457
+ "rerun with --models and the entries to keep:\n" + "\n".join(failures)
458
+ )
459
+ if args.publish:
460
+ append_results(rows, token)
461
+ logging.info("published %d rows into version %s", len(rows), CURRENT.number)
462
+
463
+
464
+ if __name__ == "__main__":
465
+ main()
results.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persisted leaderboard rows: the results-CSV schema and its Hugging Face IO.
2
+
3
+ Kept out of ``app.py`` so the schema has one owner and so the baseline publisher
4
+ can append rows without importing Gradio. ``huggingface_hub`` is imported lazily,
5
+ so the unit tests touch no network.
6
+
7
+ ``is_baseline`` marks a reference submission we produced ourselves (log-normalized
8
+ expression, whole or cut to its most variable genes) rather than a model somebody
9
+ sent us. Baselines are ranked in place, never pinned: the point of showing them is
10
+ that a foundation model can lose to an information-rich expression baseline, and
11
+ a row pushed to the bottom of the table would hide exactly that.
12
+
13
+ ``hf_username`` privately records who submitted a name, because the board keeps
14
+ each name's LATEST rows: without an owner, anyone could supersede another team's
15
+ ranked entry by sending a one-task file under their model name. The Submit form
16
+ refuses a name somebody else already claimed. New submissions separately store
17
+ an optional public ``institution``, an author-submission flag and a paper link;
18
+ legacy owners remain private. Historical ``team`` values are read as institutions.
19
+
20
+ Rows written before these columns existed carry no column at all, so every
21
+ reader normalises the baseline flag, ownership, public credits and submission
22
+ identity. Missing legacy public-credit values remain private.
23
+
24
+ Each row records the board ``version`` whose harness scored it, which is always
25
+ the current version of this code. A board shows only the rows of its version.
26
+ Rows written before versions existed carry none and are shown on no board.
27
+
28
+ Every submission is also scored concatenated with our ``HVG-1200-genes``
29
+ baseline features and stored as a second entry, ``<model> + HVG``, sharing the
30
+ parent's owner, credits and submission id. It shows what a model adds on top of
31
+ plain expression. The features live privately at ``HVG_REFERENCE_FILE``, next
32
+ to the results. They are read at the commit the board version pins, so a new
33
+ upload is used only once a release pins it.
34
+
35
+ The Space and ``rescore.py`` both append to the same CSV files. An append reads
36
+ a file at the latest commit, adds its rows, and uploads the whole file on top of
37
+ that commit only. If another write lands in between, the Hub refuses the upload,
38
+ and the append reads the file again, up to ``WRITE_ATTEMPTS`` times. So two
39
+ appends that cross never lose a row.
40
+ """
41
+
42
+ import hashlib
43
+ import io
44
+ import json
45
+ from collections.abc import Callable
46
+ from http import HTTPStatus
47
+ from pathlib import Path
48
+
49
+ import pandas as pd
50
+ from evaluator import EMBEDDING, RESULTS_REPO, EvaluatorError, load_submission
51
+ from versions import CURRENT
52
+
53
+ RESULTS_FILE = "task_results.csv"
54
+ SUBMISSIONS_FILE = "submissions.csv"
55
+
56
+ MODEL_NAME = "model_name"
57
+ IS_BASELINE = "is_baseline"
58
+ OWNER = "hf_username"
59
+ INSTITUTION = "institution"
60
+ LEGACY_TEAM = "team"
61
+ IS_AUTHOR_SUBMISSION = "is_author_submission"
62
+ PAPER_LINK = "paper_link"
63
+ SUBMISSION_ID = "submission_id"
64
+ VERSION = "version"
65
+
66
+ RESULT_COLUMNS = [
67
+ MODEL_NAME,
68
+ "task_id",
69
+ "score",
70
+ "repeat_scores",
71
+ "diagnostics",
72
+ "submitted_at",
73
+ IS_BASELINE,
74
+ OWNER,
75
+ INSTITUTION,
76
+ IS_AUTHOR_SUBMISSION,
77
+ PAPER_LINK,
78
+ SUBMISSION_ID,
79
+ VERSION,
80
+ ]
81
+ SUBMISSION_COLUMNS = [
82
+ MODEL_NAME,
83
+ "submitted_at",
84
+ OWNER,
85
+ INSTITUTION,
86
+ IS_AUTHOR_SUBMISSION,
87
+ SUBMISSION_ID,
88
+ "email",
89
+ PAPER_LINK,
90
+ "hf_model_link",
91
+ "notes",
92
+ ]
93
+
94
+ TRUTHY = ("true", "1")
95
+ BASELINE_TAG = "(baseline)"
96
+ HVG_TAG = "+ HVG"
97
+ HVG_REFERENCE_MODEL = "HVG-1200-genes"
98
+ HVG_REFERENCE_FILE = f"baselines/{HVG_REFERENCE_MODEL}/submission.parquet"
99
+ ARCHIVE_DIR = "models"
100
+ ARCHIVE_MANIFEST = "manifest.json"
101
+ WRITE_ATTEMPTS = 3
102
+
103
+
104
+ def with_baseline_flag(df: pd.DataFrame) -> pd.DataFrame:
105
+ """Guarantee a boolean ``is_baseline`` column, whatever the CSV held.
106
+
107
+ Read back from CSV the column can be bool, the strings ``True``/``False``, or
108
+ absent on rows written before baselines existed; all of those must collapse
109
+ to a real boolean before anything ranks on it.
110
+ """
111
+ if IS_BASELINE not in df.columns:
112
+ return df.assign(**{IS_BASELINE: False})
113
+ flags = df[IS_BASELINE].astype(str).str.strip().str.lower().isin(TRUTHY)
114
+ return df.assign(**{IS_BASELINE: flags})
115
+
116
+
117
+ def with_owner(df: pd.DataFrame) -> pd.DataFrame:
118
+ """Guarantee a string ``hf_username`` column; an absent one means unclaimed."""
119
+ return _with_text_column(df, OWNER)
120
+
121
+
122
+ def with_institution(df: pd.DataFrame) -> pd.DataFrame:
123
+ """Guarantee an institution, falling back to the former ``team`` field."""
124
+ normalized = _with_text_column(df, INSTITUTION)
125
+ if LEGACY_TEAM not in normalized.columns:
126
+ return normalized
127
+ legacy = _with_text_column(normalized, LEGACY_TEAM)[LEGACY_TEAM]
128
+ values = normalized[INSTITUTION].mask(normalized[INSTITUTION] == "", legacy)
129
+ return normalized.assign(**{INSTITUTION: values})
130
+
131
+
132
+ def with_submission_id(df: pd.DataFrame) -> pd.DataFrame:
133
+ """Guarantee a submission id; legacy rows fall back to their timestamp."""
134
+ return _with_text_column(df, SUBMISSION_ID)
135
+
136
+
137
+ def with_version(df: pd.DataFrame) -> pd.DataFrame:
138
+ """Guarantee a board-version column; legacy rows have the empty version."""
139
+ return _with_text_column(df, VERSION)
140
+
141
+
142
+ def version_rows(df: pd.DataFrame, number: str) -> pd.DataFrame:
143
+ """The rows that board version ``number`` scored."""
144
+ versioned = with_version(df)
145
+ return versioned[versioned[VERSION] == number]
146
+
147
+
148
+ def with_paper_link(df: pd.DataFrame) -> pd.DataFrame:
149
+ """Guarantee a public paper-link column; legacy rows have no public link."""
150
+ return _with_text_column(df, PAPER_LINK)
151
+
152
+
153
+ def with_author_submission_flag(df: pd.DataFrame) -> pd.DataFrame:
154
+ """Guarantee a boolean author-submission flag; legacy rows are unverified."""
155
+ if IS_AUTHOR_SUBMISSION not in df.columns:
156
+ return df.assign(**{IS_AUTHOR_SUBMISSION: False})
157
+ flags = df[IS_AUTHOR_SUBMISSION].astype(str).str.strip().str.lower().isin(TRUTHY)
158
+ return df.assign(**{IS_AUTHOR_SUBMISSION: flags})
159
+
160
+
161
+ def _with_text_column(df: pd.DataFrame, column: str) -> pd.DataFrame:
162
+ """Normalize a nullable CSV text column without stringifying missing values."""
163
+ if column not in df.columns:
164
+ return df.assign(**{column: ""})
165
+ values = df[column].map(lambda value: "" if pd.isna(value) else str(value).strip())
166
+ return df.assign(**{column: values})
167
+
168
+
169
+ def owner_of(df: pd.DataFrame, model: str) -> str:
170
+ """Who claimed this submitted model name, or ``""`` if it is free.
171
+
172
+ Baselines are ignored: they live in their own namespace (``is_baseline``), so
173
+ publishing a baseline never reserves its name for user submissions.
174
+ """
175
+ if df.empty:
176
+ return ""
177
+ claimed = with_owner(with_baseline_flag(df))
178
+ rows = claimed[
179
+ (claimed[MODEL_NAME] == model) & ~claimed[IS_BASELINE] & (claimed[OWNER] != "")
180
+ ]
181
+ return "" if rows.empty else str(rows.iloc[-1][OWNER])
182
+
183
+
184
+ def display_name(model: str, is_baseline: bool) -> str:
185
+ """Leaderboard label: a baseline says so, in the one column everybody reads."""
186
+ return f"{model} {BASELINE_TAG}" if is_baseline else model
187
+
188
+
189
+ def hvg_name(model: str) -> str:
190
+ """Name of the entry scoring ``model`` concatenated with the HVG features."""
191
+ return f"{model} {HVG_TAG}"
192
+
193
+
194
+ def split_hvg_name(model: str) -> tuple[str, bool]:
195
+ """Split ``<model> + HVG`` into the parent name and whether it is one."""
196
+ suffix = f" {HVG_TAG}"
197
+ if model.endswith(suffix):
198
+ return model.removesuffix(suffix), True
199
+ return model, False
200
+
201
+
202
+ def without_hvg(df: pd.DataFrame) -> pd.DataFrame:
203
+ """Drop the rows of every ``<model> + HVG`` entry."""
204
+ return df[~df[MODEL_NAME].astype(str).str.endswith(f" {HVG_TAG}")]
205
+
206
+
207
+ def fetch_hvg_reference(token: str | None, revision: str = CURRENT.reference) -> Path:
208
+ """Local path to the HVG-1200 features every submission is concatenated with."""
209
+ from huggingface_hub import hf_hub_download
210
+
211
+ return Path(
212
+ hf_hub_download(
213
+ RESULTS_REPO,
214
+ HVG_REFERENCE_FILE,
215
+ repo_type="dataset",
216
+ token=token,
217
+ revision=revision,
218
+ )
219
+ )
220
+
221
+
222
+ def upload_hvg_reference(path: str | Path, token: str | None) -> str:
223
+ """Replace the HVG-1200 features; return the commit that holds them, to pin."""
224
+ from huggingface_hub import HfApi
225
+
226
+ return (
227
+ HfApi(token=token)
228
+ .upload_file(
229
+ path_or_fileobj=str(path),
230
+ path_in_repo=HVG_REFERENCE_FILE,
231
+ repo_id=RESULTS_REPO,
232
+ repo_type="dataset",
233
+ commit_message="Update HVG-1200 reference features",
234
+ )
235
+ .oid
236
+ )
237
+
238
+
239
+ def task_rows(
240
+ model: str, result: dict, submitted_at: str, submission_id: str, credits: dict
241
+ ) -> list[dict]:
242
+ """One row per task scored in ``result``, credited and tagged with this version."""
243
+ return [
244
+ {
245
+ MODEL_NAME: model,
246
+ "task_id": task.task_id,
247
+ "score": round(float(task.score), 4),
248
+ "repeat_scores": json.dumps(
249
+ [round(float(value), 4) for value in task.repeat_scores]
250
+ ),
251
+ "diagnostics": json.dumps(
252
+ {key: round(float(value), 4) for key, value in task.diagnostics.items()}
253
+ ),
254
+ "submitted_at": submitted_at,
255
+ IS_BASELINE: False,
256
+ SUBMISSION_ID: submission_id,
257
+ VERSION: CURRENT.number,
258
+ **credits,
259
+ }
260
+ for task in result["per_task"]
261
+ ]
262
+
263
+
264
+ def read_csv(
265
+ filename: str, columns: list[str], token: str | None, revision: str | None = None
266
+ ) -> pd.DataFrame:
267
+ """One CSV of the private results dataset, at ``revision`` or the latest commit.
268
+
269
+ An absent file is an empty table.
270
+ """
271
+ from huggingface_hub import hf_hub_download
272
+ from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
273
+
274
+ try:
275
+ path = hf_hub_download(
276
+ RESULTS_REPO, filename, repo_type="dataset", token=token, revision=revision
277
+ )
278
+ except (RepositoryNotFoundError, EntryNotFoundError):
279
+ return pd.DataFrame(columns=columns)
280
+ return pd.read_csv(path, dtype={VERSION: str})
281
+
282
+
283
+ def upload_csv(
284
+ filename: str, df: pd.DataFrame, token: str | None, parent_commit: str
285
+ ) -> None:
286
+ """Overwrite one CSV, unless a commit landed on top of ``parent_commit``."""
287
+ from huggingface_hub import HfApi
288
+
289
+ buffer = io.BytesIO()
290
+ df.to_csv(buffer, index=False)
291
+ buffer.seek(0)
292
+ HfApi(token=token).upload_file(
293
+ path_or_fileobj=buffer,
294
+ path_in_repo=filename,
295
+ repo_id=RESULTS_REPO,
296
+ repo_type="dataset",
297
+ parent_commit=parent_commit,
298
+ )
299
+
300
+
301
+ def _append_csv(
302
+ filename: str, extended: Callable[[str], pd.DataFrame], token: str | None
303
+ ) -> None:
304
+ """Upload ``extended(head)``: the CSV read at commit ``head``, plus new rows.
305
+
306
+ When another write lands first, read the CSV again at the new head.
307
+ """
308
+ from huggingface_hub import HfApi
309
+ from huggingface_hub.utils import HfHubHTTPError
310
+
311
+ api = HfApi(token=token)
312
+ api.create_repo(RESULTS_REPO, repo_type="dataset", private=True, exist_ok=True)
313
+ for _ in range(WRITE_ATTEMPTS):
314
+ head = api.dataset_info(RESULTS_REPO).sha
315
+ try:
316
+ upload_csv(filename, extended(head), token, head)
317
+ return
318
+ except HfHubHTTPError as error:
319
+ if error.response.status_code != HTTPStatus.PRECONDITION_FAILED:
320
+ raise
321
+ raise EvaluatorError(
322
+ f"another write changed {filename} during each of {WRITE_ATTEMPTS} "
323
+ "attempts, so these rows were not saved: try again"
324
+ )
325
+
326
+
327
+ def archive_paths(submission_id: str, source_format: str) -> tuple[str, str]:
328
+ """Where a submission's file and its manifest are archived in the results repo."""
329
+ prefix = f"{ARCHIVE_DIR}/{submission_id}"
330
+ return (
331
+ f"{prefix}/submission{source_format or '.bin'}",
332
+ f"{prefix}/{ARCHIVE_MANIFEST}",
333
+ )
334
+
335
+
336
+ def _embedding_manifest(
337
+ path: str | Path,
338
+ model_name: str,
339
+ submission_id: str,
340
+ modality: str,
341
+ submitted_at: str,
342
+ representation: str = EMBEDDING,
343
+ ) -> tuple[dict, bytes]:
344
+ """Build a privacy-preserving manifest for an embedding submission."""
345
+ source = Path(path)
346
+ digest = hashlib.sha256()
347
+ with source.open("rb") as stream:
348
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
349
+ digest.update(chunk)
350
+ blocks = load_submission(source)
351
+ frame = pd.concat(
352
+ [block.assign(dataset_id=dataset_id) for dataset_id, block in blocks.items()],
353
+ ignore_index=True,
354
+ )
355
+ counts = frame["dataset_id"].value_counts().sort_index().to_dict()
356
+ embedding_columns = [
357
+ column for column in frame.columns if column not in {"dataset_id", "sample_id"}
358
+ ]
359
+ manifest = {
360
+ "model_name": model_name,
361
+ "submission_id": submission_id,
362
+ "submitted_at": submitted_at,
363
+ "modality": modality,
364
+ "representation": representation,
365
+ "source_format": source.suffix.lower(),
366
+ "sha256": digest.hexdigest(),
367
+ "size_bytes": source.stat().st_size,
368
+ "n_rows": len(frame),
369
+ "embedding_dimension": len(embedding_columns),
370
+ "dataset_row_counts": {str(key): int(value) for key, value in counts.items()},
371
+ }
372
+ return manifest, json.dumps(manifest, indent=2).encode()
373
+
374
+
375
+ def archive_submission(
376
+ path: str | Path,
377
+ model_name: str,
378
+ submission_id: str,
379
+ modality: str,
380
+ submitted_at: str,
381
+ token: str | None,
382
+ representation: str = EMBEDDING,
383
+ ) -> None:
384
+ """Archive an embedding file and its manifest in the private results dataset."""
385
+ from huggingface_hub import CommitOperationAdd, HfApi
386
+
387
+ source = Path(path)
388
+ _, manifest_bytes = _embedding_manifest(
389
+ source, model_name, submission_id, modality, submitted_at, representation
390
+ )
391
+ file_path, manifest_path = archive_paths(submission_id, source.suffix.lower())
392
+ HfApi(token=token).create_commit(
393
+ repo_id=RESULTS_REPO,
394
+ repo_type="dataset",
395
+ operations=[
396
+ CommitOperationAdd(path_in_repo=file_path, path_or_fileobj=source),
397
+ CommitOperationAdd(
398
+ path_in_repo=manifest_path, path_or_fileobj=manifest_bytes
399
+ ),
400
+ ],
401
+ commit_message=f"Archive embedding submission {submission_id}",
402
+ )
403
+
404
+
405
+ def read_results(token: str | None, revision: str | None = None) -> pd.DataFrame:
406
+ """Every persisted task score, with baseline, credit and version normalised."""
407
+ return with_version(
408
+ with_submission_id(
409
+ with_paper_link(
410
+ with_author_submission_flag(
411
+ with_institution(
412
+ with_owner(
413
+ with_baseline_flag(
414
+ read_csv(RESULTS_FILE, RESULT_COLUMNS, token, revision)
415
+ )
416
+ )
417
+ )
418
+ )
419
+ )
420
+ )
421
+ )
422
+
423
+
424
+ def append_results(rows: list[dict], token: str | None) -> None:
425
+ """Append scored rows to the leaderboard, keeping the history intact."""
426
+ if not rows:
427
+ return
428
+
429
+ def extended(head: str) -> pd.DataFrame:
430
+ df = pd.concat(
431
+ [read_results(token, head), pd.DataFrame(rows)], ignore_index=True
432
+ )
433
+ return with_version(
434
+ with_submission_id(
435
+ with_paper_link(
436
+ with_author_submission_flag(
437
+ with_institution(with_owner(with_baseline_flag(df)))
438
+ )
439
+ )
440
+ )
441
+ )
442
+
443
+ _append_csv(RESULTS_FILE, extended, token)
444
+
445
+
446
+ def append_submission(meta: dict, token: str | None) -> None:
447
+ """Persist private contact metadata with matching public-credit fields."""
448
+
449
+ def extended(head: str) -> pd.DataFrame:
450
+ return pd.concat(
451
+ [
452
+ read_csv(SUBMISSIONS_FILE, SUBMISSION_COLUMNS, token, head),
453
+ pd.DataFrame([meta]),
454
+ ],
455
+ ignore_index=True,
456
+ )
457
+
458
+ _append_csv(SUBMISSIONS_FILE, extended, token)
scoring.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scoring policy for the PRIMO benchmark: predictions -> per-category numbers.
2
+
3
+ Everything that turns a model's fold-wise cross-validation scores into leaderboard
4
+ numbers lives here, kept apart from the probe and from any I/O so it stays easy
5
+ to change as the benchmark grows.
6
+
7
+ The scoring unit is a task = (dataset, target). Tasks are grouped by their
8
+ ``category`` (including treatment outcome, clinical scores, endotype, and
9
+ perturbation response). A category uses a SINGLE metric (enforced in the registry),
10
+ so its leaderboard number is a plain mean of that metric -- AUROC, Pearson and
11
+ centered Spearman are never averaged together inside a category column.
12
+
13
+ ``sort_key`` is the one place they are averaged, to give the board a single
14
+ order. It is shown as the ``Mean`` column, labelled as a cross-metric average so
15
+ nobody reads it as a metric in its own right; the per-category columns remain
16
+ the numbers to compare on.
17
+
18
+ Pure numpy / sklearn-metrics -- no huggingface, no file I/O, so it unit-tests
19
+ without a network and is safe to rework mid-project.
20
+ """
21
+
22
+ from collections import defaultdict
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+
26
+ import numpy as np
27
+ from sklearn.metrics import roc_auc_score
28
+
29
+
30
+ def compute_auroc(
31
+ y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
32
+ ) -> float:
33
+ """AUROC from class probabilities (``y_pred`` is an ``(n, n_classes)`` matrix).
34
+
35
+ ``classes`` names the columns of ``y_pred``. It matters whenever ``y_true``
36
+ holds fewer classes than the task does -- a transfer split whose test cohort
37
+ misses one. The columns of the absent classes are dropped and the rest
38
+ renormalized, so the score never reads a column belonging to another class,
39
+ and never trips sklearn's sum-to-one check.
40
+ """
41
+ classes = np.unique(y_true) if classes is None else np.asarray(classes)
42
+ present = np.isin(classes, np.unique(y_true))
43
+ scores = np.asarray(y_pred)[:, present]
44
+ totals = scores.sum(axis=1, keepdims=True)
45
+ if not (totals > 0).all():
46
+ raise ValueError(
47
+ "some samples carry no probability on any class present in y_true"
48
+ )
49
+ scores = scores / totals
50
+ kept = classes[present]
51
+ if len(kept) == 2:
52
+ return float(roc_auc_score(y_true, scores[:, 1]))
53
+ return float(
54
+ roc_auc_score(
55
+ y_true, scores, multi_class="ovr", average="weighted", labels=kept
56
+ )
57
+ )
58
+
59
+
60
+ def compute_pearson(
61
+ y_true: np.ndarray, y_pred: np.ndarray, classes: np.ndarray | None = None
62
+ ) -> float:
63
+ """Pearson r between predictions and targets; NaN if either is constant."""
64
+ if np.std(y_pred) == 0 or np.std(y_true) == 0:
65
+ return float("nan")
66
+ return float(np.corrcoef(y_pred, y_true)[0, 1])
67
+
68
+
69
+ def _average_ranks(values: np.ndarray) -> np.ndarray:
70
+ """Return stable, one-based average ranks with deterministic tie handling."""
71
+ values = np.asarray(values)
72
+ order = np.argsort(values, kind="stable")
73
+ sorted_values = values[order]
74
+ sorted_ranks = np.empty(len(values), dtype=float)
75
+ start = 0
76
+ while start < len(values):
77
+ stop = start + 1
78
+ while stop < len(values) and sorted_values[stop] == sorted_values[start]:
79
+ stop += 1
80
+ sorted_ranks[start:stop] = 0.5 * (start + stop - 1) + 1.0
81
+ start = stop
82
+ ranks = np.empty(len(values), dtype=float)
83
+ ranks[order] = sorted_ranks
84
+ return ranks
85
+
86
+
87
+ def _mean_sample_spearman(y_true: np.ndarray, y_pred: np.ndarray) -> float:
88
+ """Return mean row-wise rank correlation, scoring degenerate rows at zero."""
89
+ correlations = []
90
+ for truth, prediction in zip(y_true, y_pred):
91
+ correlation = compute_pearson(_average_ranks(truth), _average_ranks(prediction))
92
+ correlations.append(correlation if np.isfinite(correlation) else 0.0)
93
+ return float(np.mean(correlations)) if correlations else float("nan")
94
+
95
+
96
+ def compute_residual_sample_spearman(
97
+ y_true: np.ndarray,
98
+ y_pred: np.ndarray,
99
+ training_mean: np.ndarray,
100
+ ) -> float:
101
+ """Mean sample-wise Spearman beyond the training-fold mean response."""
102
+ return _mean_sample_spearman(y_true - training_mean, y_pred - training_mean)
103
+
104
+
105
+ def compute_target_centered_sample_spearman(
106
+ y_true: np.ndarray,
107
+ y_pred: np.ndarray,
108
+ ) -> float:
109
+ """Mean sample-wise Spearman after removing each target-cohort gene mean."""
110
+ truth_centered = y_true - np.mean(y_true, axis=0)
111
+ prediction_centered = y_pred - np.mean(y_pred, axis=0)
112
+ return _mean_sample_spearman(truth_centered, prediction_centered)
113
+
114
+
115
+ METRICS: dict[str, Callable[[np.ndarray, np.ndarray, np.ndarray | None], float]] = {
116
+ "auroc": compute_auroc,
117
+ "pearson": compute_pearson,
118
+ }
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class TaskScore:
123
+ """One task's result: a raw metric plus the category it is grouped under."""
124
+
125
+ task_id: str
126
+ dataset_id: str
127
+ category: str
128
+ metric: str
129
+ score: float
130
+ n_samples: int
131
+ repeat_scores: tuple[float, ...] = ()
132
+ diagnostics: dict[str, float] = field(default_factory=dict)
133
+
134
+
135
+ def category_means(scores: list[TaskScore]) -> dict[str, dict]:
136
+ """Mean of the native metric per task category.
137
+
138
+ A category uses one metric, so this is a plain mean of that metric -- never a
139
+ mix of native metrics. Degenerate (non-finite) task scores are dropped from
140
+ the mean. Returns ``{category: {metric, mean, n_tasks}}`` for the categories
141
+ present in ``scores``.
142
+ """
143
+ by_category: dict[str, list[TaskScore]] = defaultdict(list)
144
+ for score in scores:
145
+ by_category[score.category].append(score)
146
+ out = {}
147
+ for category, items in by_category.items():
148
+ finite = [s.score for s in items if np.isfinite(s.score)]
149
+ out[category] = {
150
+ "metric": items[0].metric,
151
+ "mean": float(np.mean(finite)) if finite else float("nan"),
152
+ "n_tasks": len(items),
153
+ }
154
+ return out
155
+
156
+
157
+ def sort_key(categories: dict[str, dict]) -> float:
158
+ """Leaderboard ranking key: mean of the per-category means.
159
+
160
+ Orders the rows, and is shown as the ``Mean`` column on boards holding more
161
+ than one category. It does average across native metrics, a
162
+ deliberate compromise for a single order; swap for a per-category-normalized
163
+ mean if the ranking needs to be metric-fair.
164
+
165
+ An entry with nothing finite to average sorts LAST, not at zero: a constant
166
+ embedding scores NaN on every Pearson task, and zero would float it above a
167
+ model that merely correlates negatively -- ranking "no score" over "a bad
168
+ score". Nothing finite means nothing to show, so the table renders it blank.
169
+ """
170
+ means = [c["mean"] for c in categories.values() if np.isfinite(c["mean"])]
171
+ return float(np.mean(means)) if means else float("-inf")
submit_api.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The ``/submit`` endpoint that ``primo submit`` calls.
2
+
3
+ The web form and the endpoint share one path: both call ``run_submission`` in
4
+ ``app.py``, so the same checks, scores and records apply to both. The endpoint
5
+ reads the caller's Hugging Face token only to learn the account name, with
6
+ ``whoami``. It never stores the token, logs it or puts it in a message. Only a
7
+ non-empty string reaches ``whoami``: for any other value, such as ``""``, ``None``
8
+ or ``True``, ``whoami`` falls back to the token of the Space itself. A 401 or a
9
+ 403 from ``whoami`` means a bad token, which the caller fixes. Any other failure,
10
+ such as a timeout or a 5xx, is on our side.
11
+
12
+ Gradio checks that a file comes from an upload only when its payload carries the
13
+ ``gradio.FileData`` marker, which ``gradio_client.handle_file`` adds. Without the
14
+ marker, a caller could send the path of any file of the Space, so the endpoint
15
+ refuses such a payload.
16
+
17
+ An account can publish ``SUBMISSIONS_PER_DAY`` submissions in 24 hours, through
18
+ the form and the endpoint together. A file can weigh ``MAX_FILE_SIZE`` at most.
19
+ The form and the endpoint share the ``SUBMISSIONS`` queue, so one submission runs
20
+ at a time.
21
+ """
22
+
23
+ import logging
24
+ from collections.abc import Callable
25
+ from datetime import datetime, timedelta
26
+ from http import HTTPStatus
27
+
28
+ import pandas as pd
29
+ from results import (
30
+ IS_BASELINE,
31
+ OWNER,
32
+ SUBMISSION_ID,
33
+ with_baseline_flag,
34
+ with_owner,
35
+ with_submission_id,
36
+ )
37
+
38
+ PUBLISHED = "published"
39
+ REFUSED = "refused"
40
+ ERROR = "error"
41
+ SUBMISSIONS = "submissions"
42
+ SUBMISSIONS_PER_DAY = 4
43
+ MAX_FILE_SIZE = 500_000_000
44
+ SUBMITTED_AT = "%Y-%m-%d %H:%M:%S.%f"
45
+ FILE_DATA = "gradio.FileData"
46
+
47
+
48
+ def reply(status: str, message: str, submission_id: str = "") -> dict:
49
+ """The answer to one submission, as ``primo submit`` reads it."""
50
+ return {"status": status, "message": message, "submission_id": submission_id}
51
+
52
+
53
+ def recent_submissions(df: pd.DataFrame, owner: str, now: datetime) -> int:
54
+ """How many submissions ``owner`` published in the 24 hours before ``now``."""
55
+ since = (now - timedelta(days=1)).strftime(SUBMITTED_AT)
56
+ rows = with_submission_id(with_owner(with_baseline_flag(df)))
57
+ recent = rows[
58
+ (rows[OWNER] == owner)
59
+ & ~rows[IS_BASELINE]
60
+ & (rows["submitted_at"].astype(str) >= since)
61
+ ]
62
+ return int(recent[SUBMISSION_ID].nunique())
63
+
64
+
65
+ def route(core: Callable[..., dict]) -> Callable[..., dict]:
66
+ """The ``/submit`` endpoint, which checks, scores and saves through ``core``."""
67
+
68
+ def submit(
69
+ submission_path: dict,
70
+ modality: str,
71
+ model_name: str,
72
+ institution: str,
73
+ is_author_submission: bool,
74
+ email: str,
75
+ paper_link: str,
76
+ hf_model_link: str,
77
+ notes: str,
78
+ token: str,
79
+ ) -> dict:
80
+ """Score one submission file that ``primo submit`` sends."""
81
+ from huggingface_hub import HfApi
82
+
83
+ meta = (
84
+ submission_path.get("meta") if isinstance(submission_path, dict) else None
85
+ )
86
+ if not isinstance(meta, dict) or meta.get("_type") != FILE_DATA:
87
+ return reply(
88
+ REFUSED, "Send the submission file with gradio_client.handle_file."
89
+ )
90
+ if not isinstance(token, str) or not token.strip():
91
+ return reply(REFUSED, "Send a Hugging Face token: run `hf auth login`.")
92
+ try:
93
+ username = HfApi().whoami(token=token.strip())["name"]
94
+ except Exception as error: # noqa: BLE001
95
+ status = getattr(getattr(error, "response", None), "status_code", None)
96
+ if status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
97
+ return reply(
98
+ REFUSED,
99
+ "Hugging Face refused the token. Log in again with "
100
+ "`hf auth login`.",
101
+ )
102
+ logging.warning(
103
+ "whoami failed: %s, status %s", type(error).__name__, status
104
+ )
105
+ return reply(
106
+ ERROR,
107
+ "⚠️ We could not check the token with Hugging Face. This is on our "
108
+ "side, not your token. Please try again in a moment.",
109
+ )
110
+ return core(
111
+ submission_path["path"],
112
+ modality,
113
+ model_name,
114
+ institution,
115
+ is_author_submission,
116
+ email,
117
+ paper_link,
118
+ hf_model_link,
119
+ notes,
120
+ username,
121
+ )
122
+
123
+ return submit
versions.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Board versions: the Hub commits that each version of the leaderboard reads.
2
+
3
+ A board version is a minor release of primo-eval: version ``0.1`` is scored by the
4
+ ``v0.1.x`` releases. It pins the commit of every Hub repo that a score reads, so a
5
+ push to one of these repos changes no score until a release opens a new version.
6
+
7
+ ``versions.yaml`` lists every version. ``current`` is the version this code scores
8
+ and shows by default. Each version names its release tag, the day it opened, and
9
+ three commits: ``data`` of the public data repo, ``labels`` of the private labels
10
+ repo, and ``reference`` of the results repo, which holds the HVG features. Its
11
+ ``notes`` say in one line what changed: the board shows them to every visitor.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ VERSIONS_FILE = Path(__file__).parent / "versions.yaml"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Version:
24
+ """One board version, the Hub commits its scores read, and what changed."""
25
+
26
+ number: str
27
+ release: str
28
+ opened: str
29
+ data: str
30
+ labels: str
31
+ reference: str
32
+ notes: str = ""
33
+
34
+
35
+ def _quoted(entry: dict, path: Path) -> dict:
36
+ """Refuse the values that YAML reads as numbers or dates, such as ``0.10``."""
37
+ unquoted = sorted(key for key, value in entry.items() if not isinstance(value, str))
38
+ if unquoted:
39
+ raise ValueError(f"quote the values of {unquoted} in {path}")
40
+ return entry
41
+
42
+
43
+ def load_versions(path: str | Path) -> tuple[Version, list[Version]]:
44
+ """The current version and every version listed in ``path``."""
45
+ path = Path(path)
46
+ raw = yaml.safe_load(path.read_text())
47
+ current = _quoted({"current": raw["current"]}, path)["current"]
48
+ versions = [Version(**_quoted(entry, path)) for entry in raw["versions"]]
49
+ numbers = [version.number for version in versions]
50
+ if len(set(numbers)) != len(numbers):
51
+ raise ValueError(f"a version is listed twice in {path}: {numbers}")
52
+ if current not in numbers:
53
+ raise ValueError(f"the current version {current} is not listed in {path}")
54
+ return versions[numbers.index(current)], versions
55
+
56
+
57
+ CURRENT, VERSIONS = load_versions(VERSIONS_FILE)
versions.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ current: "0.1"
2
+ versions:
3
+ - number: "0.1"
4
+ release: "v0.1.0"
5
+ opened: "2026-10-19"
6
+ data: "0f452b2fc84e544fb1a1fc5bd42017482e07b43c"
7
+ labels: "b5cd56fc5cec748fba5db6280477167a45d5cc1a"
8
+ reference: "6f3e0b3e9413591ffbf34fc51cf9f0a51c6ab399"
9
+ notes: "First board version: 70 tasks on bulk and single-cell RNAseq. Its scores are the reference for the later versions."