bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,1443 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Discovery-half orchestrator.
4
+
5
+ Joins the upstream genomics evidence into a ranked target shortlist:
6
+
7
+ DEGs ──► Open Targets enrichment ──► Surfaceome filter
8
+ │
9
+ ▼
10
+ Top-N targets
11
+ │
12
+ ▼
13
+ AlphaFoldDB structures
14
+ │
15
+ ▼
16
+ SURFACE-Bind site lookup
17
+ │
18
+ ▼
19
+ targets/candidates.parquet
20
+ epitopes/epitopes.parquet
21
+
22
+ All steps are CPU-only and run on the user's laptop. Each stage appends a
23
+ :class:`bindsight.provenance.StageRecord` to a single per-run manifest.
24
+
25
+ Failures are recorded, not swallowed: if Open Targets has no record for a
26
+ gene, we tag the row ``no_open_targets`` and keep going. The downstream
27
+ ``rank`` stage uses these tags to build a failure taxonomy for the report.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import numpy as np
38
+ import pandas as pd
39
+
40
+ from bindsight import __version__
41
+ from bindsight.config import RunConfig, TargetDiscoveryParams
42
+ from bindsight.deg.pydeseq2_runner import PyDESeq2Runner
43
+ from bindsight.epitopes.surface_bind import SURFACE_BIND_DATA_ENV, SurfaceBindClient
44
+ from bindsight.io.paths import adopt_structure, resolve_run_path, run_dir
45
+ from bindsight.pipelines.caveats import DISCOVERY_LIMITATIONS, caveat_summary
46
+ from bindsight.provenance import (
47
+ InputRef,
48
+ Manifest,
49
+ OutputRef,
50
+ StageRecord,
51
+ ToolRef,
52
+ new_manifest,
53
+ sha256_file,
54
+ )
55
+ from bindsight.structures.alphafolddb import AlphaFoldDBClient
56
+ from bindsight.structures.plddt import mean_plddt, region_plddt
57
+ from bindsight.structures.topology import Topology, UniProtTopologyClient
58
+ from bindsight.surfaceome import (
59
+ is_surface_protein,
60
+ load_surfaceome,
61
+ load_surfaceome_gene_map,
62
+ load_surfy,
63
+ surfaceome_source,
64
+ )
65
+ from bindsight.targets.gtex import GTExTissueExpression
66
+ from bindsight.targets.open_targets import OpenTargetsClient
67
+
68
+ LOG = logging.getLogger(__name__)
69
+
70
+ # Cap on how many top candidates (by |log2fc|) get an AlphaFoldDB structure
71
+ # fetch. Only the top-N proceed to design, so fetching for every surface DE gene
72
+ # on a real cohort (hundreds) is wasted work; this keeps discovery fast.
73
+ _STRUCTURE_FETCH_CAP = 25
74
+
75
+ # The enrichment cap lives in TargetDiscoveryParams.enrich_top_k so it is
76
+ # recorded in the run manifest and can be reported as an explicit gate. The
77
+ # module-level constant that used to shadow it is gone: it was retained "for
78
+ # older callers" that do not exist, and it silently duplicated the field's
79
+ # default, so the two could disagree with nothing to notice.
80
+
81
+ #: Sentinel for a gene with no UniProt mapping (see _do_discover).
82
+ _NO_UNIPROT: str | None = None
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Public entry point
87
+ # ---------------------------------------------------------------------------
88
+ def run(
89
+ config: RunConfig,
90
+ *,
91
+ out_dir: Path | None = None,
92
+ open_targets_client: OpenTargetsClient | None = None,
93
+ alphafolddb_client: AlphaFoldDBClient | None = None,
94
+ surface_bind_client: SurfaceBindClient | None = None,
95
+ topology_client: UniProtTopologyClient | None = None,
96
+ gtex_client: GTExTissueExpression | None = None,
97
+ surfy: frozenset[str] | None = None,
98
+ ) -> Manifest:
99
+ """Run the discovery half end-to-end and write artifacts to ``out_dir``.
100
+
101
+ The optional client/data arguments exist so tests can inject mocks. In
102
+ normal use, they default to live clients hitting the public APIs.
103
+ """
104
+ root = run_dir(out_dir or config.out_dir)
105
+ LOG.info("bindsight discover: out=%s name=%s", root, config.name)
106
+
107
+ # Stage 0: ensure real reference data is present (auto-download TCGA cohort
108
+ # from GDC if configured + missing; populate the full SURFY surfaceome cache
109
+ # for production runs). No-ops when data is already present or injected.
110
+ _ensure_reference_data(config)
111
+ surface_bind_client = _resolve_surface_bind_client(surface_bind_client)
112
+
113
+ # Persist the effective configuration inside the run. Nothing used to write
114
+ # it, so a finished run — or an exported crate — could not say what produced
115
+ # it, and any CLI override (--backend, --cheap …) was lost entirely.
116
+ config_path = _write_run_config(config, root)
117
+
118
+ manifest = new_manifest(name=config.name, config_path=str(config_path))
119
+
120
+ # ---- 1. DEG ----
121
+ deg_table_path = root / "deg" / "results.parquet"
122
+ deg_stage = _stage_deg(config, deg_table_path)
123
+ manifest.append(deg_stage)
124
+ if deg_stage.status != "completed":
125
+ manifest.write(root / "run_manifest.jsonld")
126
+ return manifest
127
+
128
+ # ---- 2. Discover ----
129
+ candidates_path = root / "targets" / "candidates.parquet"
130
+ epitopes_path = root / "epitopes" / "epitopes.parquet"
131
+ discover_stage = _stage_discover(
132
+ config,
133
+ deg_table_path=deg_table_path,
134
+ candidates_path=candidates_path,
135
+ epitopes_path=epitopes_path,
136
+ open_targets_client=open_targets_client,
137
+ alphafolddb_client=alphafolddb_client,
138
+ surface_bind_client=surface_bind_client,
139
+ topology_client=topology_client,
140
+ gtex_client=gtex_client,
141
+ surfy=surfy,
142
+ )
143
+ manifest.append(discover_stage)
144
+
145
+ manifest.write(root / "run_manifest.jsonld")
146
+ LOG.info("bindsight discover complete; manifest=%s", root / "run_manifest.jsonld")
147
+ return manifest
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Stage 0: ensure real reference data (GDC cohort + SURFY surfaceome)
152
+ # ---------------------------------------------------------------------------
153
+ def _ensure_reference_data(config: RunConfig) -> None:
154
+ """Auto-download the real input cohort from NIH/GDC when configured + missing.
155
+
156
+ Only fires when ``inputs.download`` is set and the counts/design files don't
157
+ exist yet. (The SURFY surfaceome cache is populated later, inside the
158
+ discover stage, so a missing-inputs run fails fast on DEG without any
159
+ network calls.)
160
+ """
161
+ counts_p = Path(config.inputs.counts)
162
+ design_p = Path(config.inputs.design)
163
+ dl = config.inputs.download
164
+ if dl is not None and (not counts_p.exists() or not design_p.exists()):
165
+ from bindsight.io.gdc import fetch_cohort
166
+
167
+ LOG.info("inputs missing; auto-downloading %s cohort from GDC", dl.project)
168
+ fetch_cohort(
169
+ project=dl.project,
170
+ n_tumor=dl.n_tumor,
171
+ n_normal=dl.n_normal,
172
+ counts_out=counts_p,
173
+ design_out=design_p,
174
+ gene_types=tuple(dl.gene_types),
175
+ )
176
+
177
+
178
+ def _write_run_config(config: RunConfig, root: Path) -> Path:
179
+ """Write the effective run configuration to ``<run>/config.yaml``.
180
+
181
+ ``io/paths.py`` has always documented this file as part of the run layout,
182
+ and nothing ever produced it. Writing the *effective* config — after CLI
183
+ overrides are applied — is what makes a run self-describing and an exported
184
+ crate reproducible.
185
+
186
+ Args:
187
+ config: The resolved run configuration.
188
+ root: The run directory.
189
+
190
+ Returns:
191
+ Path to the written config file.
192
+ """
193
+ import yaml
194
+
195
+ out = root / "config.yaml"
196
+ payload = config.model_dump(mode="json", by_alias=True)
197
+ out.write_text(
198
+ "# Effective bindsight run configuration, written by the pipeline.\n"
199
+ "# Includes any command-line overrides applied to the source config.\n"
200
+ + yaml.safe_dump(payload, sort_keys=False),
201
+ encoding="utf-8",
202
+ newline="\n",
203
+ )
204
+ return out
205
+
206
+
207
+ def _resolve_surfy(p: object, surfy: frozenset[str] | None) -> frozenset[str]:
208
+ """Return the SURFY surface set.
209
+
210
+ No network access. The full canonical list is vendored with the package, so
211
+ discovery resolves the surfaceome from disk every time.
212
+
213
+ This used to try ``populate_surfy_cache()`` on a cache miss. That download
214
+ can no longer succeed — upstream serves an HTML page on one host and a
215
+ Git-LFS pointer on the other — so every fresh install either hard-failed or
216
+ silently degraded to a ten-protein list and surfaced almost nothing. Keeping
217
+ the attempt would only add five retries of latency before the same outcome.
218
+
219
+ An explicitly injected set (tests) still wins.
220
+ """
221
+ from bindsight.config import TargetDiscoveryParams
222
+
223
+ assert isinstance(p, TargetDiscoveryParams)
224
+ if surfy is not None:
225
+ return surfy
226
+ if p.use_extended_surfaceome:
227
+ # SURFY plus UniProt's curated cell-membrane annotations. SURFY alone
228
+ # cannot see CA9 or STEAP1, and CA9 carries the largest effect measured
229
+ # anywhere in the rediscovery panel.
230
+ resolved = load_surfaceome(extended=True)
231
+ LOG.info("surfaceome: %d accessions (SURFY + UniProt cell membrane)", len(resolved))
232
+ return resolved
233
+ return load_surfy(allow_offline_fallback=p.surfy_allow_offline_fallback)
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Stage: DEG
238
+ # ---------------------------------------------------------------------------
239
+ def _deg_cache_key(inputs: list[InputRef], params: dict[str, Any]) -> str:
240
+ """Identify a differential-expression computation by its inputs and parameters.
241
+
242
+ Covers the *content* of the counts and design tables, not their paths, so a
243
+ moved or re-downloaded but identical cohort still hits. Any parameter change
244
+ misses, including the worker count, because a run recorded under one
245
+ configuration should not be reported under another.
246
+ """
247
+ import hashlib
248
+
249
+ # The tool that produced the table is part of the work, not context around
250
+ # it. Without it an upgraded pydeseq2 hits the old cache and the manifest
251
+ # records the reused bytes under the NEW version -- a completed stage
252
+ # attributing one tool's output to another. The design cache already folds
253
+ # its code identity in for exactly this reason; this one did not.
254
+ from bindsight.validate.protocol import UNRECORDED_VERSION, installed_version
255
+
256
+ tool_identity = f"pydeseq2:{installed_version('pydeseq2') or UNRECORDED_VERSION}"
257
+ material = "|".join(
258
+ [
259
+ *(f"{i.role}:{i.sha256}" for i in sorted(inputs, key=lambda x: x.role)),
260
+ json.dumps(params, sort_keys=True, default=str),
261
+ tool_identity,
262
+ ]
263
+ )
264
+ return hashlib.sha256(material.encode()).hexdigest()
265
+
266
+
267
+ def _stage_deg(config: RunConfig, out_path: Path) -> StageRecord:
268
+ counts_p = Path(config.inputs.counts)
269
+ design_p = Path(config.inputs.design)
270
+
271
+ inputs: list[InputRef] = []
272
+ for role, p in (("counts", counts_p), ("design", design_p)):
273
+ if p.exists():
274
+ inputs.append(
275
+ InputRef(
276
+ role=role,
277
+ path=str(p),
278
+ sha256=sha256_file(p),
279
+ bytes=p.stat().st_size,
280
+ media_type="text/tab-separated-values",
281
+ )
282
+ )
283
+
284
+ try:
285
+ from pydeseq2 import __version__ as pydeseq2_version
286
+ except ImportError:
287
+ pydeseq2_version = "uninstalled"
288
+
289
+ stage = StageRecord(
290
+ name="deg",
291
+ tool=ToolRef(
292
+ name="pydeseq2",
293
+ version=pydeseq2_version,
294
+ license="MIT",
295
+ repo_url="https://github.com/scverse/PyDESeq2",
296
+ citation="10.1093/bioinformatics/btad547",
297
+ ),
298
+ inputs=inputs,
299
+ params=config.params.deg.model_dump(),
300
+ )
301
+
302
+ if not counts_p.exists() or not design_p.exists():
303
+ stage.mark_failed(
304
+ f"missing input(s): counts_exists={counts_p.exists()} design_exists={design_p.exists()}"
305
+ )
306
+ return stage
307
+
308
+ # Differential expression is by far the most expensive stage — minutes to hours
309
+ # on a real cohort — and it depends only on the counts, the design and the DEG
310
+ # parameters. Re-running it because something downstream changed wastes that
311
+ # time for an identical answer. The key covers the content of both inputs and
312
+ # every parameter, so a cache hit is only ever the same computation.
313
+ cache_key = _deg_cache_key(inputs, config.params.deg.model_dump())
314
+ key_path = out_path.with_suffix(".cache_key")
315
+ if (
316
+ out_path.exists()
317
+ and out_path.stat().st_size > 0
318
+ and key_path.exists()
319
+ and key_path.read_text(encoding="utf-8").strip() == cache_key
320
+ ):
321
+ LOG.info("DEG cache hit (%s); reusing %s", cache_key[:8], out_path)
322
+ stage.cache_key = cache_key
323
+ stage.cache_status = "hit"
324
+ stage.notes = "reused an existing DEG table with identical inputs and parameters"
325
+ stage.mark_completed(
326
+ outputs=[
327
+ OutputRef(
328
+ role="deg_table",
329
+ path=str(out_path),
330
+ sha256=sha256_file(out_path),
331
+ bytes=out_path.stat().st_size,
332
+ media_type="application/x-parquet",
333
+ )
334
+ ]
335
+ )
336
+ return stage
337
+
338
+ stage.cache_key = cache_key
339
+ stage.cache_status = "miss"
340
+ try:
341
+ runner = PyDESeq2Runner(config.params.deg)
342
+ metrics = runner.run(counts_p, design_p, out_path)
343
+ key_path.write_text(cache_key, encoding="utf-8", newline="\n")
344
+ stage.notes = (
345
+ f"n_samples={metrics['n_samples']}, "
346
+ f"n_genes_tested={metrics['n_genes_tested']}, "
347
+ f"n_significant={metrics['n_significant']}"
348
+ )
349
+ stage.mark_completed(
350
+ outputs=[
351
+ OutputRef(
352
+ role="deg_table",
353
+ path=str(out_path),
354
+ sha256=sha256_file(out_path),
355
+ bytes=out_path.stat().st_size,
356
+ media_type="application/x-parquet",
357
+ )
358
+ ]
359
+ )
360
+ except Exception as e:
361
+ LOG.exception("DEG stage failed")
362
+ stage.mark_failed(repr(e))
363
+ return stage
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Stage: discover (joins DEGs → Open Targets → SURFY → SURFACE-Bind → AFDB)
368
+ # ---------------------------------------------------------------------------
369
+ def _stage_discover(
370
+ config: RunConfig,
371
+ *,
372
+ deg_table_path: Path,
373
+ candidates_path: Path,
374
+ epitopes_path: Path,
375
+ open_targets_client: OpenTargetsClient | None,
376
+ alphafolddb_client: AlphaFoldDBClient | None,
377
+ surface_bind_client: SurfaceBindClient | None,
378
+ topology_client: UniProtTopologyClient | None,
379
+ gtex_client: GTExTissueExpression | None,
380
+ surfy: frozenset[str] | None,
381
+ ) -> StageRecord:
382
+ stage = StageRecord(
383
+ name="discover",
384
+ tool=ToolRef(
385
+ name=f"bindsight/{__version__}",
386
+ version=__version__,
387
+ license="AGPL-3.0-or-later",
388
+ repo_url="https://github.com/mikhaeelatefrizk/bindsight",
389
+ ),
390
+ inputs=[
391
+ InputRef(
392
+ role="deg_table",
393
+ path=str(deg_table_path),
394
+ sha256=sha256_file(deg_table_path),
395
+ bytes=deg_table_path.stat().st_size,
396
+ media_type="application/x-parquet",
397
+ )
398
+ ],
399
+ params=config.params.target_discovery.model_dump()
400
+ | {
401
+ # Which surfaceome list this run actually resolved. The choice
402
+ # between a user-refreshed cache and the vendored list is made
403
+ # inside load_surfy and was recorded nowhere, so two runs could use
404
+ # different lists -- different eligible denominators, different
405
+ # counterfactual ranks -- with nothing in either manifest saying so.
406
+ "surfaceome_source": surfaceome_source(),
407
+ },
408
+ )
409
+
410
+ for _title, _body in DISCOVERY_LIMITATIONS:
411
+ LOG.warning("discovery limitation — %s: %s", _title, _body)
412
+
413
+ try:
414
+ candidates_df, epitopes_df, taxonomy_df = _do_discover(
415
+ config=config,
416
+ deg_table_path=deg_table_path,
417
+ open_targets_client=open_targets_client,
418
+ alphafolddb_client=alphafolddb_client,
419
+ surface_bind_client=surface_bind_client,
420
+ topology_client=topology_client,
421
+ gtex_client=gtex_client,
422
+ surfy=surfy,
423
+ )
424
+ candidates_path.parent.mkdir(parents=True, exist_ok=True)
425
+ epitopes_path.parent.mkdir(parents=True, exist_ok=True)
426
+ taxonomy_path = candidates_path.parent.parent / "taxonomy" / "failure_taxonomy.parquet"
427
+ taxonomy_path.parent.mkdir(parents=True, exist_ok=True)
428
+ candidates_df.to_parquet(candidates_path, index=False)
429
+ epitopes_df.to_parquet(epitopes_path, index=False)
430
+ taxonomy_df.to_parquet(taxonomy_path, index=False)
431
+
432
+ disp_counts = taxonomy_df["disposition"].value_counts().to_dict()
433
+ stage.notes = (
434
+ f"n_candidates={len(candidates_df)}, "
435
+ f"n_with_structure={int(candidates_df['has_alphafold_structure'].sum())}, "
436
+ f"n_top={int((candidates_df['rank_in_top_n']).sum())}; "
437
+ f"taxonomy({len(taxonomy_df)} genes)="
438
+ + ",".join(f"{k}:{v}" for k, v in sorted(disp_counts.items()))
439
+ + "; "
440
+ + caveat_summary()
441
+ )
442
+ stage.mark_completed(
443
+ outputs=[
444
+ OutputRef(
445
+ role="candidates",
446
+ path=str(candidates_path),
447
+ sha256=sha256_file(candidates_path),
448
+ bytes=candidates_path.stat().st_size,
449
+ media_type="application/x-parquet",
450
+ ),
451
+ OutputRef(
452
+ role="epitopes",
453
+ path=str(epitopes_path),
454
+ sha256=sha256_file(epitopes_path),
455
+ bytes=epitopes_path.stat().st_size,
456
+ media_type="application/x-parquet",
457
+ ),
458
+ OutputRef(
459
+ role="failure_taxonomy",
460
+ path=str(taxonomy_path),
461
+ sha256=sha256_file(taxonomy_path),
462
+ bytes=taxonomy_path.stat().st_size,
463
+ media_type="application/x-parquet",
464
+ ),
465
+ ]
466
+ )
467
+ except Exception as e:
468
+ LOG.exception("discover stage failed")
469
+ stage.mark_failed(repr(e))
470
+ return stage
471
+
472
+
473
+ def _do_discover(
474
+ *,
475
+ config: RunConfig,
476
+ deg_table_path: Path,
477
+ open_targets_client: OpenTargetsClient | None,
478
+ alphafolddb_client: AlphaFoldDBClient | None,
479
+ surface_bind_client: SurfaceBindClient | None,
480
+ topology_client: UniProtTopologyClient | None,
481
+ gtex_client: GTExTissueExpression | None,
482
+ surfy: frozenset[str] | None,
483
+ ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
484
+ """Pure-data discovery logic; returns (candidates_df, epitopes_df, taxonomy_df)."""
485
+ p = config.params.target_discovery
486
+ # The run root, derived the same way the taxonomy path is: the DEG table
487
+ # lives at <run>/deg/results.parquet. Structures are adopted into <run>/
488
+ # structures/ so the run stays portable.
489
+ run_root = deg_table_path.parent.parent
490
+
491
+ # 1. Load DEGs and keep significant ones. Antibody targets need tumor
492
+ # over-expression, so carry the most up-regulated significant genes into
493
+ # enrichment (bounded — a real cohort has thousands of significant DEGs).
494
+ deg = pd.read_parquet(deg_table_path)
495
+ sig = deg[deg["significant"]].copy()
496
+ n_sig = len(sig)
497
+
498
+ # The surfaceome must be resolved before the enrichment cut, because the cut
499
+ # is now spent on surface proteins rather than on the whole genome.
500
+ surfy_set = _resolve_surfy(p, surfy)
501
+
502
+ # 1a. Surfaceome pre-filter. Enrichment slots are a scarce resource, and
503
+ # spending them on genes that could never be antibody targets discards most
504
+ # of the surfaceome before it is ever examined. Filtering first is free: the
505
+ # number of Open Targets calls is unchanged, only which genes get them.
506
+ surfaceome_gene_ids: frozenset[str] = frozenset()
507
+ n_before_prefilter = len(sig)
508
+ if p.surfaceome_prefilter and p.require_surfy:
509
+ surfaceome_gene_ids = frozenset(
510
+ gene
511
+ for gene, accession in load_surfaceome_gene_map(
512
+ extended=p.use_extended_surfaceome
513
+ ).items()
514
+ if accession in surfy_set
515
+ )
516
+ if surfaceome_gene_ids:
517
+ sig = sig[sig["gene_id"].astype(str).isin(surfaceome_gene_ids)].copy()
518
+ LOG.info(
519
+ "surfaceome pre-filter: %d significant gene(s) → %d on the surfaceome",
520
+ n_before_prefilter,
521
+ len(sig),
522
+ )
523
+ else:
524
+ # Without the map the pre-filter cannot run, and silently skipping it
525
+ # would change the science without saying so.
526
+ LOG.warning(
527
+ "surfaceome gene map is unavailable; falling back to filtering AFTER "
528
+ "enrichment, which spends the cut on the whole genome"
529
+ )
530
+
531
+ # Carry the most *confidently* over-expressed genes into enrichment, ranked
532
+ # by the combined DE score π = log2fc × −log10(padj) rather than raw
533
+ # fold-change — so a highly-significant, abundant antigen with a moderate
534
+ # ratio (e.g. PSMA) is not crowded out by noisy high-fold-change genes.
535
+ sig["pi_score"] = _pi_score(sig)
536
+ sig = sig.sort_values("pi_score", ascending=False).head(p.enrich_top_k)
537
+ enriched_gene_ids = {str(g) for g in sig["gene_id"]}
538
+ LOG.info(
539
+ "DEGs: %d total, %d significant; enriching top %d by combined score (π)",
540
+ len(deg),
541
+ n_sig,
542
+ len(sig),
543
+ )
544
+
545
+ # Default clients
546
+ ot = open_targets_client or OpenTargetsClient()
547
+ afdb = alphafolddb_client or AlphaFoldDBClient()
548
+
549
+ # 2. For each significant gene, enrich via Open Targets (or fall back to
550
+ # the bundled offline map for well-known genes — used by the demo and
551
+ # when Open Targets is unreachable).
552
+ from bindsight.targets import ensembl_uniprot
553
+
554
+ enriched_rows: list[dict[str, object]] = []
555
+ for _, row in sig.iterrows():
556
+ gene_id = str(row["gene_id"])
557
+ ev = None
558
+ ot_status = "skipped"
559
+ if p.use_open_targets:
560
+ try:
561
+ ev = ot.get_target(gene_id)
562
+ ot_status = "ok" if ev is not None else "no_record"
563
+ except Exception as e:
564
+ LOG.warning("Open Targets failed for %s: %s", gene_id, e)
565
+ ot_status = f"error:{type(e).__name__}"
566
+
567
+ uniprot_ids = ev.uniprot_ids if ev else []
568
+ modalities = ev.tractability_modalities if ev else []
569
+ symbol = ev.symbol if ev else None
570
+
571
+ # Offline / fallback path: consult the bundled ENSG → UniProt map for
572
+ # well-known genes so the demo (and other offline runs) still produce
573
+ # candidates.
574
+ if not uniprot_ids:
575
+ fb_symbol, fb_uniprot = ensembl_uniprot.lookup(gene_id)
576
+ if fb_uniprot:
577
+ uniprot_ids = [fb_uniprot]
578
+ if symbol is None:
579
+ symbol = fb_symbol
580
+ if ot_status == "skipped":
581
+ ot_status = "bundled_fallback"
582
+
583
+ # [None] is the deliberate sentinel for 'gene mapped to no UniProt
584
+ # accession'; the row is still emitted so the failure taxonomy can
585
+ # record it as no_uniprot rather than dropping it silently.
586
+ candidates_for_gene: list[str | None] = list(uniprot_ids) or [_NO_UNIPROT]
587
+ for uniprot_id in candidates_for_gene:
588
+ enriched_rows.append(
589
+ {
590
+ "gene_id": gene_id,
591
+ "symbol": symbol,
592
+ "uniprot_id": uniprot_id,
593
+ "log2fc": float(row["log2fc"]),
594
+ "padj": float(row["padj"]) if pd.notna(row["padj"]) else None,
595
+ "tractable_modalities": ";".join(modalities),
596
+ "open_targets_status": ot_status,
597
+ "n_safety_events": ev.safety_event_count if ev else 0,
598
+ # Whether that count is a measurement or a default. Without
599
+ # this, a gene whose Open Targets lookup errored carries 0
600
+ # and sails through a filter meant to exclude genes with
601
+ # known safety events. Silence is not zero.
602
+ "safety_events_measured": ev is not None,
603
+ }
604
+ )
605
+
606
+ candidates = pd.DataFrame(enriched_rows)
607
+ enriched_all = candidates.copy()
608
+ if candidates.empty:
609
+ LOG.warning("no candidates after Open Targets enrichment")
610
+ taxonomy = _build_taxonomy(
611
+ deg,
612
+ enriched_gene_ids,
613
+ enriched_all,
614
+ _empty_candidates_frame(),
615
+ _empty_epitopes_frame(),
616
+ surfy_set,
617
+ p,
618
+ surface_bind_active=surface_bind_client is not None,
619
+ structure_queried=frozenset(),
620
+ surfaceome_gene_ids=surfaceome_gene_ids,
621
+ )
622
+ return _empty_candidates_frame(), _empty_epitopes_frame(), taxonomy
623
+
624
+ # 3. Surfaceome filter.
625
+ if p.require_surfy:
626
+ before = len(candidates)
627
+ candidates["is_surface"] = candidates["uniprot_id"].apply(
628
+ lambda u: bool(u) and is_surface_protein(u, surfy=surfy_set)
629
+ )
630
+ candidates = candidates[candidates["is_surface"]].copy()
631
+ LOG.info("surfaceome filter: %d → %d", before, len(candidates))
632
+ else:
633
+ candidates["is_surface"] = True
634
+
635
+ # 4. Tractability filter (optional).
636
+ if p.require_tractable_modality and not candidates.empty:
637
+ before = len(candidates)
638
+ wanted = set(p.require_tractable_modality)
639
+
640
+ def _has_wanted_modality(s: str) -> bool:
641
+ modalities = {m.strip() for m in s.split(";") if m.strip()}
642
+ return bool(wanted & modalities)
643
+
644
+ candidates = candidates[
645
+ candidates["tractable_modalities"].fillna("").apply(_has_wanted_modality)
646
+ ].copy()
647
+ LOG.info("tractability filter (%s): %d → %d", sorted(wanted), before, len(candidates))
648
+
649
+ # 5. Safety filter (optional).
650
+ #
651
+ # An unreachable Open Targets is not a clean bill of health. The count
652
+ # defaults to zero when the lookup returns nothing, so before this a rate
653
+ # limit or a network blip promoted every affected gene to "no known safety
654
+ # events" — the same fail-open the GTEx gate beside it was fixed not to do.
655
+ # When the gate is switched off entirely the count claims nothing either
656
+ # way, so nothing is withheld on its account.
657
+ if not candidates.empty:
658
+ before = len(candidates)
659
+ within = candidates["n_safety_events"] <= p.max_safety_events
660
+ gate_claims = p.use_open_targets and p.open_targets_require_measured
661
+ if gate_claims and "safety_events_measured" in candidates.columns:
662
+ measured = candidates["safety_events_measured"].fillna(False).astype(bool)
663
+ withheld = int((~measured).sum())
664
+ if withheld:
665
+ LOG.warning(
666
+ "safety gate: %d candidate(s) withheld because Open Targets "
667
+ "returned no record, so their safety-event count is unknown "
668
+ "rather than zero",
669
+ withheld,
670
+ )
671
+ candidates = candidates[within & measured].copy()
672
+ else:
673
+ candidates = candidates[within].copy()
674
+ LOG.info(
675
+ "safety filter (≤%d events): %d → %d", p.max_safety_events, before, len(candidates)
676
+ )
677
+
678
+ # 6. Pull AlphaFoldDB structures + tag. Only the strongest candidates carry
679
+ # forward to design, so we fetch structures for the top ones by |log2fc|
680
+ # (capped) rather than every surface DE gene — on a real cohort that can
681
+ # be hundreds, and the rest are never used downstream.
682
+ #
683
+ # ``structure_queried`` records the accessions whose AlphaFoldDB lookup
684
+ # actually completed. Everything else — below the cap, or a lookup that
685
+ # errored — was never assessed, which the taxonomy must not report as
686
+ # "no model exists".
687
+ structure_queried: set[str] = set()
688
+ if not candidates.empty:
689
+ candidates["pi_score"] = _pi_score(candidates)
690
+ candidates = candidates.sort_values(by="pi_score", ascending=False).reset_index(drop=True)
691
+ n_fetch = max(p.top_n, _STRUCTURE_FETCH_CAP)
692
+ fetch_uniprots = sorted(
693
+ {u for u in candidates.head(n_fetch)["uniprot_id"].dropna().unique() if u}
694
+ )
695
+ # Structures are fetched into the shared cache, then adopted into the
696
+ # run's own structures/ directory and referenced run-relatively. Storing
697
+ # the absolute cache path made every run — and every RO-Crate built from
698
+ # one — valid on exactly one machine.
699
+ struct_paths: dict[str, str] = {}
700
+ for uid in fetch_uniprots:
701
+ try:
702
+ cached = afdb.fetch(uid)
703
+ except Exception as e:
704
+ # A failed lookup is not evidence that no model exists; leaving the
705
+ # accession out of ``structure_queried`` keeps the taxonomy honest.
706
+ LOG.warning("AlphaFoldDB fetch failed for %s: %s", uid, e)
707
+ continue
708
+ structure_queried.add(uid)
709
+ if cached is None:
710
+ continue
711
+ try:
712
+ struct_paths[uid] = adopt_structure(run_root, cached)
713
+ except OSError as e:
714
+ # A copy failure must not lose the structure; fall back to the
715
+ # cache path, which older runs used exclusively anyway.
716
+ LOG.warning("could not adopt structure for %s into the run (%s)", uid, e)
717
+ struct_paths[uid] = str(cached)
718
+ candidates["alphafold_structure_path"] = candidates["uniprot_id"].map(
719
+ lambda u: struct_paths.get(u, "") if u else ""
720
+ )
721
+ candidates["has_alphafold_structure"] = candidates["alphafold_structure_path"] != ""
722
+ else:
723
+ candidates["alphafold_structure_path"] = ""
724
+ candidates["has_alphafold_structure"] = False
725
+
726
+ # 6b. Disorder signal: per-model mean pLDDT (AlphaFold confidence, 0-100),
727
+ # read straight from the cached mmCIF B-factor column. Always surfaced; only
728
+ # gates carry-forward when ``min_mean_plddt`` is set. A mostly-disordered /
729
+ # low-confidence model cannot be reliably designed against.
730
+ candidates["mean_plddt"] = candidates["alphafold_structure_path"].map(
731
+ lambda pth: mean_plddt(resolved) if (resolved := resolve_run_path(run_root, pth)) else None
732
+ )
733
+ if p.min_mean_plddt > 0:
734
+ low_conf = (
735
+ candidates["has_alphafold_structure"]
736
+ & candidates["mean_plddt"].notna()
737
+ & (candidates["mean_plddt"] < p.min_mean_plddt)
738
+ )
739
+ else:
740
+ low_conf = pd.Series(False, index=candidates.index)
741
+ candidates["low_confidence_structure"] = low_conf
742
+ # A model whose pLDDT could not be read is not a model that passed. The gate
743
+ # above tests `mean_plddt.notna()`, so an unreadable confidence fell through
744
+ # to the accepting side and an unmeasured structure was carried forward as
745
+ # though it had cleared the bar. Recorded rather than gated, mirroring
746
+ # `normal_tissue_unassessed`: what a reader needs is the difference between
747
+ # a model that passed and one never measured, and dropping the second for an
748
+ # infrastructure reason would be its own error.
749
+ candidates["structure_confidence_unassessed"] = (
750
+ candidates["has_alphafold_structure"]
751
+ & candidates["mean_plddt"].isna()
752
+ & (p.min_mean_plddt > 0)
753
+ )
754
+ if bool(candidates["structure_confidence_unassessed"].any()):
755
+ LOG.info(
756
+ "pLDDT gate: %d candidate(s) carry a structure whose confidence could not "
757
+ "be read; recorded as structure_confidence_unassessed, not as passing",
758
+ int(candidates["structure_confidence_unassessed"].sum()),
759
+ )
760
+ if bool(low_conf.any()):
761
+ # Distinct from no_alphafold_model: the model exists but is too disordered.
762
+ candidates.loc[low_conf, "has_alphafold_structure"] = False
763
+ LOG.info(
764
+ "pLDDT gate: %d candidate(s) below mean pLDDT %.0f -> low_confidence_structure",
765
+ int(low_conf.sum()),
766
+ p.min_mean_plddt,
767
+ )
768
+
769
+ # 6c. Membrane topology (extracellular-domain awareness) — opt-in (UniProt).
770
+ # A binder only reaches the extracellular part of a surface protein, so when
771
+ # enabled we annotate each candidate's extracellular ranges (and target the
772
+ # ECD for whole-surface design). Off by default — requires UniProt network.
773
+ topo_map: dict[str, Topology] = {}
774
+ if p.use_uniprot_topology and not candidates.empty:
775
+ topo_client = topology_client or UniProtTopologyClient()
776
+ topo_fetch = sorted(
777
+ {
778
+ u
779
+ for u in candidates.head(max(p.top_n, _STRUCTURE_FETCH_CAP))["uniprot_id"]
780
+ .dropna()
781
+ .unique()
782
+ if u
783
+ }
784
+ )
785
+ for uid in topo_fetch:
786
+ try:
787
+ t = topo_client.fetch(uid)
788
+ except Exception as e: # network/parse — fall back to whole-surface
789
+ LOG.warning("UniProt topology fetch failed for %s: %s", uid, e)
790
+ t = None
791
+ if t is not None:
792
+ topo_map[uid] = t
793
+ candidates["has_extracellular_domain"] = candidates["uniprot_id"].map(
794
+ lambda u: topo_map[u].has_extracellular if u in topo_map else None
795
+ )
796
+ candidates["extracellular_ranges"] = candidates["uniprot_id"].map(
797
+ lambda u: [list(r) for r in topo_map[u].extracellular_ranges] if u in topo_map else None
798
+ )
799
+ if p.require_extracellular_domain:
800
+ no_ecd = candidates["uniprot_id"].map(
801
+ lambda u: u in topo_map and not topo_map[u].has_extracellular
802
+ )
803
+ candidates["no_extracellular_domain"] = no_ecd
804
+ if bool(no_ecd.any()):
805
+ # Not antibody-accessible — drop from design carry-forward.
806
+ candidates.loc[no_ecd, "has_alphafold_structure"] = False
807
+ LOG.info(
808
+ "topology gate: %d candidate(s) with no extracellular domain", int(no_ecd.sum())
809
+ )
810
+
811
+ # 6d. Normal-tissue safety (GTEx) — opt-in. A binder against a target that is
812
+ # also highly expressed in vital normal tissue risks on-target/off-tumor
813
+ # toxicity. Flag candidates over the vital-tissue TPM threshold and drop them
814
+ # from design carry-forward. Off by default — requires the GTEx download.
815
+ if p.use_gtex_safety and not candidates.empty:
816
+ gtex = gtex_client or GTExTissueExpression()
817
+ # assess() separates a *measured* pass from an absent measurement. Using
818
+ # max_expression() directly conflated the two: a gene GTEx has no entry
819
+ # for returned None, which compared False against the ceiling and so was
820
+ # published as having cleared a safety gate that never ran on it.
821
+ verdicts = {
822
+ str(g): gtex.assess(str(g), p.vital_tissues, p.vital_tissue_max_tpm)
823
+ for g in candidates["gene_id"]
824
+ if g is not None
825
+ }
826
+ candidates["max_vital_tissue_tpm"] = candidates["gene_id"].map(
827
+ lambda g: verdicts[str(g)].max_tpm if g is not None and str(g) in verdicts else None
828
+ )
829
+ candidates["gtex_safety_status"] = candidates["gene_id"].map(
830
+ lambda g: (
831
+ verdicts[str(g)].status if g is not None and str(g) in verdicts else "unassessed"
832
+ )
833
+ )
834
+ tissue_unsafe = candidates["gtex_safety_status"] == "unsafe"
835
+ tissue_unassessed = candidates["gtex_safety_status"] == "unassessed"
836
+ candidates["high_normal_tissue_expression"] = tissue_unsafe
837
+ candidates["normal_tissue_unassessed"] = tissue_unassessed & p.gtex_require_measured
838
+ if bool(tissue_unsafe.any()):
839
+ candidates.loc[tissue_unsafe, "has_alphafold_structure"] = False
840
+ LOG.info(
841
+ "GTEx tissue-safety: %d candidate(s) over %.1f TPM in a vital tissue "
842
+ "-> high_normal_tissue_expression",
843
+ int(tissue_unsafe.sum()),
844
+ p.vital_tissue_max_tpm,
845
+ )
846
+ if bool(tissue_unassessed.any()):
847
+ if p.gtex_require_measured:
848
+ candidates.loc[tissue_unassessed, "has_alphafold_structure"] = False
849
+ LOG.info(
850
+ "GTEx tissue-safety: %d candidate(s) have no GTEx entry -> %s",
851
+ int(tissue_unassessed.sum()),
852
+ "normal_tissue_unassessed (withheld)"
853
+ if p.gtex_require_measured
854
+ else "unassessed (accepted; gtex_require_measured is False)",
855
+ )
856
+
857
+ # 7. Rank by the combined DE score π = log2fc × −log10(padj) (Xiao et al.
858
+ # 2014). Higher π = more confidently over-expressed. π is the *only* rank
859
+ # key, so a published rank is a deterministic function of the statistics:
860
+ # structure availability is network-dependent (fetched only for a capped
861
+ # head of the list, and a failed fetch is indistinguishable from a missing
862
+ # model), so ranking on it let a transient AlphaFoldDB outage reorder the
863
+ # table. Gene/accession break π ties so equal scores order identically
864
+ # across runs.
865
+ if "pi_score" not in candidates.columns:
866
+ candidates["pi_score"] = _pi_score(candidates)
867
+ candidates = candidates.sort_values(
868
+ by=["pi_score", "gene_id", "uniprot_id"],
869
+ ascending=[False, True, True],
870
+ kind="stable",
871
+ ).reset_index(drop=True)
872
+ candidates["rank"] = range(1, len(candidates) + 1)
873
+ # Design eligibility is a separate criterion, carried by the
874
+ # has_alphafold_structure column rather than by the rank: only
875
+ # structure-bearing candidates can proceed to design, so the design
876
+ # shortlist takes those first, in rank order.
877
+ design_shortlist = candidates.sort_values(
878
+ by=["has_alphafold_structure", "rank"],
879
+ ascending=[False, True],
880
+ kind="stable",
881
+ ).index[: p.top_n]
882
+ candidates["rank_in_top_n"] = candidates.index.isin(design_shortlist)
883
+
884
+ # 8. Build the epitopes table from SURFACE-Bind targetable sites when the
885
+ # data is vendored; otherwise design against the whole surface, recorded
886
+ # honestly in ``epitope_status``.
887
+ epitopes = _build_epitopes(
888
+ candidates[candidates["rank_in_top_n"]],
889
+ surface_bind_client,
890
+ p,
891
+ topology_map=topo_map,
892
+ run_root=run_root,
893
+ )
894
+
895
+ # 9. Negative-result taxonomy: one disposition per DEG gene, explaining why it
896
+ # is / isn't a surfaced candidate. Every gene is accounted for (the counts sum
897
+ # to the DEG total) — the failure modes are a first-class, auditable output.
898
+ taxonomy = _build_taxonomy(
899
+ deg,
900
+ enriched_gene_ids,
901
+ enriched_all,
902
+ candidates,
903
+ epitopes,
904
+ surfy_set,
905
+ p,
906
+ surface_bind_active=surface_bind_client is not None,
907
+ structure_queried=frozenset(structure_queried),
908
+ surfaceome_gene_ids=surfaceome_gene_ids,
909
+ )
910
+ return candidates, epitopes, taxonomy
911
+
912
+
913
+ # Negative-result taxonomy: the ordered dispositions a DEG gene can land in, from
914
+ # "never in contention" to "surfaced". The funnel is exhaustive, so the per-
915
+ # disposition counts always sum to the total DEG gene count.
916
+ #
917
+ # ``uniprot_lookup_failed`` and ``structure_not_queried`` are *not* findings: they
918
+ # mark genes whose lookup errored or was never attempted. Only a lookup that ran
919
+ # and came back empty earns ``no_uniprot`` / ``no_alphafold_model``, so a network
920
+ # outage can never be published as a negative result.
921
+ TAXONOMY_DISPOSITIONS: tuple[str, ...] = (
922
+ "not_significant",
923
+ "significance_unassessed",
924
+ "down_regulated",
925
+ "below_enrichment_cutoff",
926
+ "uniprot_lookup_failed",
927
+ "no_uniprot",
928
+ "not_surfaceome",
929
+ "fails_tractability",
930
+ "fails_safety",
931
+ "safety_unassessed",
932
+ "high_normal_tissue_expression",
933
+ "normal_tissue_unassessed",
934
+ "no_extracellular_domain",
935
+ "structure_not_queried",
936
+ "no_alphafold_model",
937
+ "low_confidence_structure",
938
+ "structure_confidence_unassessed",
939
+ "not_top_n",
940
+ "no_surface_bind_site",
941
+ "surface_bind_lookup_failed",
942
+ "surfaced",
943
+ )
944
+
945
+
946
+ def _padj_is_absent(row: object) -> bool:
947
+ """Whether this gene has no adjusted p-value, so nothing tested it.
948
+
949
+ pydeseq2 returns ``padj = NaN`` for genes its independent filtering removed
950
+ before testing. NaN is not equal to itself, which is the check; a gene with
951
+ no ``padj`` column at all is also untested rather than assumed null.
952
+ """
953
+ padj = getattr(row, "padj", None)
954
+ if padj is None:
955
+ return True
956
+ try:
957
+ return bool(padj != padj) # NaN
958
+ except TypeError: # pragma: no cover - a non-numeric padj is not a p-value
959
+ return True
960
+
961
+
962
+ def _build_taxonomy(
963
+ deg: pd.DataFrame,
964
+ enriched_gene_ids: set[str],
965
+ enriched_all: pd.DataFrame,
966
+ candidates: pd.DataFrame,
967
+ epitopes: pd.DataFrame,
968
+ surfy_set: frozenset[str],
969
+ p: TargetDiscoveryParams,
970
+ *,
971
+ surface_bind_active: bool,
972
+ structure_queried: frozenset[str],
973
+ surfaceome_gene_ids: frozenset[str] = frozenset(),
974
+ ) -> pd.DataFrame:
975
+ """One disposition per DEG gene explaining why it is / isn't a surfaced candidate.
976
+
977
+ Re-derives each gene's fate from the same signals the pipeline used — without
978
+ perturbing the candidate/epitope outputs — so the failure modes (the "negative
979
+ results") become an auditable, first-class artifact. The funnel is exhaustive:
980
+ the per-disposition counts always sum to the total DEG gene count.
981
+
982
+ Args:
983
+ deg: The full DEG table; every gene in it gets exactly one row out.
984
+ enriched_gene_ids: Genes carried into Open Targets enrichment.
985
+ enriched_all: Enrichment rows before any filter, one per gene × UniProt.
986
+ candidates: The surviving candidates, ranked.
987
+ epitopes: The epitope table for the design shortlist.
988
+ surfy_set: The resolved SURFY surfaceome.
989
+ p: Target-discovery parameters.
990
+ surface_bind_active: Whether SURFACE-Bind data was actually vendored.
991
+ structure_queried: UniProt accessions whose AlphaFoldDB lookup actually
992
+ ran to completion. Accessions outside this set were never assessed,
993
+ so their genes are reported as ``structure_not_queried`` rather than
994
+ as an absent model.
995
+ surfaceome_gene_ids: Genes on the surfaceome, when the pre-filter ran.
996
+ A significant, up-regulated gene outside this set was dropped before
997
+ the enrichment cut could apply, so it is reported as
998
+ ``not_surfaceome`` rather than blamed on a gate that never saw it.
999
+ Empty when the pre-filter is off, which restores the old behaviour.
1000
+
1001
+ Returns:
1002
+ One row per DEG gene with its disposition and the Open Targets status
1003
+ that produced it.
1004
+ """
1005
+ wanted = {m for m in (p.require_tractable_modality or [])}
1006
+
1007
+ # Per-gene "furthest stage reached", collapsed over a gene's UniProt rows.
1008
+ reach: dict[str, dict[str, bool]] = {}
1009
+ sym_map: dict[str, object] = {}
1010
+ ot_status_map: dict[str, str] = {}
1011
+ for r in enriched_all.itertuples(index=False):
1012
+ gid = str(r.gene_id)
1013
+ sym_map.setdefault(gid, getattr(r, "symbol", None))
1014
+ ot_status = str(getattr(r, "open_targets_status", "") or "")
1015
+ # An errored lookup dominates: it is the reason the gene has no mapping.
1016
+ if gid not in ot_status_map or ot_status.startswith("error:"):
1017
+ ot_status_map[gid] = ot_status
1018
+ uniprot = getattr(r, "uniprot_id", None)
1019
+ has_u = bool(uniprot) and pd.notna(uniprot)
1020
+ surf = has_u and (not p.require_surfy or is_surface_protein(str(uniprot), surfy=surfy_set))
1021
+ mods = {
1022
+ m.strip()
1023
+ for m in str(getattr(r, "tractable_modalities", "") or "").split(";")
1024
+ if m.strip()
1025
+ }
1026
+ tract = surf and (not wanted or bool(wanted & mods))
1027
+ # A gene whose safety count was never measured has not passed the gate;
1028
+ # it was never put to it. Old taxonomies carry no such column, and a
1029
+ # disabled gate claims nothing, so both default to measured.
1030
+ safety_measured = (not (p.use_open_targets and p.open_targets_require_measured)) or bool(
1031
+ getattr(r, "safety_events_measured", True)
1032
+ )
1033
+ safe = (
1034
+ tract
1035
+ and safety_measured
1036
+ and (int(getattr(r, "n_safety_events", 0) or 0) <= p.max_safety_events)
1037
+ )
1038
+ d = reach.setdefault(
1039
+ gid,
1040
+ {"u": False, "surf": False, "tract": False, "safe": False, "safety_measured": False},
1041
+ )
1042
+ d["u"] |= has_u
1043
+ d["surf"] |= surf
1044
+ d["tract"] |= tract
1045
+ d["safety_measured"] |= tract and safety_measured
1046
+ d["safe"] |= safe
1047
+
1048
+ cand_gids: set[str] = set()
1049
+ struct_gids: set[str] = set()
1050
+ queried_gids: set[str] = set()
1051
+ topn_gids: set[str] = set()
1052
+ low_conf_gids: set[str] = set()
1053
+ unassessed_conf_gids: set[str] = set()
1054
+ no_ecd_gids: set[str] = set()
1055
+ tissue_unsafe_gids: set[str] = set()
1056
+ tissue_unassessed_gids: set[str] = set()
1057
+ if not candidates.empty:
1058
+ cand_gids = {str(g) for g in candidates["gene_id"]}
1059
+ struct_gids = {
1060
+ str(g) for g in candidates.loc[candidates["has_alphafold_structure"], "gene_id"]
1061
+ }
1062
+ was_queried = candidates["uniprot_id"].map(
1063
+ lambda u: isinstance(u, str) and u in structure_queried
1064
+ )
1065
+ queried_gids = {str(g) for g in candidates.loc[was_queried, "gene_id"]}
1066
+ topn_gids = {str(g) for g in candidates.loc[candidates["rank_in_top_n"], "gene_id"]}
1067
+ if "low_confidence_structure" in candidates.columns:
1068
+ low_conf_gids = {
1069
+ str(g) for g in candidates.loc[candidates["low_confidence_structure"], "gene_id"]
1070
+ }
1071
+ if "structure_confidence_unassessed" in candidates.columns:
1072
+ unassessed_conf_gids = {
1073
+ str(g)
1074
+ for g in candidates.loc[candidates["structure_confidence_unassessed"], "gene_id"]
1075
+ }
1076
+ if "no_extracellular_domain" in candidates.columns:
1077
+ no_ecd_gids = {
1078
+ str(g) for g in candidates.loc[candidates["no_extracellular_domain"], "gene_id"]
1079
+ }
1080
+ if "high_normal_tissue_expression" in candidates.columns:
1081
+ tissue_unsafe_gids = {
1082
+ str(g)
1083
+ for g in candidates.loc[candidates["high_normal_tissue_expression"], "gene_id"]
1084
+ }
1085
+ if "normal_tissue_unassessed" in candidates.columns:
1086
+ tissue_unassessed_gids = {
1087
+ str(g) for g in candidates.loc[candidates["normal_tissue_unassessed"], "gene_id"]
1088
+ }
1089
+ site_gids: set[str] = set()
1090
+ # Genes whose site lookup errored. They are not genes without a site: the
1091
+ # lookup said nothing about them at all, and the taxonomy derived
1092
+ # "no_surface_bind_site" purely from the absence of a row, so an outage
1093
+ # became a biological finding about every protein it touched.
1094
+ lookup_failed_gids: set[str] = set()
1095
+ if surface_bind_active and not epitopes.empty and "epitope_status" in epitopes.columns:
1096
+ site_gids = {
1097
+ str(g)
1098
+ for g in epitopes.loc[epitopes["epitope_status"] == "surface_bind_site", "gene_id"]
1099
+ }
1100
+ lookup_failed_gids = {
1101
+ str(g)
1102
+ for g in epitopes.loc[
1103
+ epitopes["epitope_status"] == "surface_bind_lookup_failed", "gene_id"
1104
+ ]
1105
+ }
1106
+
1107
+ rows: list[dict[str, object]] = []
1108
+ for r in deg.itertuples(index=False):
1109
+ gid = str(r.gene_id)
1110
+ significant = bool(getattr(r, "significant", False))
1111
+ log2fc = float(r.log2fc) if pd.notna(getattr(r, "log2fc", None)) else None
1112
+ padj = float(r.padj) if pd.notna(getattr(r, "padj", None)) else None
1113
+ # Terminal outcome wins: a gene's *actual* fate (the pipeline ranks rather
1114
+ # than hard-filters on fold-change/structure, so a down-regulated or
1115
+ # structure-less gene can still appear in candidates). Only genes that
1116
+ # never reached candidates get the upstream "why dropped" reasons.
1117
+ if gid in struct_gids and gid in topn_gids:
1118
+ if gid in lookup_failed_gids:
1119
+ disp = "surface_bind_lookup_failed"
1120
+ elif surface_bind_active and gid not in site_gids:
1121
+ disp = "no_surface_bind_site"
1122
+ else:
1123
+ disp = "surfaced"
1124
+ elif gid in cand_gids:
1125
+ # passed the filters but can't proceed to design: over-expressed in vital
1126
+ # tissue, not antibody-accessible (no ECD), low-confidence model, no
1127
+ # structure, or out of top-N.
1128
+ if gid in tissue_unsafe_gids:
1129
+ disp = "high_normal_tissue_expression"
1130
+ elif gid in tissue_unassessed_gids:
1131
+ disp = "normal_tissue_unassessed"
1132
+ elif gid in no_ecd_gids:
1133
+ disp = "no_extracellular_domain"
1134
+ elif gid in unassessed_conf_gids:
1135
+ disp = "structure_confidence_unassessed"
1136
+ elif gid in low_conf_gids:
1137
+ disp = "low_confidence_structure"
1138
+ elif gid not in struct_gids:
1139
+ # Past the fetch cap, or the lookup errored: nothing was measured.
1140
+ disp = "no_alphafold_model" if gid in queried_gids else "structure_not_queried"
1141
+ else:
1142
+ disp = "not_top_n"
1143
+ elif gid in enriched_gene_ids:
1144
+ d = reach.get(
1145
+ gid,
1146
+ {
1147
+ "u": False,
1148
+ "surf": False,
1149
+ "tract": False,
1150
+ "safe": False,
1151
+ "safety_measured": False,
1152
+ },
1153
+ )
1154
+ if not d["u"]:
1155
+ disp = (
1156
+ "uniprot_lookup_failed"
1157
+ if ot_status_map.get(gid, "").startswith("error:")
1158
+ else "no_uniprot"
1159
+ )
1160
+ elif p.require_surfy and not d["surf"]:
1161
+ disp = "not_surfaceome"
1162
+ elif wanted and not d["tract"]:
1163
+ disp = "fails_tractability"
1164
+ elif not d["safety_measured"]:
1165
+ # Withheld for want of an answer, not for failing one. Reporting
1166
+ # this as fails_safety would publish a network outage as a
1167
+ # negative result about the gene.
1168
+ disp = "safety_unassessed"
1169
+ else:
1170
+ disp = "fails_safety"
1171
+ elif _padj_is_absent(r):
1172
+ # pydeseq2's independent filtering drops low-power genes before
1173
+ # testing them and returns padj = NaN. `_postprocess` fills that
1174
+ # with 1.0 to compute `significant`, which makes an untested gene
1175
+ # indistinguishable from a tested one that came back null -- and
1176
+ # `not_significant` is a gate, so it would enter every recall
1177
+ # denominator as a measured miss, under a reason naming a
1178
+ # comparison that never happened.
1179
+ #
1180
+ # `outcomes.eligible_ranking` already fixed this exact merge for the
1181
+ # counterfactual ordering, quoting this module's rule back at it.
1182
+ # The `significant` column it is computed from was left alone.
1183
+ disp = "significance_unassessed"
1184
+ elif not significant:
1185
+ disp = "not_significant"
1186
+ elif (log2fc or 0.0) <= 0.0:
1187
+ disp = "down_regulated"
1188
+ elif surfaceome_gene_ids and gid not in surfaceome_gene_ids:
1189
+ # Dropped by the surfaceome pre-filter, before the enrichment cut
1190
+ # could apply. Recording it as below_enrichment_cutoff would blame a
1191
+ # gate that never saw this gene.
1192
+ disp = "not_surfaceome"
1193
+ else:
1194
+ disp = "below_enrichment_cutoff"
1195
+ rows.append(
1196
+ {
1197
+ "gene_id": gid,
1198
+ "symbol": sym_map.get(gid),
1199
+ "log2fc": log2fc,
1200
+ "padj": padj,
1201
+ "disposition": disp,
1202
+ # Kept alongside the disposition so a reader can tell a real
1203
+ # negative from a lookup that never ran; it is dropped from the
1204
+ # candidates table for genes that never became candidates.
1205
+ "open_targets_status": ot_status_map.get(gid),
1206
+ }
1207
+ )
1208
+ return pd.DataFrame(
1209
+ rows,
1210
+ columns=["gene_id", "symbol", "log2fc", "padj", "disposition", "open_targets_status"],
1211
+ )
1212
+
1213
+
1214
+ def _pi_score(df: pd.DataFrame) -> pd.Series:
1215
+ """Combined DE ranking score π = log2fc × −log10(padj) (Xiao et al. 2014).
1216
+
1217
+ Rewards genes that are both strongly and *confidently* up-regulated. Genes
1218
+ with a large fold-change but weak significance, or down-regulated genes
1219
+ (negative log2fc → negative score), are naturally de-prioritised. A missing
1220
+ padj maps to π = 0.
1221
+ """
1222
+ padj = df["padj"].astype(float).fillna(1.0).clip(lower=1e-300)
1223
+ return df["log2fc"].astype(float) * -np.log10(padj)
1224
+
1225
+
1226
+ def _empty_candidates_frame() -> pd.DataFrame:
1227
+ return pd.DataFrame(
1228
+ columns=[
1229
+ "gene_id",
1230
+ "symbol",
1231
+ "uniprot_id",
1232
+ "log2fc",
1233
+ "padj",
1234
+ "tractable_modalities",
1235
+ "open_targets_status",
1236
+ "n_safety_events",
1237
+ "is_surface",
1238
+ "alphafold_structure_path",
1239
+ "has_alphafold_structure",
1240
+ "mean_plddt",
1241
+ "low_confidence_structure",
1242
+ "gtex_safety_status",
1243
+ "normal_tissue_unassessed",
1244
+ "rank",
1245
+ "rank_in_top_n",
1246
+ ]
1247
+ )
1248
+
1249
+
1250
+ def _resolve_surface_bind_client(injected: SurfaceBindClient | None) -> SurfaceBindClient | None:
1251
+ """Return the injected client, or auto-construct one from vendored data.
1252
+
1253
+ Auto-construction happens *only* if SURFACE-Bind data is actually vendored
1254
+ (the ``BINDSIGHT_SURFACE_BIND_DATA`` env var is set, or
1255
+ ``data/surface_bind/sites/`` exists). A bare or absent data tree yields None
1256
+ — discovery then designs against the whole surface and says so.
1257
+ """
1258
+ if injected is not None:
1259
+ return injected
1260
+ import os
1261
+
1262
+ env = os.environ.get(SURFACE_BIND_DATA_ENV)
1263
+ local = Path("data/surface_bind")
1264
+ root: str | Path | None = env or (local if (local / "sites").is_dir() else None)
1265
+ if root is None:
1266
+ return None
1267
+ try:
1268
+ return SurfaceBindClient(data_root=root)
1269
+ except (RuntimeError, FileNotFoundError) as e:
1270
+ LOG.warning("SURFACE-Bind data not usable (%s); designing against the whole surface", e)
1271
+ return None
1272
+
1273
+
1274
+ def _build_epitopes(
1275
+ top: pd.DataFrame,
1276
+ client: SurfaceBindClient | None,
1277
+ p: TargetDiscoveryParams,
1278
+ *,
1279
+ topology_map: dict[str, Topology] | None = None,
1280
+ run_root: Path | None = None,
1281
+ ) -> pd.DataFrame:
1282
+ """Build the epitopes table for the top-N candidates.
1283
+
1284
+ With a SURFACE-Bind client whose data is vendored, each candidate gets one
1285
+ row per qualifying targetable site (real residues → focused RFdiffusion
1286
+ design). The ``epitope_status`` is honest about what happened:
1287
+
1288
+ - ``surface_bind_site`` — a real vendored site (focused design);
1289
+ - ``no_surface_bind_site`` — data present, none for this protein;
1290
+ - ``surface_bind_lookup_failed`` — the client raised; nothing was learned
1291
+ about this protein, and that is not the same as learning it has no site;
1292
+ - ``surface_bind_not_configured`` — no SURFACE-Bind data vendored.
1293
+
1294
+ ``min_surface_bind_score`` filters scored sites only. A site the vendored
1295
+ data left unscored cannot be compared against the threshold, so it is kept
1296
+ and logged rather than silently counted as having passed one; its row
1297
+ carries ``score: None``.
1298
+
1299
+ ``require_surface_bind_site`` only bites when data is actually vendored: with
1300
+ a client, ``True`` carries *only* candidates that have ≥1 qualifying site,
1301
+ while ``False`` carries every top-N candidate (whole-surface where no site
1302
+ exists). Without vendored data there is nothing to require, so every
1303
+ candidate falls back to whole-surface design.
1304
+
1305
+ Two columns describe the region a designer must restrict itself to:
1306
+
1307
+ - ``design_ranges`` — list of ``[start, end]`` residue ranges, 1-based
1308
+ and inclusive, in UniProt numbering: the extracellular parts of the
1309
+ receptor. ``None`` when no topology was resolved, in which case the
1310
+ designer has no evidence to narrow the target and must say so rather than
1311
+ assume the whole chain is reachable;
1312
+ - ``design_range_source`` — ``uniprot_topology`` when the ranges came from a
1313
+ resolved UniProt topology, ``not_available`` when none was.
1314
+
1315
+ A binder only ever reaches the extracellular part of a surface protein, so a
1316
+ consumer that ignores ``design_ranges`` designs against the transmembrane
1317
+ helix and cytoplasmic tail too.
1318
+ """
1319
+ tmap = topology_map or {}
1320
+
1321
+ def _epitope_plddt(stored: Any, residues: list[int]) -> float | None:
1322
+ """Mean pLDDT over a region, resolving the run-relative path first.
1323
+
1324
+ ``adopt_structure`` stores structure paths relative to the run root, so
1325
+ handing the raw value to ``region_plddt`` silently yields ``None`` for
1326
+ every row. The sibling ``mean_plddt`` column resolves it; this must too.
1327
+ """
1328
+ resolved = resolve_run_path(run_root, stored) if run_root is not None else stored
1329
+ return region_plddt(resolved, residues) if resolved else None
1330
+
1331
+ rows: list[dict[str, Any]] = []
1332
+ for _, row in top.iterrows():
1333
+ uni = row["uniprot_id"]
1334
+ topo = tmap.get(uni) if isinstance(uni, str) else None
1335
+ design_ranges = (
1336
+ [[int(start), int(end)] for start, end in topo.extracellular_ranges] if topo else None
1337
+ )
1338
+ base = {
1339
+ "gene_id": row["gene_id"],
1340
+ "symbol": row["symbol"],
1341
+ "uniprot_id": uni,
1342
+ "structure_path": row["alphafold_structure_path"],
1343
+ "design_ranges": design_ranges,
1344
+ "design_range_source": "uniprot_topology" if topo else "not_available",
1345
+ }
1346
+ sites: list[Any] = []
1347
+ lookup_failed = False
1348
+ if client is not None and isinstance(uni, str) and uni:
1349
+ try:
1350
+ found = client.sites(uni)
1351
+ # `score` is optional in the SURFACE-Bind contract -- the loader
1352
+ # reads it with `.get` and sorts unscored sites last -- so a site
1353
+ # can arrive with nothing to compare against the threshold.
1354
+ # Dropping it would let the run report `no_surface_bind_site`,
1355
+ # which is documented as "data present, none for this protein":
1356
+ # a claim about the biology that would not be true. So it is
1357
+ # kept, and the run says so rather than letting the reader
1358
+ # assume every surviving site cleared the bar. Its row carries
1359
+ # `score: None`, which is the same fact on the reader's side.
1360
+ unscored = [s for s in found if s.score is None]
1361
+ if unscored:
1362
+ LOG.warning(
1363
+ "%s: %d SURFACE-Bind site(s) carry no score; "
1364
+ "min_surface_bind_score=%.2f could not be applied to them "
1365
+ "and they are retained unfiltered",
1366
+ uni,
1367
+ len(unscored),
1368
+ p.min_surface_bind_score,
1369
+ )
1370
+ sites = [s for s in found if s.score is None or s.score >= p.min_surface_bind_score]
1371
+ except Exception as e: # malformed vendored data must not abort discovery
1372
+ LOG.warning("SURFACE-Bind lookup failed for %s: %s", uni, e)
1373
+ lookup_failed = True
1374
+ if sites:
1375
+ for s in sites:
1376
+ rows.append(
1377
+ {
1378
+ **base,
1379
+ "site_id": s.site_id,
1380
+ "chain": s.chain,
1381
+ "residues": list(s.residues),
1382
+ "score": s.score,
1383
+ "seed_pdb_path": s.seed_pdb_path,
1384
+ "epitope_status": "surface_bind_site",
1385
+ "mean_epitope_plddt": _epitope_plddt(
1386
+ row["alphafold_structure_path"], list(s.residues)
1387
+ ),
1388
+ "fraction_extracellular": (
1389
+ topo.fraction_extracellular(list(s.residues)) if topo else None
1390
+ ),
1391
+ }
1392
+ )
1393
+ elif lookup_failed or client is None or not p.require_surface_bind_site:
1394
+ # whole-surface fallback (honest status); omitted only when data is
1395
+ # vendored AND a site is required but none exists for this protein.
1396
+ # A lookup that errored is not a biological negative.
1397
+ # ``no_surface_bind_site`` is documented as "data present, none for
1398
+ # this protein" — a finding about the protein. An exception in the
1399
+ # client said nothing about the protein at all, and reporting the
1400
+ # two identically turned an outage into evidence. The project's own
1401
+ # outcomes module states the rule: "a lookup that errored or never
1402
+ # ran is not a scientific negative."
1403
+ if lookup_failed:
1404
+ status = "surface_bind_lookup_failed"
1405
+ elif client is not None:
1406
+ status = "no_surface_bind_site"
1407
+ else:
1408
+ status = "surface_bind_not_configured"
1409
+ rows.append(
1410
+ {
1411
+ **base,
1412
+ "site_id": None,
1413
+ "chain": "A",
1414
+ "residues": [],
1415
+ "score": None,
1416
+ "seed_pdb_path": None,
1417
+ "epitope_status": status,
1418
+ "mean_epitope_plddt": _epitope_plddt(row["alphafold_structure_path"], []),
1419
+ "fraction_extracellular": None,
1420
+ }
1421
+ )
1422
+ return pd.DataFrame(rows) if rows else _empty_epitopes_frame()
1423
+
1424
+
1425
+ def _empty_epitopes_frame() -> pd.DataFrame:
1426
+ return pd.DataFrame(
1427
+ columns=[
1428
+ "gene_id",
1429
+ "symbol",
1430
+ "uniprot_id",
1431
+ "structure_path",
1432
+ "design_ranges",
1433
+ "design_range_source",
1434
+ "site_id",
1435
+ "chain",
1436
+ "residues",
1437
+ "score",
1438
+ "seed_pdb_path",
1439
+ "epitope_status",
1440
+ "mean_epitope_plddt",
1441
+ "fraction_extracellular",
1442
+ ]
1443
+ )